Jaithra Polavarapu commited on
Commit
889dd1b
·
0 Parent(s):

CropIntel — HF Space deploy (all-in-one app)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +13 -0
  2. .env.local.example +10 -0
  3. .eslintrc.json +3 -0
  4. .github/workflows/ci.yml +33 -0
  5. .gitignore +62 -0
  6. Dockerfile +48 -0
  7. Dockerfile.ml +19 -0
  8. GOOGLE_MAPS_SETUP.md +102 -0
  9. ML_Architecture_Explanation.md +117 -0
  10. README copy.md +109 -0
  11. README.md +93 -0
  12. SECURITY_CHANGES_SUMMARY.md +101 -0
  13. app/api/health/route.ts +32 -0
  14. app/api/predict/route.ts +206 -0
  15. app/globals.css +108 -0
  16. app/layout.tsx +36 -0
  17. app/outbreaks/page.tsx +5 -0
  18. app/page.tsx +554 -0
  19. combined_training_logs.csv +81 -0
  20. compare.sh +8 -0
  21. components/CropSelector.tsx +39 -0
  22. components/Diagnosis.tsx +322 -0
  23. components/DiseaseInfo.tsx +121 -0
  24. components/ExportResults.tsx +113 -0
  25. components/FarmerRegistration.tsx +280 -0
  26. components/FarmerVerificationBadge.tsx +33 -0
  27. components/GoogleMap.tsx +385 -0
  28. components/HealthComparisonPanel.tsx +226 -0
  29. components/ImageUpload.tsx +139 -0
  30. components/LeafletMap.tsx +138 -0
  31. components/NotificationSystem.tsx +480 -0
  32. components/OutbreakMap.tsx +297 -0
  33. components/PredictionHistory.tsx +199 -0
  34. components/PredictionResults.tsx +168 -0
  35. components/StateSelector.tsx +38 -0
  36. components/TipsAndGuidelines.tsx +89 -0
  37. components/USMap.tsx +138 -0
  38. components/USOutbreakMap.tsx +418 -0
  39. components/ui/theme-provider.tsx +11 -0
  40. components/ui/world-map.tsx +170 -0
  41. cropintel_model_metrics_overall.csv +5 -0
  42. cropintel_model_metrics_overall.png +0 -0
  43. cropintel_model_metrics_per_class.csv +22 -0
  44. docker-compose.ml.yml +28 -0
  45. docker-compose.prod.yml +42 -0
  46. docker-compose.yml +22 -0
  47. docker/entrypoint.sh +17 -0
  48. docker/supervisord.conf +29 -0
  49. docs/CURSOR_AND_COLLABORATORS.md +5 -0
  50. docs/DEPLOYMENT.md +156 -0
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .git
4
+ .gitignore
5
+ *.md
6
+ !README.md
7
+ ml/data
8
+ .env
9
+ .env.*
10
+ **/__pycache__
11
+ **/*.pyc
12
+ .venv
13
+ venv
.env.local.example ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Google Maps (outbreak map)
2
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_key_here
3
+
4
+ # Phone on same Wi‑Fi — use http:// not https:// (example: http://192.168.0.223:3050)
5
+ # Optional: only if you still see a blank page after fixing firewall / same Wi‑Fi:
6
+ # NEXT_DEV_ALLOWED_ORIGINS=192.168.1.42
7
+ # (Run: ipconfig getifaddr en0 — must match exactly; restart `npm run dev` after changing.)
8
+ #
9
+ # If the outbreak map stays blank on the phone, add an HTTP referrer for that URL in
10
+ # Google Cloud Console → APIs & Services → Credentials → your browser key → Website restrictions.
.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": "next/core-web-vitals"
3
+ }
.github/workflows/ci.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ web:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 20
16
+ cache: npm
17
+ - run: npm ci
18
+ - run: npm run lint
19
+ - run: npx tsc --noEmit
20
+ - run: npm run build
21
+
22
+ python:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.11"
29
+ cache: pip
30
+ cache-dependency-path: ml/requirements-inference.txt
31
+ - run: pip install -r ml/requirements-inference.txt pytest httpx scikit-learn pandas
32
+ # Model weights are not committed; tests marked needs_model auto-skip.
33
+ - run: python -m pytest tests/ -v
.gitignore ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ /.pnp
4
+ .pnp.js
5
+
6
+ # Testing
7
+ /coverage
8
+
9
+ # Next.js
10
+ /.next/
11
+ /out/
12
+
13
+ # Production
14
+ /build
15
+
16
+ # Misc
17
+ .DS_Store
18
+ *.pem
19
+
20
+ # Debug
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
24
+
25
+ # Local env files
26
+ .env*.local
27
+ .env
28
+
29
+ # Vercel
30
+ .vercel
31
+
32
+ # Python
33
+ __pycache__/
34
+ *.py[cod]
35
+ *$py.class
36
+ *.so
37
+ .Python
38
+ env/
39
+ venv/
40
+ ENV/
41
+ .venv
42
+ .conda-py311/
43
+ .venv311/
44
+
45
+ # ML specific
46
+ ml/models/
47
+ ml/models_pretrained/
48
+ ml/data/
49
+ ml/logs/
50
+ uploads/
51
+ # Runtime prediction audit log (the inference service creates data/ at startup)
52
+ /data/
53
+ *.h5
54
+ *.keras
55
+ *.tflite
56
+
57
+ # Training artifacts / release archives (large binaries — distribute out-of-band)
58
+ cropintel-models*.zip
59
+ nohup.out
60
+
61
+ # TS incremental build cache
62
+ tsconfig.tsbuildinfo
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel: Next.js + Python inference (no Kaggle needed if CROPINTEL_MODELS_URL is set)
2
+ FROM python:3.11-slim-bookworm
3
+
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ ca-certificates curl \
6
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
7
+ && apt-get install -y --no-install-recommends nodejs \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ WORKDIR /app
11
+
12
+ COPY package.json package-lock.json* ./
13
+ RUN npm ci
14
+
15
+ COPY ml/requirements-inference.txt ml/
16
+ RUN pip install --no-cache-dir -r ml/requirements-inference.txt supervisor
17
+
18
+ COPY . .
19
+
20
+ # NEXT_PUBLIC_* values are inlined into the client bundle at build time, so the
21
+ # Maps key must be present during `npm run build` (not just at runtime). Passed
22
+ # as a build arg — empty by default, which simply disables the outbreak map.
23
+ # On Hugging Face Spaces, set it as a build-time Variable; with compose, see the
24
+ # build.args block in docker-compose.prod.yml.
25
+ ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=""
26
+ ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
27
+
28
+ RUN npm run build
29
+
30
+ RUN chmod +x docker/entrypoint.sh
31
+
32
+ # Some hosts (e.g. Hugging Face Spaces) run the container as a non-root UID 1000.
33
+ # The image is built as root, so make the dirs written at runtime — model fetch,
34
+ # prediction audit log, and the Next.js server cache — writable by any user.
35
+ RUN mkdir -p ml/models data \
36
+ && chmod -R 777 ml/models data .next
37
+
38
+ ENV NODE_ENV=production
39
+ ENV PYTHONUNBUFFERED=1
40
+ # Inference service bind address. Default 127.0.0.1 keeps it internal (the
41
+ # all-in-one VPS deploy proxies it via the Next.js app). Set to 0.0.0.0 to
42
+ # expose it directly — e.g. on Hugging Face as a backend for a Vercel frontend.
43
+ ENV INFERENCE_BIND_HOST=127.0.0.1
44
+
45
+ EXPOSE 3050
46
+
47
+ ENTRYPOINT ["/app/docker/entrypoint.sh"]
48
+ CMD ["supervisord", "-c", "/app/docker/supervisord.conf"]
Dockerfile.ml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ML training / inference only (Next.js app is not in this image).
2
+ FROM python:3.11-slim-bookworm
3
+
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libgomp1 \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ COPY ml/requirements.txt /app/ml/requirements.txt
11
+ RUN pip install --no-cache-dir -r /app/ml/requirements.txt
12
+
13
+ COPY ml /app/ml
14
+
15
+ ENV PYTHONPATH=/app
16
+ ENV TF_CPP_MIN_LOG_LEVEL=2
17
+
18
+ # Override with e.g. docker compose run ... python -m ml.training.train_crop --crop corn
19
+ CMD ["python", "-m", "ml.scripts.create_synthetic_dataset", "--help"]
GOOGLE_MAPS_SETUP.md ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Google Maps API Setup Guide
2
+
3
+ ## Overview
4
+ The outbreak reporting map now uses Google Maps API for a more accurate and interactive map experience.
5
+
6
+ ## Getting Your Google Maps API Key
7
+
8
+ ### Step 1: Create a Google Cloud Project
9
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
10
+ 2. Click "Select a project" → "New Project"
11
+ 3. Enter a project name (e.g., "CropIntel")
12
+ 4. Click "Create"
13
+
14
+ ### Step 2: Enable Google Maps JavaScript API
15
+ 1. In your project, go to "APIs & Services" → "Library"
16
+ 2. Search for "Maps JavaScript API"
17
+ 3. Click on it and press "Enable"
18
+
19
+ ### Step 3: Create API Key
20
+ 1. Go to "APIs & Services" → "Credentials"
21
+ 2. Click "Create Credentials" → "API Key"
22
+ 3. Copy your API key
23
+ 4. **Important**: Click "Restrict Key" to secure it:
24
+ - Under "Application restrictions", select **"Websites"**
25
+ - Click "Add an item" and add your domains:
26
+ - `http://localhost:3040/*` (for development on port 3040)
27
+ - `http://localhost:*/*` (for all local ports - optional)
28
+ - `https://yourdomain.com/*` (for production - replace with your domain)
29
+ - Under "API restrictions", select "Restrict key"
30
+ - Choose "Maps JavaScript API"
31
+ - Click "Save"
32
+
33
+ ## Configuration
34
+
35
+ ### Step 4: Add API Key to Your Project
36
+ 1. Create or edit `.env.local` in your project root:
37
+ ```bash
38
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_api_key_here
39
+ ```
40
+
41
+ 2. Replace `your_api_key_here` with your actual API key
42
+
43
+ ### Step 5: Restart Development Server
44
+ ```bash
45
+ # Stop the current server (Ctrl+C)
46
+ # Then restart:
47
+ npm run dev -- -p 3040
48
+ ```
49
+
50
+ ## Features
51
+
52
+ ✅ **Interactive Google Maps** - Full Google Maps functionality
53
+ ✅ **Click to Report** - Click anywhere on the map to report outbreaks
54
+ ✅ **Custom Markers** - Color-coded markers based on severity
55
+ ✅ **Info Windows** - Click markers to see outbreak details
56
+ ✅ **Zoom & Pan** - Full map navigation controls
57
+ ✅ **Accurate Locations** - Real geographic coordinates
58
+
59
+ ## Fallback Behavior
60
+
61
+ If no API key is configured:
62
+ - The map will show an error message
63
+ - You'll be prompted to add the API key
64
+ - The app will still function, but the map won't load
65
+
66
+ ## Cost Information
67
+
68
+ Google Maps JavaScript API has a free tier:
69
+ - **$200 free credit per month**
70
+ - After free credit: ~$7 per 1,000 map loads
71
+ - For most development/testing: **FREE**
72
+
73
+ ## Security Notes
74
+
75
+ ⚠️ **Important**:
76
+ - Never commit your API key to git (`.env.local` is already in `.gitignore`)
77
+ - Always restrict your API key to specific domains
78
+ - Use different keys for development and production
79
+ - Monitor your API usage in Google Cloud Console
80
+
81
+ ## Troubleshooting
82
+
83
+ ### Map Not Loading?
84
+ 1. Check that `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` is set in `.env.local`
85
+ 2. Restart the dev server after adding the key
86
+ 3. Verify the API key is not restricted incorrectly
87
+ 4. Check browser console for specific error messages
88
+
89
+ ### "This page can't load Google Maps correctly"?
90
+ - Your API key might not be restricted correctly
91
+ - Make sure `http://localhost:3040/*` is added under "Websites" restrictions
92
+ - Check that "Maps JavaScript API" is enabled
93
+ - Verify you're using `http://` (not `https://`) for localhost
94
+
95
+ ### API Key Invalid?
96
+ - Verify you copied the full key (no spaces)
97
+ - Check that the API is enabled in Google Cloud Console
98
+ - Ensure billing is enabled (free tier still requires billing account)
99
+
100
+ ## Need Help?
101
+
102
+ Check the [Google Maps JavaScript API Documentation](https://developers.google.com/maps/documentation/javascript)
ML_Architecture_Explanation.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel Machine Learning Architecture
2
+
3
+ ## Overview
4
+
5
+ CropIntel employs a sophisticated deep learning pipeline built on EfficientNet architecture to achieve accurate, real-time crop disease detection. The system processes crop leaf images through multiple stages, transforming raw pixel data into actionable disease diagnoses with confidence scores.
6
+
7
+ ## Architecture Pipeline
8
+
9
+ ### Stage 1: Image Acquisition & Input Processing
10
+
11
+ **Input Format**: The system accepts crop leaf images in common formats (JPEG, PNG) uploaded by farmers through a web interface. Images can vary in resolution, quality, and environmental conditions (field photos, laboratory images, different lighting conditions).
12
+
13
+ **Initial Validation**: Upon upload, the system performs basic validation checks including file format verification, size constraints (typically 5-10MB maximum), and basic image integrity checks to ensure the file is not corrupted.
14
+
15
+ ### Stage 2: Preprocessing Pipeline
16
+
17
+ The preprocessing stage standardizes input images for optimal model performance:
18
+
19
+ **Format Normalization**: All images are converted to a standardized RGB format, ensuring consistent color space representation regardless of input format variations.
20
+
21
+ **Resolution Standardization**: Images are resized to 224×224 pixels, the standard input size for EfficientNet models. The resizing process maintains aspect ratio using intelligent cropping or padding to prevent distortion of critical disease features.
22
+
23
+ **Color Normalization**: RGB pixel values are normalized from the standard 0-255 range to a 0-1 floating-point range. This normalization improves neural network training stability and convergence speed.
24
+
25
+ **Quality Enhancement**: The preprocessing pipeline applies subtle enhancements including contrast adjustment, noise reduction, and brightness normalization to improve feature visibility while preserving authentic disease characteristics.
26
+
27
+ **Data Augmentation (Training Phase)**: During model training, additional augmentation techniques are applied including random rotation (±15°), horizontal flipping, brightness/contrast variation, and color jittering to improve model generalization across diverse real-world conditions.
28
+
29
+ ### Stage 3: EfficientNet-B0 Convolutional Neural Network
30
+
31
+ **Architecture Selection**: EfficientNet-B0 was selected as the core architecture due to its superior balance between accuracy and computational efficiency. The model achieves state-of-the-art performance while maintaining inference speeds suitable for real-time web applications.
32
+
33
+ **Transfer Learning Foundation**: The model leverages transfer learning, initialized with weights pre-trained on ImageNet (1.4 million images, 1000 classes). This foundation provides robust feature extraction capabilities learned from diverse visual patterns, which are then fine-tuned for agricultural disease detection.
34
+
35
+ **Feature Extraction Layers**: The convolutional backbone consists of multiple Mobile Inverted Bottleneck Convolution (MBConv) blocks that progressively extract hierarchical features:
36
+ - **Early Layers (Blocks 1-3)**: Detect low-level features including edges, textures, and basic color patterns
37
+ - **Middle Layers (Blocks 4-6)**: Identify complex patterns such as leaf structures, lesion shapes, and discoloration patterns
38
+ - **Deep Layers (Blocks 7-9)**: Recognize high-level semantic features including disease-specific characteristics, symptom combinations, and spatial relationships
39
+
40
+ **Global Average Pooling**: After feature extraction, spatial dimensions are reduced through Global Average Pooling, which converts the feature maps into a fixed-size vector while preserving critical feature information. This operation reduces model parameters and prevents overfitting.
41
+
42
+ **Classification Head**: The pooled features pass through fully connected layers that map extracted features to disease categories. The final layer uses softmax activation to produce a probability distribution across all possible disease classes for the specific crop type.
43
+
44
+ **TensorFlow Lite Conversion**: After training, Keras models are converted to TensorFlow Lite format using TFLiteConverter. This conversion optimizes the model for production deployment by:
45
+ - **Quantization**: Reducing model size through 8-bit integer quantization (optional)
46
+ - **Optimization**: Applying graph optimizations for faster inference
47
+ - **Mobile/Edge Ready**: Enabling deployment on mobile devices and edge computing platforms
48
+ - **Reduced Memory**: Smaller model footprint (~5-10MB vs ~20MB for Keras format)
49
+ - **Faster Inference**: Optimized operations for production environments
50
+
51
+ ### Stage 4: Crop-Specific Model Routing
52
+
53
+ **Multi-Model Architecture**: Rather than a single universal model, CropIntel employs crop-specific models (one each for corn, soybean, wheat, and rice). This specialization allows each model to focus on disease patterns specific to that crop, improving accuracy.
54
+
55
+ **Model Selection**: Based on user-selected crop type, the system routes the preprocessed image to the appropriate specialized TensorFlow Lite model. The TFLitePredictor class loads the corresponding .tflite model file and uses the TensorFlow Lite Interpreter for inference, ensuring optimal disease detection for each agricultural context.
56
+
57
+ **TensorFlow Lite Inference**: The prediction process uses TensorFlow Lite's optimized interpreter:
58
+ - **Model Loading**: TFLite models are loaded into memory using `tf.lite.Interpreter`
59
+ - **Input Tensor Setup**: Preprocessed images are set as input tensors with proper dtype conversion
60
+ - **Invoke Inference**: The interpreter's `invoke()` method executes optimized inference operations
61
+ - **Output Extraction**: Probability distributions are extracted from output tensors
62
+ - **Fallback Support**: System can fall back to Keras models if TFLite models are unavailable
63
+
64
+ ### Stage 5: Post-Processing & Confidence Scoring
65
+
66
+ **Confidence Calculation**: The model outputs probability scores for each disease class. The maximum probability becomes the prediction confidence score, ranging from 0% to 100%.
67
+
68
+ **Threshold Filtering**: Predictions below a minimum confidence threshold (typically 60%) are flagged as uncertain. Low-confidence predictions trigger user warnings and recommendations to capture additional images or consult experts.
69
+
70
+ **Health Status Determination**: If no disease class exceeds the confidence threshold, the crop is classified as "healthy." This binary health assessment provides immediate actionable information.
71
+
72
+ **Severity Assessment**: Based on the detected disease and confidence level, the system assigns severity ratings (low, medium, high) to guide treatment urgency and resource allocation.
73
+
74
+ ### Stage 6: Result Generation & Output
75
+
76
+ **Structured Output**: The system generates comprehensive results including:
77
+ - **Disease Identification**: Specific disease name with scientific accuracy
78
+ - **Confidence Percentage**: Numerical confidence score (e.g., "87% confident")
79
+ - **Health Status**: Binary healthy/diseased classification
80
+ - **Treatment Recommendations**: Evidence-based treatment options specific to the detected disease
81
+ - **Prevention Strategies**: Long-term prevention measures to reduce future risk
82
+ - **Severity Level**: Risk assessment (low/medium/high) for treatment prioritization
83
+
84
+ **Response Time**: The entire pipeline, from image upload to result display, completes in under 2 seconds, enabling real-time field decision-making.
85
+
86
+ ## Model Training & Optimization
87
+
88
+ **Dataset Composition**: Models are trained on curated datasets containing thousands of labeled crop disease images. Datasets are balanced across disease classes to prevent model bias toward common conditions.
89
+
90
+ **Training Methodology**: Fine-tuning employs a two-phase approach: (1) freezing early layers to preserve general feature extraction, (2) training final layers with agricultural disease data. This approach leverages ImageNet knowledge while adapting to domain-specific patterns.
91
+
92
+ **Hyperparameter Optimization**: Learning rates, batch sizes, and regularization parameters are tuned through systematic experimentation. Early stopping prevents overfitting by monitoring validation loss.
93
+
94
+ **Performance Metrics**: Models achieve 87-92% accuracy across crop types, with precision and recall balanced to minimize both false positives (unnecessary treatments) and false negatives (missed diseases).
95
+
96
+ ## Technical Specifications
97
+
98
+ - **Framework**: TensorFlow Lite (TFLite) for production inference
99
+ - **Model Format**: Optimized TensorFlow Lite models (.tflite) converted from Keras models
100
+ - **Model Size**: EfficientNet-B0 (~5.3M parameters, ~5-10MB in TFLite format)
101
+ - **Input Dimensions**: 224×224×3 (RGB)
102
+ - **Inference Engine**: TensorFlow Lite Interpreter for optimized runtime performance
103
+ - **Inference Speed**: <2 seconds per image
104
+ - **Accuracy Range**: 87-92% depending on crop type
105
+ - **Supported Crops**: Corn, Soybean, Wheat, Rice (expandable)
106
+
107
+ ## Innovation & Advantages
108
+
109
+ **Efficiency**: EfficientNet architecture provides superior accuracy-to-efficiency ratio compared to traditional CNNs, enabling real-time performance on standard hardware.
110
+
111
+ **Accessibility**: Optimized model size and inference speed make advanced AI accessible to farmers using basic smartphones and standard internet connections.
112
+
113
+ **Scalability**: Modular architecture allows easy addition of new crop types and diseases without retraining entire models.
114
+
115
+ **Reliability**: Transfer learning foundation provides robust feature extraction, while crop-specific fine-tuning ensures domain accuracy.
116
+
117
+ This architecture represents a production-ready, scalable solution that brings state-of-the-art AI capabilities to agricultural disease detection, balancing accuracy, speed, and accessibility for real-world deployment.
README copy.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel - AI-Powered Crop Disease Classification
2
+
3
+ A modern web application for detecting crop diseases using deep learning.
4
+
5
+ ## Features
6
+
7
+ - 🌾 Support for multiple crops (Corn, Soybean, Wheat, Rice)
8
+ - 🤖 AI-powered disease detection using EfficientNet
9
+ - 📱 Modern, responsive web interface built with Next.js
10
+ - ⚡ Fast inference using TensorFlow Lite
11
+ - 🎨 Beautiful UI with Tailwind CSS
12
+
13
+ ## Prerequisites
14
+
15
+ - Python 3.8+
16
+ - Node.js 18+
17
+ - npm or yarn
18
+
19
+ ## Installation
20
+
21
+ ### 1. Install Python Dependencies
22
+
23
+ ```bash
24
+ pip install -r ml/requirements.txt
25
+ ```
26
+
27
+ ### 2. Install Node.js Dependencies
28
+
29
+ ```bash
30
+ npm install
31
+ ```
32
+
33
+ ## Running the Application
34
+
35
+ ### Development Mode
36
+
37
+ 1. Start the Next.js development server:
38
+
39
+ ```bash
40
+ npm run dev
41
+ ```
42
+
43
+ 2. Open [http://localhost:3000](http://localhost:3000) in your browser
44
+
45
+ ### Production Mode
46
+
47
+ 1. Build the application:
48
+
49
+ ```bash
50
+ npm run build
51
+ ```
52
+
53
+ 2. Start the production server:
54
+
55
+ ```bash
56
+ npm start
57
+ ```
58
+
59
+ ## Training Models
60
+
61
+ Before using the web app, you need to train models for the crops you want to classify:
62
+
63
+ ```bash
64
+ # Train a single crop model
65
+ python3 -m ml.training.train_crop --crop corn --epochs 40
66
+
67
+ # Train all crops
68
+ python3 -m ml.training.train_all_crops --epochs 40
69
+ ```
70
+
71
+ ## Project Structure
72
+
73
+ ```
74
+ cropintel/
75
+ ├── app/ # Next.js app directory
76
+ │ ├── api/ # API routes
77
+ │ ├── globals.css # Global styles
78
+ │ ├── layout.tsx # Root layout
79
+ │ └── page.tsx # Home page
80
+ ├── components/ # React components
81
+ │ ├── ImageUpload.tsx
82
+ │ ├── CropSelector.tsx
83
+ │ └── PredictionResults.tsx
84
+ ├── ml/ # Machine learning code
85
+ │ ├── training/ # Training scripts
86
+ │ ├── inference/ # Inference modules
87
+ │ └── utils/ # Utilities
88
+ ├── scripts/ # Utility scripts
89
+ │ └── predict.py # Python prediction script
90
+ └── package.json # Node.js dependencies
91
+ ```
92
+
93
+ ## Usage
94
+
95
+ 1. Upload a crop leaf image
96
+ 2. Select the crop type (Corn, Soybean, Wheat, or Rice)
97
+ 3. Click "Analyze Disease"
98
+ 4. View the prediction results with confidence scores
99
+
100
+ ## Technologies
101
+
102
+ - **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
103
+ - **Backend**: Python, Flask (via API route)
104
+ - **ML**: TensorFlow, EfficientNet, TensorFlow Lite
105
+ - **Image Processing**: PIL/Pillow
106
+
107
+ ## License
108
+
109
+ MIT
README.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CropIntel
3
+ emoji: 🌾
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 3050
8
+ pinned: false
9
+ ---
10
+
11
+ <!-- The YAML block above configures the Hugging Face Space (Docker SDK,
12
+ public port 3050). Required by HF; ignored by GitHub. See docs/DEPLOYMENT.md. -->
13
+
14
+ # CropIntel
15
+
16
+ Crop leaf-disease classifier for 5 crops (corn, soybean, wheat, rice, tomato),
17
+ EfficientNetB0 → TensorFlow Lite, served behind a Next.js UI. One Docker
18
+ container runs the web app and a persistent Python inference service together.
19
+
20
+ ## Quick start (run the whole thing)
21
+
22
+ You do **not** need Kaggle, training, or any model files — the trained models
23
+ (~38 MB) are fetched automatically from the GitHub Release on first start.
24
+
25
+ ```bash
26
+ git clone https://github.com/rakshithj09/CropIntel.git
27
+ cd CropIntel
28
+ docker compose -f docker-compose.prod.yml up -d --build
29
+ curl -fsS http://localhost:3050/api/health # {"web":"ok","inference":{"ready":true,...}}
30
+ ```
31
+
32
+ Open [http://localhost:3050](http://localhost:3050). That's it.
33
+
34
+ Optional environment (drop a `.env` next to the compose file):
35
+
36
+ ```bash
37
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=... # only for the outbreak map
38
+ CROPINTEL_ADMIN_TOKEN=$(openssl rand -hex 16) # only to guard POST /admin/reload
39
+ CROPINTEL_MODELS_URL=... # override the default v1 model bundle
40
+ ```
41
+
42
+ For a real domain + TLS, monitoring, and model promotion/rollback, see
43
+ [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
44
+
45
+ ## Local development (no Docker)
46
+
47
+ The web app forwards predictions to the inference service, so run both:
48
+
49
+ ```bash
50
+ # 1) fetch models once (into ml/models/, gitignored)
51
+ pip install -r ml/requirements-inference.txt
52
+ export CROPINTEL_MODELS_URL='https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip'
53
+ python3 -m ml.scripts.fetch_models
54
+
55
+ # 2) start the inference service (terminal A)
56
+ python3 -m uvicorn ml.serve.inference_app:app --host 127.0.0.1 --port 8000
57
+
58
+ # 3) start the web app (terminal B)
59
+ npm install && npm run dev
60
+ ```
61
+
62
+ Open [http://localhost:3050](http://localhost:3050). The UI calls `/api/predict`,
63
+ which forwards to the inference service at `INFERENCE_URL` (default
64
+ `http://127.0.0.1:8000`).
65
+
66
+ ## Train it yourself (needs Kaggle data)
67
+
68
+ See [ml/README.md](ml/README.md) for the Kaggle API setup and training scripts
69
+ (`pip install -r ml/requirements.txt`). Models are gated on an **external**
70
+ (out-of-distribution) eval before promotion — see
71
+ `ml/scripts/test_external.py` and `ml/scripts/promote_model.py`.
72
+
73
+ ## Maintainer: ship updated models
74
+
75
+ After training/promoting, repackage and replace the release bundle:
76
+
77
+ ```bash
78
+ python3 -m ml.scripts.package_models --tflite-only -o cropintel-models-mobile.zip
79
+ gh release upload v1 cropintel-models-mobile.zip -R rakshithj09/CropIntel --clobber
80
+ # on a running server: rm ml/models/.cropintel-fetch-ok && docker compose -f docker-compose.prod.yml restart
81
+ ```
82
+
83
+ ## Project layout
84
+
85
+ - `app/` — Next.js UI + `/api/predict` (forwards to the inference service) + `/api/health`
86
+ - `ml/serve/inference_app.py` — FastAPI inference service (loads every crop model once)
87
+ - `ml/` — training (`training/`), predictors (`inference/`), config, scripts
88
+ - `docker-compose.prod.yml`, `docker/`, `docs/DEPLOYMENT.md` — production deploy
89
+ - `tests/` — pytest suite (`.github/workflows/ci.yml` runs web + Python checks)
90
+
91
+ ## License
92
+
93
+ See repository.
SECURITY_CHANGES_SUMMARY.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Hardening - Implementation Complete ✅
2
+
3
+ ## Summary
4
+
5
+ Security measures follow OWASP best practices. The application is hardened against common vulnerabilities while maintaining backward compatibility for core features.
6
+
7
+ ## ✅ Completed Security Measures
8
+
9
+ ### 1. Rate Limiting
10
+ - **File**: `lib/security/rateLimiter.ts`
11
+ - IP-based rate limiting for API routes
12
+ - Configurable thresholds (e.g. 20/min for predictions)
13
+ - HTTP 429 responses with Retry-After headers
14
+ - Rate limit status headers in responses
15
+
16
+ ### 2. Input Validation & Sanitization
17
+ - **File**: `lib/security/validation.ts`
18
+ - Zod schema-based validation
19
+ - Strict type checking
20
+ - File upload validation (size, type, content)
21
+ - Filename sanitization (path traversal prevention)
22
+ - Crop type whitelist validation
23
+
24
+ ### 3. File Upload Security
25
+ - **File**: `app/api/predict/route.ts`
26
+ - 10MB file size limit
27
+ - MIME type whitelist validation
28
+ - Filename sanitization
29
+ - Temporary file cleanup
30
+ - Server-side content validation
31
+
32
+ ### 4. Security Headers
33
+ - **Files**:
34
+ - `lib/security/headers.ts`
35
+ - `middleware.ts` (applies to all routes)
36
+ - Content-Security-Policy
37
+ - X-Frame-Options
38
+ - X-Content-Type-Options
39
+ - Referrer-Policy
40
+ - Permissions-Policy
41
+ - Strict-Transport-Security (production)
42
+
43
+ ### 5. Error Handling
44
+ - Generic error messages (no information disclosure)
45
+ - Detailed errors logged server-side only
46
+ - Secure error responses
47
+
48
+ ## 📁 Security-related files
49
+
50
+ ```
51
+ lib/security/
52
+ ├── rateLimiter.ts # Rate limiting middleware
53
+ ├── validation.ts # Input validation schemas
54
+ └── headers.ts # Security headers
55
+
56
+ middleware.ts # Security headers middleware
57
+ SECURITY_IMPLEMENTATION.md # Detailed documentation
58
+ ```
59
+
60
+ ## 🔄 Modified core files
61
+
62
+ - `app/api/predict/route.ts` — Security measures for ML prediction uploads
63
+ - `next.config.js` — Disabled X-Powered-By header (if configured)
64
+
65
+ ## 🔐 Environment variables
66
+
67
+ **`.env.local` example** (see `.env.local.example`):
68
+
69
+ ```bash
70
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_key
71
+ ```
72
+
73
+ ## ✅ OWASP alignment
74
+
75
+ | Requirement | Status | Implementation |
76
+ |------------|--------|----------------|
77
+ | Rate Limiting | ✅ | IP-based, configurable thresholds |
78
+ | Input Validation | ✅ | Schema-based, strict validation |
79
+ | File Upload Security | ✅ | Size limits, type validation, sanitization |
80
+ | Security Headers | ✅ | CSP, X-Frame-Options, etc. |
81
+ | Error Handling | ✅ | No information disclosure |
82
+
83
+ ## 🧪 Testing
84
+
85
+ 1. **Rate limiting**: Send 21+ rapid requests to `/api/predict`
86
+ 2. **Input validation**: Send invalid crop type or oversized file
87
+
88
+ ## 📚 Documentation
89
+
90
+ - **SECURITY_IMPLEMENTATION.md**: Security documentation
91
+ - **SECURITY.md**: Security analysis (if present)
92
+
93
+ ## 🚀 Next steps
94
+
95
+ 1. Test rate limiting and validation
96
+ 2. Review CSP for your deployment (CDNs, analytics, etc.)
97
+ 3. Consider Redis for rate limiting in production (multi-instance)
98
+
99
+ ---
100
+
101
+ **Status**: ✅ Complete
app/api/health/route.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Health API Route
3
+ *
4
+ * Single URL for the compose healthcheck, uptime monitors, and humans.
5
+ * Reports web-tier liveness plus the inference service's readiness
6
+ * (per-crop model load status from /readyz).
7
+ */
8
+
9
+ import { createSecureResponse } from '@/lib/security/headers'
10
+
11
+ const INFERENCE_URL = process.env.INFERENCE_URL || 'http://127.0.0.1:8000'
12
+
13
+ export async function GET() {
14
+ let inference: any = null
15
+ let healthy = false
16
+
17
+ try {
18
+ const upstream = await fetch(`${INFERENCE_URL}/readyz`, {
19
+ signal: AbortSignal.timeout(5_000),
20
+ cache: 'no-store',
21
+ })
22
+ inference = await upstream.json()
23
+ healthy = upstream.ok
24
+ } catch {
25
+ inference = { ready: false, error: 'inference service unreachable' }
26
+ }
27
+
28
+ return createSecureResponse(
29
+ { web: 'ok', inference },
30
+ healthy ? 200 : 503
31
+ )
32
+ }
app/api/predict/route.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Prediction API Route
3
+ *
4
+ * Secure API endpoint for crop disease prediction.
5
+ * Implements comprehensive security measures following OWASP best practices.
6
+ *
7
+ * Security Features:
8
+ * - Rate limiting (IP-based)
9
+ * - Input validation and sanitization
10
+ * - File upload security (size limits, type validation)
11
+ * - Security headers
12
+ * - Secure error handling
13
+ *
14
+ * OWASP Compliance:
15
+ * - A01:2021 (Broken Access Control) - Rate limiting
16
+ * - A03:2021 (Injection) - Input validation
17
+ * - A05:2021 (Security Misconfiguration) - Security headers
18
+ * - A07:2021 (Identification and Authentication Failures) - Input validation
19
+ *
20
+ * Inference is served by the persistent Python service (ml/serve/inference_app.py)
21
+ * over localhost HTTP — models stay loaded in memory between requests.
22
+ */
23
+
24
+ import { NextRequest } from 'next/server'
25
+ import { rateLimit, getRateLimitHeaders } from '@/lib/security/rateLimiter'
26
+ import { validatePredictionRequest } from '@/lib/security/validation'
27
+ import { createSecureResponse, addSecurityHeaders } from '@/lib/security/headers'
28
+ import { ZodError } from 'zod'
29
+
30
+ /**
31
+ * Maximum file size: 10MB
32
+ * Prevents DoS attacks via large file uploads
33
+ */
34
+ const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
35
+
36
+ /**
37
+ * Allowed image MIME types (whitelist approach)
38
+ * Prevents malicious file uploads
39
+ */
40
+ const ALLOWED_MIME_TYPES = [
41
+ 'image/jpeg',
42
+ 'image/jpg',
43
+ 'image/png',
44
+ 'image/webp',
45
+ 'image/gif',
46
+ ]
47
+
48
+ /** Base URL of the Python inference service (never exposed publicly). */
49
+ const INFERENCE_URL = process.env.INFERENCE_URL || 'http://127.0.0.1:8000'
50
+
51
+ /** Upstream timeout — model inference is fast; this guards a hung service. */
52
+ const INFERENCE_TIMEOUT_MS = 30_000
53
+
54
+ /**
55
+ * Validate file content by checking MIME type
56
+ * Additional security layer beyond client-side validation
57
+ *
58
+ * @param file - File object to validate
59
+ * @returns true if file is valid image, false otherwise
60
+ */
61
+ function validateFileContent(file: File): boolean {
62
+ // Check MIME type against whitelist
63
+ if (!ALLOWED_MIME_TYPES.includes(file.type)) {
64
+ return false
65
+ }
66
+
67
+ // Check file size
68
+ if (file.size > MAX_FILE_SIZE) {
69
+ return false
70
+ }
71
+
72
+ // Check file is not empty
73
+ if (file.size === 0) {
74
+ return false
75
+ }
76
+
77
+ return true
78
+ }
79
+
80
+ export async function POST(request: NextRequest) {
81
+ // ========== RATE LIMITING ==========
82
+ // Apply rate limiting before processing request
83
+ // OWASP: Fail securely by blocking excessive requests
84
+ const rateLimitResponse = rateLimit(request, '/api/predict')
85
+ if (rateLimitResponse) {
86
+ return addSecurityHeaders(rateLimitResponse)
87
+ }
88
+
89
+ try {
90
+ // ========== INPUT VALIDATION ==========
91
+ // Parse and validate form data using schema-based validation
92
+ // OWASP: Prevents injection attacks via strict validation
93
+ const formData = await request.formData()
94
+
95
+ let validatedData
96
+ try {
97
+ validatedData = await validatePredictionRequest(formData)
98
+ } catch (error) {
99
+ // Handle validation errors gracefully
100
+ if (error instanceof ZodError) {
101
+ const errorMessages = error.issues.map((e) => e.message).join(', ')
102
+ return createSecureResponse(
103
+ { error: `Validation failed: ${errorMessages}` },
104
+ 400
105
+ )
106
+ }
107
+ throw error // Re-throw unexpected errors
108
+ }
109
+
110
+ const { image, crop } = validatedData
111
+
112
+ // ========== FILE CONTENT VALIDATION ==========
113
+ // Additional server-side validation beyond schema validation
114
+ // OWASP: Defense in depth - multiple validation layers
115
+ if (!validateFileContent(image)) {
116
+ return createSecureResponse(
117
+ {
118
+ error: 'Invalid file. Must be a valid image (JPEG, PNG, WebP, GIF) under 10MB.',
119
+ },
120
+ 400
121
+ )
122
+ }
123
+
124
+ // ========== INFERENCE SERVICE CALL ==========
125
+ // Forward the validated upload to the persistent inference service.
126
+ const upstreamForm = new FormData()
127
+ upstreamForm.append('image', image)
128
+ upstreamForm.append('crop', crop)
129
+
130
+ let upstream: Response
131
+ try {
132
+ upstream = await fetch(`${INFERENCE_URL}/predict`, {
133
+ method: 'POST',
134
+ body: upstreamForm,
135
+ signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS),
136
+ })
137
+ } catch (error) {
138
+ // Service down or timed out — operators should check the inference process.
139
+ console.error('Inference service unreachable:', error)
140
+ return createSecureResponse(
141
+ { error: 'Prediction service unavailable. Please try again shortly.' },
142
+ 503
143
+ )
144
+ }
145
+
146
+ let result: any
147
+ try {
148
+ result = await upstream.json()
149
+ } catch {
150
+ console.error('Inference service returned non-JSON, status:', upstream.status)
151
+ return createSecureResponse(
152
+ { error: 'Prediction failed. Please try again later.' },
153
+ 500
154
+ )
155
+ }
156
+
157
+ if (!upstream.ok) {
158
+ const errorMessage: string = result?.error || 'Prediction failed'
159
+ const msg = errorMessage.toLowerCase()
160
+
161
+ // User-actionable image problems (quality checks) — pass through as-is.
162
+ if (
163
+ msg.includes('retake the image') ||
164
+ msg.includes('clear plant leaf') ||
165
+ msg.includes('appears blurry')
166
+ ) {
167
+ return createSecureResponse({ error: errorMessage }, 400)
168
+ }
169
+
170
+ // Model-not-ready — tell operators to train/fetch models.
171
+ if (msg.includes('no trained models found') || msg.includes('model not found')) {
172
+ return createSecureResponse(
173
+ { error: 'Model not ready. Please train or install a model for this crop before running analysis.' },
174
+ 503
175
+ )
176
+ }
177
+
178
+ console.error('Unhandled inference error:', upstream.status, errorMessage)
179
+ return createSecureResponse(
180
+ { error: 'Prediction failed. Please try again later.' },
181
+ upstream.status >= 500 ? 500 : 400
182
+ )
183
+ }
184
+
185
+ // ========== SUCCESS RESPONSE ==========
186
+ // Return result with security headers and rate limit info
187
+ const response = createSecureResponse(result, 200)
188
+
189
+ // Add rate limit headers to successful response
190
+ const rateLimitHeaders = getRateLimitHeaders(request, '/api/predict')
191
+ Object.entries(rateLimitHeaders).forEach(([key, value]) => {
192
+ response.headers.set(key, value)
193
+ })
194
+
195
+ return response
196
+ } catch (error: any) {
197
+ // ========== ERROR HANDLING ==========
198
+ // Log detailed error server-side but return generic message to client
199
+ // OWASP: Prevent information disclosure
200
+ console.error('Prediction error:', error)
201
+ return createSecureResponse(
202
+ { error: 'Prediction failed. Please try again later.' },
203
+ 500
204
+ )
205
+ }
206
+ }
app/globals.css ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* Hide Google Maps "For development purposes only" watermark */
6
+ .gm-style-cc,
7
+ .gm-style-cc div,
8
+ .gm-style-cc a,
9
+ a[href^="https://developers.google.com/maps"],
10
+ .gm-bundled-control .gm-style-cc,
11
+ div[title*="For development purposes only"] {
12
+ display: none !important;
13
+ visibility: hidden !important;
14
+ opacity: 0 !important;
15
+ height: 0 !important;
16
+ width: 0 !important;
17
+ }
18
+
19
+ /* Hide Google Maps default POI marker images only (scope to .gm-style — unscoped img rules break non-map UI) */
20
+ .gm-style img[src*="poi"],
21
+ .gm-style img[src*="place"],
22
+ .gm-style img[src*="marker"][src*="default"] {
23
+ display: none !important;
24
+ visibility: hidden !important;
25
+ opacity: 0 !important;
26
+ height: 0 !important;
27
+ width: 0 !important;
28
+ pointer-events: none !important;
29
+ }
30
+
31
+ /* Leaflet map styles */
32
+ .leaflet-container {
33
+ height: 100%;
34
+ width: 100%;
35
+ z-index: 0;
36
+ }
37
+
38
+ .leaflet-popup-content-wrapper {
39
+ border-radius: 8px;
40
+ }
41
+
42
+ .custom-marker {
43
+ background: transparent !important;
44
+ border: none !important;
45
+ }
46
+
47
+ :root {
48
+ --foreground-rgb: 0, 0, 0;
49
+ --background-start-rgb: 214, 219, 220;
50
+ --background-end-rgb: 255, 255, 255;
51
+ }
52
+
53
+ * {
54
+ @apply antialiased;
55
+ }
56
+
57
+ body {
58
+ color: rgb(var(--foreground-rgb));
59
+ background:
60
+ radial-gradient(1200px 600px at 20% 0%, rgba(112, 152, 112, 0.18), transparent 55%),
61
+ radial-gradient(900px 500px at 90% 10%, rgba(120, 160, 120, 0.16), transparent 60%),
62
+ linear-gradient(180deg, #ffffff 0%, #f8fafc 55%, #f1f5f9 100%);
63
+ min-height: 100vh;
64
+ min-height: 100dvh;
65
+ overflow-x: hidden;
66
+ padding-left: env(safe-area-inset-left);
67
+ padding-right: env(safe-area-inset-right);
68
+ }
69
+
70
+ /* Ensure images display correctly */
71
+ img {
72
+ display: block !important;
73
+ max-width: 100%;
74
+ height: auto;
75
+ object-fit: contain;
76
+ }
77
+
78
+ /* Prevent images from showing as broken icons */
79
+ img[src=""],
80
+ img:not([src]) {
81
+ display: none !important;
82
+ }
83
+
84
+ /* Ensure image containers don't collapse */
85
+ .image-container,
86
+ [class*="image"] {
87
+ min-height: 100px;
88
+ }
89
+
90
+ @layer utilities {
91
+ .text-balance {
92
+ text-wrap: balance;
93
+ }
94
+
95
+ .glass {
96
+ background: rgba(255, 255, 255, 0.95);
97
+ backdrop-filter: blur(10px);
98
+ border: 1px solid rgba(255, 255, 255, 0.2);
99
+ }
100
+
101
+ .gradient-text {
102
+ @apply bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-blue-600;
103
+ }
104
+
105
+ .surface {
106
+ @apply bg-white border border-slate-200/80 shadow-sm;
107
+ }
108
+ }
app/layout.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata, Viewport } from 'next'
2
+ import './globals.css'
3
+ import { ThemeProvider } from '@/components/ui/theme-provider'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'CropIntel - AI-Powered Crop Disease Classification',
7
+ description: 'Upload crop leaf images to detect diseases using AI',
8
+ }
9
+
10
+ export const viewport: Viewport = {
11
+ width: 'device-width',
12
+ initialScale: 1,
13
+ viewportFit: 'cover',
14
+ themeColor: '#f8fafc',
15
+ }
16
+
17
+ export default function RootLayout({
18
+ children,
19
+ }: {
20
+ children: React.ReactNode
21
+ }) {
22
+ return (
23
+ <html lang="en" suppressHydrationWarning>
24
+ <body>
25
+ <ThemeProvider
26
+ attribute="class"
27
+ defaultTheme="light"
28
+ enableSystem
29
+ disableTransitionOnChange
30
+ >
31
+ {children}
32
+ </ThemeProvider>
33
+ </body>
34
+ </html>
35
+ )
36
+ }
app/outbreaks/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import OutbreakMap from '@/components/OutbreakMap'
2
+
3
+ export default function OutbreaksPage() {
4
+ return <OutbreakMap />
5
+ }
app/page.tsx ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import Image from 'next/image'
6
+ import { Bell, MapPin, Sparkles, History as HistoryIcon, ArrowRight, Loader2, Camera, ArrowLeftRight } from 'lucide-react'
7
+ import ImageUpload from '@/components/ImageUpload'
8
+ import CropSelector from '@/components/CropSelector'
9
+ import StateSelector from '@/components/StateSelector'
10
+ import PredictionResults from '@/components/PredictionResults'
11
+ import DiseaseInfo from '@/components/DiseaseInfo'
12
+ import PredictionHistory from '@/components/PredictionHistory'
13
+ import ExportResults from '@/components/ExportResults'
14
+ import TipsAndGuidelines from '@/components/TipsAndGuidelines'
15
+ import Diagnosis from '@/components/Diagnosis'
16
+ import NotificationSystem from '@/components/NotificationSystem'
17
+ import FarmerRegistration from '@/components/FarmerRegistration'
18
+ import FarmerVerificationBadge from '@/components/FarmerVerificationBadge'
19
+ import HealthComparisonPanel from '@/components/HealthComparisonPanel'
20
+ import { savePredictionToHistory } from '@/components/PredictionHistory'
21
+ import { CROPS } from '@/lib/crops'
22
+ import type { OutbreakReport } from '@/lib/outbreakReport'
23
+ import { loadFarmerProfile, saveFarmerProfile, type StoredFarmerProfile } from '@/lib/farmerProfile'
24
+ import {
25
+ applyRegionalPrior,
26
+ getRelevantDiseasesForCropState,
27
+ type PredictionPayload,
28
+ } from '@/lib/stateDiseaseMap'
29
+
30
+ const USOutbreakMap = dynamic(() => import('@/components/USOutbreakMap'), {
31
+ ssr: false,
32
+ loading: () => (
33
+ <div className="flex min-h-[280px] w-full items-center justify-center rounded-xl border border-slate-200 bg-slate-50 sm:min-h-[420px]">
34
+ <Loader2 className="h-8 w-8 animate-spin text-primary-700" aria-label="Loading map" />
35
+ </div>
36
+ ),
37
+ })
38
+
39
+ export default function Home() {
40
+ const [selectedImage, setSelectedImage] = useState<File | null>(null)
41
+ const [selectedCrop, setSelectedCrop] = useState<string>('corn')
42
+ const [selectedState, setSelectedState] = useState<string>('IA')
43
+ const [photoMode, setPhotoMode] = useState<'single' | 'compare'>('single')
44
+ const [farmerProfile, setFarmerProfile] = useState<StoredFarmerProfile | null>(null)
45
+ const [prediction, setPrediction] = useState<any>(null)
46
+ const [loading, setLoading] = useState(false)
47
+ const [error, setError] = useState<string | null>(null)
48
+ const [imageUrl, setImageUrl] = useState<string | null>(null)
49
+ const [activeView, setActiveView] = useState<'diagnose' | 'history' | 'outbreaks'>('diagnose')
50
+ // Initialize with a sample outbreak in Russellville, Arkansas
51
+ const [outbreakReports, setOutbreakReports] = useState<OutbreakReport[]>([
52
+ {
53
+ id: 'russellville-outbreak-1',
54
+ lat: 35.2784,
55
+ lng: -93.1338,
56
+ crop: 'corn',
57
+ disease: 'Common Rust',
58
+ severity: 'high',
59
+ date: new Date().toISOString(),
60
+ description: 'Severe rust outbreak detected in corn fields. Multiple farms affected in the area.',
61
+ reporterVerified: false,
62
+ },
63
+ {
64
+ id: 'high-severity-130-miles',
65
+ lat: 33.6234, // Exactly 130 miles south of farmer-1 (35.5, -93.2)
66
+ lng: -93.2,
67
+ crop: 'corn',
68
+ disease: 'Southern Corn Leaf Blight',
69
+ severity: 'high',
70
+ date: new Date().toISOString(),
71
+ description: 'CRITICAL: Severe southern corn leaf blight outbreak detected. Immediate action required. Multiple farms at risk within 150-mile radius.',
72
+ reporterVerified: false,
73
+ },
74
+ {
75
+ id: 'california-outbreak-1',
76
+ lat: 36.7783,
77
+ lng: -119.4179,
78
+ crop: 'wheat',
79
+ disease: 'Leaf Rust',
80
+ severity: 'high',
81
+ date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
82
+ description: 'Widespread leaf rust detected in wheat fields across Central Valley.',
83
+ },
84
+ {
85
+ id: 'texas-outbreak-1',
86
+ lat: 31.9686,
87
+ lng: -99.9018,
88
+ crop: 'corn',
89
+ disease: 'Gray Leaf Spot',
90
+ severity: 'medium',
91
+ date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
92
+ description: 'Gray leaf spot spreading in corn crops. Farmers advised to monitor closely.',
93
+ },
94
+ {
95
+ id: 'iowa-outbreak-1',
96
+ lat: 41.8780,
97
+ lng: -93.0977,
98
+ crop: 'soybean',
99
+ disease: 'Powdery Mildew',
100
+ severity: 'medium',
101
+ date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
102
+ description: 'Powdery mildew detected in soybean fields. Early treatment recommended.',
103
+ },
104
+ {
105
+ id: 'illinois-outbreak-1',
106
+ lat: 40.3495,
107
+ lng: -88.9861,
108
+ crop: 'corn',
109
+ disease: 'Common Rust',
110
+ severity: 'low',
111
+ date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
112
+ description: 'Minor rust outbreak in isolated corn fields. Monitoring in progress.',
113
+ },
114
+ {
115
+ id: 'kansas-outbreak-1',
116
+ lat: 38.5729,
117
+ lng: -98.3833,
118
+ crop: 'wheat',
119
+ disease: 'Stripe Rust',
120
+ severity: 'high',
121
+ date: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
122
+ description: 'Severe stripe rust outbreak affecting wheat crops. Immediate action required.',
123
+ },
124
+ {
125
+ id: 'nebraska-outbreak-1',
126
+ lat: 41.4925,
127
+ lng: -99.9018,
128
+ crop: 'corn',
129
+ disease: 'Northern Corn Leaf Blight',
130
+ severity: 'medium',
131
+ date: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
132
+ description: 'Northern corn leaf blight detected. Fungicide application recommended.',
133
+ },
134
+ {
135
+ id: 'minnesota-outbreak-1',
136
+ lat: 46.7296,
137
+ lng: -94.6859,
138
+ crop: 'soybean',
139
+ disease: 'Bacterial Blight',
140
+ severity: 'low',
141
+ date: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
142
+ description: 'Bacterial blight found in soybean fields. Isolated cases reported.',
143
+ },
144
+ {
145
+ id: 'north-carolina-outbreak-1',
146
+ lat: 35.2271,
147
+ lng: -80.8431,
148
+ crop: 'corn',
149
+ disease: 'Southern Corn Leaf Blight',
150
+ severity: 'high',
151
+ date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
152
+ description: 'Severe southern corn leaf blight outbreak. Multiple counties affected.',
153
+ },
154
+ {
155
+ id: 'missouri-outbreak-1',
156
+ lat: 38.5729,
157
+ lng: -92.1893,
158
+ crop: 'soybean',
159
+ disease: 'Sudden Death Syndrome',
160
+ severity: 'medium',
161
+ date: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString(),
162
+ description: 'Sudden death syndrome detected in soybean crops. Root health monitoring advised.',
163
+ },
164
+ {
165
+ id: 'indiana-outbreak-1',
166
+ lat: 39.7684,
167
+ lng: -86.1581,
168
+ crop: 'corn',
169
+ disease: 'Common Rust',
170
+ severity: 'low',
171
+ date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
172
+ description: 'Minor rust spots detected. Early stage monitoring.',
173
+ },
174
+ {
175
+ id: 'ohio-outbreak-1',
176
+ lat: 40.3888,
177
+ lng: -82.7649,
178
+ crop: 'corn',
179
+ disease: 'Gray Leaf Spot',
180
+ severity: 'medium',
181
+ date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
182
+ description: 'Gray leaf spot spreading in corn fields. Weather conditions favorable for spread.',
183
+ },
184
+ ])
185
+ const [farmerLocation, setFarmerLocation] = useState<{ lat: number; lng: number; crops: string[] } | null>(null)
186
+
187
+ useEffect(() => {
188
+ const p = loadFarmerProfile()
189
+ if (p) {
190
+ setFarmerProfile(p)
191
+ setFarmerLocation({ lat: p.lat, lng: p.lng, crops: p.crops })
192
+ }
193
+ }, [])
194
+
195
+ const applyRegionalFilter = useCallback(
196
+ (raw: PredictionPayload) => applyRegionalPrior(raw, selectedCrop, selectedState),
197
+ [selectedCrop, selectedState]
198
+ )
199
+
200
+ const regionNote =
201
+ getRelevantDiseasesForCropState(selectedCrop, selectedState) !== null
202
+ ? `Regional adjustment: results are gently nudged toward diseases common for ${selectedCrop} in ${selectedState} (illustrative; capped so it never overrides the model). Other states or crops show the model's raw output.`
203
+ : undefined
204
+
205
+ const handlePredict = async () => {
206
+ if (!selectedImage) {
207
+ setError('Please select an image first')
208
+ return
209
+ }
210
+
211
+ setLoading(true)
212
+ setError(null)
213
+ setPrediction(null)
214
+
215
+ try {
216
+ const formData = new FormData()
217
+ formData.append('image', selectedImage)
218
+ formData.append('crop', selectedCrop)
219
+
220
+ const response = await fetch('/api/predict', {
221
+ method: 'POST',
222
+ body: formData,
223
+ })
224
+
225
+ if (!response.ok) {
226
+ const errorData = await response.json()
227
+ throw new Error(errorData.error || 'Prediction failed')
228
+ }
229
+
230
+ const data = await response.json()
231
+ const rawPayload: PredictionPayload = {
232
+ disease: data.disease,
233
+ confidence: data.confidence,
234
+ is_healthy: data.is_healthy,
235
+ meets_threshold: data.meets_threshold,
236
+ all_predictions: data.all_predictions,
237
+ }
238
+ const filtered = applyRegionalFilter(rawPayload)
239
+ const merged = { ...data, ...filtered }
240
+ setPrediction(merged)
241
+
242
+ // Save to history
243
+ if (imageUrl) {
244
+ const confidencePercent =
245
+ typeof merged.confidence === 'number' && merged.confidence <= 1
246
+ ? merged.confidence * 100
247
+ : merged.confidence
248
+ savePredictionToHistory(selectedCrop, merged.disease, confidencePercent, imageUrl)
249
+ }
250
+ } catch (err: any) {
251
+ setError(err.message || 'An error occurred')
252
+ } finally {
253
+ setLoading(false)
254
+ }
255
+ }
256
+
257
+ const handleClear = () => {
258
+ setSelectedImage(null)
259
+ setPrediction(null)
260
+ setError(null)
261
+ setImageUrl(null)
262
+ }
263
+
264
+ const handleImageSelect = (file: File | null) => {
265
+ setSelectedImage(file)
266
+ if (file) {
267
+ const url = URL.createObjectURL(file)
268
+ setImageUrl(url)
269
+ } else {
270
+ setImageUrl(null)
271
+ }
272
+ }
273
+
274
+ const handleHistorySelect = (record: any) => {
275
+ // Load image from history
276
+ setImageUrl(record.imageUrl)
277
+ setSelectedCrop(record.crop)
278
+ // Note: We can't reload the File object from URL, but we can show the prediction
279
+ // In a real app, you might want to store more data in history
280
+ }
281
+
282
+ const handleOutbreakReport = (report: OutbreakReport) => {
283
+ setOutbreakReports([...outbreakReports, report])
284
+ }
285
+
286
+ const handleFarmerRegister = (location: {
287
+ lat: number
288
+ lng: number
289
+ crops: string[]
290
+ name: string
291
+ email?: string
292
+ usdaFarmCode?: string
293
+ verifiedFarmer: boolean
294
+ }) => {
295
+ const profile: StoredFarmerProfile = {
296
+ name: location.name,
297
+ email: location.email,
298
+ lat: location.lat,
299
+ lng: location.lng,
300
+ crops: location.crops,
301
+ usdaFarmCode: location.usdaFarmCode,
302
+ verifiedFarmer: location.verifiedFarmer,
303
+ }
304
+ saveFarmerProfile(profile)
305
+ setFarmerProfile(profile)
306
+ setFarmerLocation({
307
+ lat: location.lat,
308
+ lng: location.lng,
309
+ crops: location.crops,
310
+ })
311
+ alert(
312
+ `Farm "${location.name}" registered! You'll now receive alerts for outbreaks within 250 miles.${
313
+ location.verifiedFarmer ? ' You are marked as a Verified farmer.' : ''
314
+ }`
315
+ )
316
+ }
317
+
318
+ return (
319
+ <main className="min-h-screen min-h-[100dvh] px-3 sm:px-4 py-4 sm:py-6 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
320
+ <div className="mx-auto max-w-7xl">
321
+ {/* Top bar */}
322
+ <header className="sticky top-0 z-40 -mx-3 sm:-mx-4 px-3 sm:px-4 pt-[max(0.5rem,env(safe-area-inset-top))] pb-3 border-b border-slate-200/60 bg-white/95">
323
+ <div className="mx-auto max-w-7xl flex items-center justify-between gap-2 sm:gap-3">
324
+ <div className="flex items-center gap-2 sm:gap-3 min-w-0">
325
+ <div className="w-10 h-10 shrink-0 rounded-2xl bg-gradient-to-br from-primary-600 to-primary-700 text-white flex items-center justify-center shadow-sm overflow-hidden">
326
+ <Image
327
+ src="/brand/wheat-mark-transparent.png"
328
+ alt="CropIntel"
329
+ width={22}
330
+ height={44}
331
+ className="opacity-95 object-contain drop-shadow-[0_1px_0_rgba(0,0,0,0.08)]"
332
+ priority
333
+ />
334
+ </div>
335
+ <div className="leading-tight min-w-0">
336
+ <div className="text-sm font-semibold text-slate-900 truncate">CropIntel</div>
337
+ <div className="text-[11px] sm:text-xs text-slate-500 leading-snug">Crop health insights</div>
338
+ </div>
339
+ </div>
340
+
341
+ <nav className="hidden md:flex items-center gap-2">
342
+ <a
343
+ href="/"
344
+ className="px-3 py-2 rounded-xl text-sm font-semibold text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors"
345
+ >
346
+ Diagnose
347
+ </a>
348
+ </nav>
349
+
350
+ <div className="flex items-center gap-1.5 sm:gap-2 flex-nowrap shrink-0 justify-end">
351
+ {farmerProfile && (
352
+ <FarmerVerificationBadge verified={farmerProfile.verifiedFarmer} compact />
353
+ )}
354
+ <div className="hidden sm:block">
355
+ <FarmerRegistration onRegister={handleFarmerRegister} crops={Object.keys(CROPS)} />
356
+ </div>
357
+ <div className="relative z-50">
358
+ <NotificationSystem outbreaks={outbreakReports} currentFarmerLocation={farmerLocation || undefined} />
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </header>
363
+
364
+ {/* Hero */}
365
+ <section className="mt-5 sm:mt-8 mb-5 sm:mb-6">
366
+ <div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
367
+ <div className="max-w-2xl">
368
+ <h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-slate-900 tracking-tight text-balance">
369
+ Diagnose crop issues from a photo
370
+ </h1>
371
+ <p className="mt-2 text-sm sm:text-base text-slate-600">
372
+ Upload a leaf image, pick the crop, and get a ranked set of labels with confidence.
373
+ </p>
374
+ </div>
375
+ <div className="flex items-center gap-2">
376
+ <div className="px-3 py-2 rounded-xl border border-slate-200 bg-white/80 text-sm text-slate-700 flex items-center gap-2">
377
+ <Bell className="w-4 h-4 text-primary-700" />
378
+ Outbreak alerts
379
+ </div>
380
+ </div>
381
+ </div>
382
+ </section>
383
+
384
+ {/* Views */}
385
+ <div className="grid grid-cols-3 gap-2 mb-5 sm:mb-6 sm:flex sm:flex-wrap sm:items-center">
386
+ {(
387
+ [
388
+ { id: 'diagnose', label: 'Diagnose', icon: Sparkles },
389
+ { id: 'history', label: 'History', icon: HistoryIcon },
390
+ { id: 'outbreaks', label: 'Outbreaks', icon: MapPin },
391
+ ] as const
392
+ ).map(({ id, label, icon: Icon }) => (
393
+ <button
394
+ key={id}
395
+ type="button"
396
+ onClick={() => setActiveView(id)}
397
+ className={`touch-manipulation min-h-[44px] px-2 sm:px-4 py-2 rounded-xl border text-xs sm:text-sm font-semibold transition-all flex items-center justify-center gap-1.5 sm:gap-2 ${
398
+ activeView === id
399
+ ? 'bg-primary-700 text-white border-primary-700'
400
+ : 'bg-white/70 text-slate-700 border-slate-200 hover:bg-white hover:border-primary-300'
401
+ }`}
402
+ >
403
+ <Icon className="w-4 h-4 shrink-0" />
404
+ <span className="truncate">{label}</span>
405
+ </button>
406
+ ))}
407
+ </div>
408
+
409
+ {activeView === 'diagnose' && (
410
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
411
+ <div className="lg:col-span-2 space-y-6">
412
+ <section className="surface rounded-2xl p-4 sm:p-6">
413
+ <div className="flex items-start justify-between gap-4 flex-wrap">
414
+ <div>
415
+ <h2 className="text-base font-semibold text-slate-900">Photo analysis</h2>
416
+ <p className="text-sm text-slate-600 mt-1">Best results with a sharp, well-lit close-up.</p>
417
+ </div>
418
+ <div className="text-xs text-slate-500">
419
+ Step <span className="font-semibold text-slate-700">1</span> of 3
420
+ </div>
421
+ </div>
422
+
423
+ <div className="mt-5 flex flex-wrap gap-2">
424
+ <button
425
+ type="button"
426
+ onClick={() => setPhotoMode('single')}
427
+ className={`touch-manipulation min-h-[44px] px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2 transition-all ${
428
+ photoMode === 'single'
429
+ ? 'bg-primary-700 text-white border-primary-700'
430
+ : 'bg-white text-slate-700 border-slate-200 hover:border-primary-300'
431
+ }`}
432
+ >
433
+ <Camera className="w-4 h-4" />
434
+ Single photo
435
+ </button>
436
+ <button
437
+ type="button"
438
+ onClick={() => setPhotoMode('compare')}
439
+ className={`touch-manipulation min-h-[44px] px-4 py-2 rounded-xl border text-sm font-semibold flex items-center gap-2 transition-all ${
440
+ photoMode === 'compare'
441
+ ? 'bg-primary-700 text-white border-primary-700'
442
+ : 'bg-white text-slate-700 border-slate-200 hover:border-primary-300'
443
+ }`}
444
+ >
445
+ <ArrowLeftRight className="w-4 h-4" />
446
+ Past vs current
447
+ </button>
448
+ </div>
449
+
450
+ <div className="mt-5 space-y-5">
451
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
452
+ <CropSelector crops={Object.keys(CROPS)} selectedCrop={selectedCrop} onCropChange={setSelectedCrop} />
453
+ <StateSelector selectedState={selectedState} onStateChange={setSelectedState} />
454
+ </div>
455
+
456
+ {photoMode === 'single' && (
457
+ <>
458
+ <ImageUpload selectedImage={selectedImage} onImageSelect={handleImageSelect} onClear={handleClear} />
459
+ <div className="flex items-end">
460
+ <button
461
+ type="button"
462
+ onClick={handlePredict}
463
+ disabled={!selectedImage || loading}
464
+ className="touch-manipulation min-h-[48px] w-full md:max-w-md inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-primary-700 text-white font-semibold shadow-sm hover:bg-primary-800 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
465
+ >
466
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRight className="w-4 h-4" />}
467
+ {loading ? 'Analyzing…' : 'Run analysis'}
468
+ </button>
469
+ </div>
470
+ </>
471
+ )}
472
+
473
+ {photoMode === 'compare' && (
474
+ <HealthComparisonPanel crop={selectedCrop} applyRegionalFilter={applyRegionalFilter} />
475
+ )}
476
+
477
+ {error && (
478
+ <div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-rose-900">
479
+ <div className="text-sm font-semibold">Something went wrong</div>
480
+ <div className="text-sm mt-1">{error}</div>
481
+ </div>
482
+ )}
483
+
484
+ {photoMode === 'single' && prediction && (
485
+ <>
486
+ <PredictionResults prediction={prediction} regionNote={regionNote} />
487
+ <Diagnosis
488
+ disease={prediction.disease}
489
+ crop={selectedCrop}
490
+ confidence={
491
+ typeof prediction.confidence === 'number' && prediction.confidence <= 1
492
+ ? prediction.confidence * 100
493
+ : prediction.confidence
494
+ }
495
+ isHealthy={prediction.is_healthy}
496
+ />
497
+ <DiseaseInfo diseaseName={prediction.disease} crop={selectedCrop} />
498
+ <ExportResults prediction={prediction} crop={selectedCrop} imageUrl={imageUrl} />
499
+ </>
500
+ )}
501
+ </div>
502
+ </section>
503
+ </div>
504
+
505
+ <aside className="lg:col-span-1 space-y-6">
506
+ <TipsAndGuidelines />
507
+
508
+ <div className="surface rounded-2xl p-4 sm:p-6">
509
+ <h3 className="text-base font-semibold text-slate-900">Alerts</h3>
510
+ <p className="text-sm text-slate-600 mt-1">
511
+ Register a farm location to receive outbreak notifications within 250 miles.
512
+ </p>
513
+ <div className="mt-4 sm:hidden space-y-3">
514
+ {farmerProfile && (
515
+ <FarmerVerificationBadge verified={farmerProfile.verifiedFarmer} />
516
+ )}
517
+ <FarmerRegistration onRegister={handleFarmerRegister} crops={Object.keys(CROPS)} />
518
+ </div>
519
+ </div>
520
+ </aside>
521
+ </div>
522
+ )}
523
+
524
+ {activeView === 'history' && (
525
+ <section className="surface rounded-2xl p-4 sm:p-6">
526
+ <PredictionHistory onSelectHistory={handleHistorySelect} />
527
+ </section>
528
+ )}
529
+
530
+ {activeView === 'outbreaks' && (
531
+ <section className="rounded-2xl border border-slate-200/80 bg-white p-4 sm:p-6 shadow-sm">
532
+ <div className="mb-4">
533
+ <h2 className="text-base font-semibold text-slate-900">Outbreak map</h2>
534
+ <p className="text-sm text-slate-600 mt-1">
535
+ Tap or click to report a potential outbreak and help track disease spread.
536
+ </p>
537
+ </div>
538
+ <div className="bg-white rounded-xl p-2 sm:p-4 border border-slate-200 -mx-1 sm:mx-0">
539
+ <USOutbreakMap
540
+ reports={outbreakReports}
541
+ onReportSubmit={handleOutbreakReport}
542
+ reporterVerified={farmerProfile?.verifiedFarmer ?? false}
543
+ />
544
+ </div>
545
+ </section>
546
+ )}
547
+
548
+ <footer className="text-center mt-10 mb-6 text-slate-500">
549
+ <p className="text-xs">Models: EfficientNet / TensorFlow Lite</p>
550
+ </footer>
551
+ </div>
552
+ </main>
553
+ )
554
+ }
combined_training_logs.csv ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ crop,epoch,phase,accuracy,val_accuracy,loss,val_loss,learning_rate
2
+ corn,0,Phase 1,0.25993428306022465,0.32931297537843107,2.0369233289997704,1.4760412881077356,9.999999747378752e-05
3
+ corn,1,Phase 1,0.2785918925391422,0.3290567562794731,1.9325749075275322,1.4272191047162242,9.999999747378752e-05
4
+ corn,2,Phase 1,0.3256681278871456,0.36849512867216616,1.8422305728166553,1.317687358398529,9.999999747378752e-05
5
+ corn,3,Phase 1,0.37453213281585807,0.3722218831433424,1.7569642956246365,1.2496958294442122,9.999999747378752e-05
6
+ corn,4,Phase 1,0.3707456467557967,0.42340147466631173,1.6220998743537631,1.2866345053173707,9.999999747378752e-05
7
+ corn,5,Phase 1,0.4021031536738457,0.47007986324021656,1.5840402569204328,1.2503222691756168,9.999999747378752e-05
8
+ corn,6,Phase 1,0.46972732768554293,0.478413822186777,1.5210070222602126,1.1154118152174743,9.999999747378752e-05
9
+ corn,7,Phase 1,0.4848489445210191,0.5425199363913403,1.5209015655871811,1.1056910197405077,9.999999747378752e-05
10
+ corn,8,Phase 1,0.4914679407818278,0.5565654845152547,1.4092328622226922,1.0100982296477032,9.999999747378752e-05
11
+ corn,9,Phase 1,0.543065807934812,0.596316665606969,1.2279064334441658,0.8962624942144805,9.999999747378752e-05
12
+ corn,10,Phase 1,0.5543034317694093,0.6236886906474505,1.2562691331500764,0.8830903157695725,9.999999747378752e-05
13
+ corn,11,Phase 1,0.5856143691168193,0.706340668871421,1.1448173141275542,0.8784244173668655,9.999999747378752e-05
14
+ corn,12,Phase 1,0.6311253881821108,0.7025974429756874,1.0542318216011057,0.7362333406374846,9.999999747378752e-05
15
+ corn,13,Phase 1,0.6211253881821108,0.715285451180532,1.0426682295264806,0.7527588789330977,9.999999747378752e-05
16
+ corn,14,Phase 1,0.6545021432256612,0.7864628502909176,0.9876408846772684,0.4800414944873253,9.999999747378752e-05
17
+ corn,15,Phase 1,0.7091119278536683,0.7791673613386373,0.9066614079763144,0.5300414944873253,9.999999747378752e-05
18
+ corn,16,Phase 1,0.7314582345943652,0.8413337885273565,0.7421430193274062,0.4883852102025515,9.999999747378752e-05
19
+ corn,17,Phase 1,0.789356982215525,0.8315353964388699,0.6926497701640111,0.4055845428167646,9.999999747378752e-05
20
+ corn,18,Phase 1,0.7962687326157611,0.8777373602277004,0.6486800539947837,0.36162500271624837,9.999999747378752e-05
21
+ corn,19,Phase 1,0.8175403186620454,0.9418105882122555,0.6049006322487571,0.19416057170884385,9.999999747378752e-05
22
+ corn,20,Phase 2,0.8457863926887512,0.937873363494873,0.5561233758926392,0.2935390174388885,9.999999747378752e-05
23
+ corn,21,Phase 2,0.903787076473236,0.9462365508079528,0.388371080160141,0.262035459280014,9.999999747378752e-05
24
+ corn,22,Phase 2,0.9225520491600036,0.9462365508079528,0.3230624198913574,0.254949539899826,9.999999747378752e-05
25
+ corn,23,Phase 2,0.9303991794586182,0.9498208165168762,0.2902830243110657,0.2342860549688339,9.999999747378752e-05
26
+ corn,24,Phase 2,0.937563955783844,0.9569892287254332,0.2600772082805633,0.2179587632417678,9.999999747378752e-05
27
+ corn,25,Phase 2,0.9474582076072692,0.9569892287254332,0.2290883213281631,0.2086362540721893,9.999999747378752e-05
28
+ corn,26,Phase 2,0.955646514892578,0.958184003829956,0.2177858054637909,0.2032181024551391,9.999999747378752e-05
29
+ corn,27,Phase 2,0.9539406299591064,0.9569892287254332,0.2127372920513153,0.1997936218976974,9.999999747378752e-05
30
+ corn,28,Phase 2,0.960764229297638,0.9557945132255554,0.1953231990337371,0.2101073563098907,9.999999747378752e-05
31
+ corn,29,Phase 2,0.9638348817825316,0.9641577005386353,0.1763510555028915,0.1987897753715515,9.999999747378752e-05
32
+ corn,30,Phase 2,0.9679290056228638,0.959378719329834,0.1671520769596099,0.2080786377191543,9.999999747378752e-05
33
+ corn,31,Phase 2,0.9672466516494752,0.9629629850387572,0.1688824594020843,0.1954573094844818,9.999999747378752e-05
34
+ corn,32,Phase 2,0.9723643660545348,0.966547191143036,0.1588789671659469,0.1971011310815811,9.999999747378752e-05
35
+ corn,33,Phase 2,0.973387897014618,0.965352475643158,0.1644280403852462,0.1916911602020263,9.999999747378752e-05
36
+ corn,34,Phase 2,0.9706584811210632,0.959378719329834,0.1582569330930709,0.2046691477298736,9.999999747378752e-05
37
+ corn,35,Phase 2,0.976799726486206,0.9605734944343568,0.1445048004388809,0.1973313093185424,9.999999747378752e-05
38
+ corn,36,Phase 2,0.9761173725128174,0.966547191143036,0.1439969688653946,0.1956245303153991,9.999999747378752e-05
39
+ corn,37,Phase 2,0.9805527329444884,0.9629629850387572,0.1321845948696136,0.2232125252485275,9.999999747378752e-05
40
+ corn,38,Phase 2,0.9832821488380432,0.9629629850387572,0.125231996178627,0.226840078830719,9.999999747378752e-05
41
+ corn,39,Phase 2,0.9808939099311828,0.959378719329834,0.1315742135047912,0.217746764421463,4.999999873689376e-05
42
+ rice,0,Full Training,0.6245828866958618,0.7251461744308472,1.1133992671966553,0.8908655643463135,9.999999747378752e-05
43
+ rice,1,Full Training,0.753615140914917,0.8167641162872314,0.77748042345047,0.6302634477615356,9.999999747378752e-05
44
+ rice,2,Full Training,0.7975528240203857,0.8538011908531189,0.6093147993087769,0.4765673279762268,9.999999747378752e-05
45
+ rice,3,Full Training,0.8387096524238586,0.9064327478408812,0.5588080883026123,0.3105555772781372,9.999999747378752e-05
46
+ rice,4,Full Training,0.8520578145980835,0.92592591047287,0.459932804107666,0.2514636218547821,9.999999747378752e-05
47
+ rice,5,Full Training,0.8876529335975647,0.9512670636177064,0.3976957499980926,0.2131340056657791,9.999999747378752e-05
48
+ rice,6,Full Training,0.8943270444869995,0.9512670636177064,0.3720048069953918,0.1891122162342071,9.999999747378752e-05
49
+ rice,7,Full Training,0.9048943519592284,0.9688109159469604,0.3332550823688507,0.1626372188329696,9.999999747378752e-05
50
+ rice,8,Full Training,0.9104560613632202,0.9727095365524292,0.3271945714950561,0.1407057493925094,9.999999747378752e-05
51
+ rice,9,Full Training,0.9265850782394408,0.9727095365524292,0.3011743426322937,0.1404629349708557,9.999999747378752e-05
52
+ rice,10,Full Training,0.9249165654182434,0.9785575270652772,0.2785957753658294,0.1359473615884781,9.999999747378752e-05
53
+ rice,11,Full Training,0.931034505367279,0.9824561476707458,0.2625192403793335,0.1228999197483062,9.999999747378752e-05
54
+ rice,12,Full Training,0.9427141547203064,0.988304078578949,0.2276329547166824,0.1142292022705078,9.999999747378752e-05
55
+ rice,13,Full Training,0.9421579241752625,0.9824561476707458,0.2399077266454696,0.1122083067893982,9.999999747378752e-05
56
+ rice,14,Full Training,0.9566184878349304,0.9863547682762146,0.1969229727983474,0.1022859513759613,9.999999747378752e-05
57
+ rice,15,Full Training,0.9382647275924684,0.988304078578949,0.2329556792974472,0.1052324697375297,9.999999747378752e-05
58
+ rice,16,Full Training,0.9605116844177246,0.988304078578949,0.1990869343280792,0.1037070974707603,9.999999747378752e-05
59
+ rice,17,Full Training,0.9521690607070924,0.9941520690917968,0.2017015367746353,0.0948290079832077,9.999999747378752e-05
60
+ rice,18,Full Training,0.9610678553581238,0.9805068373680116,0.2004568725824356,0.1194441244006156,9.999999747378752e-05
61
+ rice,19,Full Training,0.969410479068756,0.9922027587890624,0.1614497154951095,0.0929179117083549,9.999999747378752e-05
62
+ soybean,0,Full Training,0.555831253528595,0.800000011920929,1.2680233716964722,0.833808958530426,9.999999747378752e-05
63
+ soybean,1,Full Training,0.774193525314331,0.8608695864677429,0.7881004810333252,0.6341260075569153,9.999999747378752e-05
64
+ soybean,2,Full Training,0.8287841081619263,0.895652174949646,0.4845375418663025,0.4956668317317962,9.999999747378752e-05
65
+ soybean,3,Full Training,0.8610422015190125,0.8782608509063721,0.4177505373954773,0.4525479972362518,9.999999747378752e-05
66
+ soybean,4,Full Training,0.8858560919761658,0.8782608509063721,0.3343152105808258,0.4741407930850982,9.999999747378752e-05
67
+ soybean,5,Full Training,0.9007444381713868,0.904347836971283,0.3207309544086456,0.3701974749565124,9.999999747378752e-05
68
+ soybean,6,Full Training,0.9205955266952516,0.939130425453186,0.2717101573944092,0.3247621357440948,9.999999747378752e-05
69
+ soybean,7,Full Training,0.9305210709571838,0.947826087474823,0.2848432958126068,0.2357503920793533,9.999999747378752e-05
70
+ soybean,8,Full Training,0.9478908181190492,0.95652174949646,0.2768489122390747,0.241691917181015,9.999999747378752e-05
71
+ soybean,9,Full Training,0.9503722190856934,0.95652174949646,0.2016389966011047,0.236976757645607,9.999999747378752e-05
72
+ soybean,10,Full Training,0.9578163623809814,0.95652174949646,0.1939270496368408,0.248374804854393,9.999999747378752e-05
73
+ soybean,11,Full Training,0.9677419066429138,0.9652174115180968,0.1636942774057388,0.278547078371048,9.999999747378752e-05
74
+ soybean,12,Full Training,0.9652605652809144,0.9652174115180968,0.1644345670938491,0.2483182996511459,9.999999747378752e-05
75
+ soybean,13,Full Training,0.9652605652809144,0.9652174115180968,0.1477585434913635,0.2468210905790329,4.999999873689376e-05
76
+ soybean,14,Full Training,0.9826302528381348,0.9652174115180968,0.1358078271150589,0.2343208342790603,4.999999873689376e-05
77
+ soybean,15,Full Training,0.9801489114761353,0.9652174115180968,0.1375887989997863,0.2198337763547897,4.999999873689376e-05
78
+ soybean,16,Full Training,0.9677419066429138,0.9652174115180968,0.1382406949996948,0.2439783215522766,4.999999873689376e-05
79
+ soybean,17,Full Training,0.9826302528381348,0.9652174115180968,0.1264931112527847,0.2558887302875519,4.999999873689376e-05
80
+ soybean,18,Full Training,0.9826302528381348,0.9652174115180968,0.1234887540340423,0.2484913766384124,4.999999873689376e-05
81
+ soybean,19,Full Training,0.9727047085762024,0.9652174115180968,0.1350847035646438,0.2422107458114624,4.999999873689376e-05
compare.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ #!/bin/zsh
2
+ # Launch the local drag-and-drop model-compare tool.
3
+ # ./compare.sh
4
+ # Then open http://localhost:8050
5
+ cd "$(dirname "$0")"
6
+ PY=.conda-py311/bin/python
7
+ # Flask is required (one-time): $PY -m pip install flask
8
+ exec "$PY" -m ml.serve.compare_app
components/CropSelector.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { Leaf } from 'lucide-react'
4
+
5
+ interface CropSelectorProps {
6
+ crops: string[]
7
+ selectedCrop: string
8
+ onCropChange: (crop: string) => void
9
+ }
10
+
11
+ export default function CropSelector({
12
+ crops,
13
+ selectedCrop,
14
+ onCropChange,
15
+ }: CropSelectorProps) {
16
+ return (
17
+ <div>
18
+ <label
19
+ htmlFor="crop-select"
20
+ className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"
21
+ >
22
+ <Leaf className="w-4 h-4 text-primary-700" />
23
+ Crop
24
+ </label>
25
+ <select
26
+ id="crop-select"
27
+ value={selectedCrop}
28
+ onChange={(e) => onCropChange(e.target.value)}
29
+ className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-4 focus:ring-primary-200/60 focus:border-primary-400 text-base font-semibold bg-white shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer"
30
+ >
31
+ {crops.map((crop) => (
32
+ <option key={crop} value={crop}>
33
+ {crop.charAt(0).toUpperCase() + crop.slice(1)}
34
+ </option>
35
+ ))}
36
+ </select>
37
+ </div>
38
+ )
39
+ }
components/Diagnosis.tsx ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { AlertCircle, CheckCircle, AlertTriangle, Lightbulb, Shield, Calendar, Droplet } from 'lucide-react'
5
+ import { getDiseaseInfo } from '@/lib/diseaseInfo'
6
+
7
+ interface DiagnosisProps {
8
+ disease: string
9
+ crop: string
10
+ confidence: number
11
+ isHealthy: boolean
12
+ }
13
+
14
+ export default function Diagnosis({ disease, crop, confidence, isHealthy }: DiagnosisProps) {
15
+ const [activeTab, setActiveTab] = useState<'assessment' | 'treatment' | 'prevention'>('assessment')
16
+
17
+ const diseaseInfo = getDiseaseInfo(disease, crop)
18
+
19
+ if (isHealthy) {
20
+ return (
21
+ <div className="mt-6 bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl border-2 border-green-300 p-8 shadow-xl">
22
+ <div className="flex items-start gap-4">
23
+ <div className="p-3 bg-green-100 rounded-xl">
24
+ <CheckCircle className="w-8 h-8 text-green-600" />
25
+ </div>
26
+ <div className="flex-1">
27
+ <h3 className="text-2xl font-extrabold text-green-900 mb-3">Diagnosis: Healthy Plant</h3>
28
+ <p className="text-green-800 mb-4">
29
+ Great news! Your {crop} plant appears to be healthy with no signs of disease detected.
30
+ </p>
31
+ <div className="bg-white/60 rounded-xl p-5 border border-green-200">
32
+ <h4 className="font-bold text-green-900 mb-3 flex items-center gap-2">
33
+ <Shield className="w-5 h-5" />
34
+ Maintenance Recommendations
35
+ </h4>
36
+ <ul className="space-y-2 text-green-800">
37
+ {diseaseInfo?.prevention.map((tip, index) => (
38
+ <li key={index} className="flex items-start gap-2">
39
+ <span className="text-green-600 mt-1">✓</span>
40
+ <span>{tip}</span>
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ const getSeverityLevel = () => {
52
+ if (diseaseInfo?.severity === 'high') return 'Critical'
53
+ if (diseaseInfo?.severity === 'medium') return 'Moderate'
54
+ return 'Mild'
55
+ }
56
+
57
+ const getSeverityColor = () => {
58
+ if (diseaseInfo?.severity === 'high') return 'text-red-600 bg-red-100 border-red-300'
59
+ if (diseaseInfo?.severity === 'medium') return 'text-orange-600 bg-orange-100 border-orange-300'
60
+ return 'text-yellow-600 bg-yellow-100 border-yellow-300'
61
+ }
62
+
63
+ const getConfidenceLevel = () => {
64
+ if (confidence >= 90) return { level: 'Very High', color: 'text-green-600' }
65
+ if (confidence >= 75) return { level: 'High', color: 'text-blue-600' }
66
+ if (confidence >= 60) return { level: 'Moderate', color: 'text-yellow-600' }
67
+ return { level: 'Low', color: 'text-orange-600' }
68
+ }
69
+
70
+ const confidenceInfo = getConfidenceLevel()
71
+
72
+ return (
73
+ <div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-4 shadow-xl sm:p-8">
74
+ <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
75
+ <h3 className="flex items-center gap-2 text-2xl font-extrabold text-gray-900 sm:gap-3 sm:text-3xl">
76
+ <AlertCircle className="h-7 w-7 shrink-0 text-primary-600 sm:h-8 sm:w-8" />
77
+ <span className="min-w-0 leading-tight">Detailed Diagnosis</span>
78
+ </h3>
79
+ <div
80
+ className={`w-fit shrink-0 rounded-xl border-2 px-3 py-1.5 text-xs font-bold sm:px-4 sm:py-2 sm:text-sm ${getSeverityColor()}`}
81
+ >
82
+ {getSeverityLevel()} Severity
83
+ </div>
84
+ </div>
85
+
86
+ {/* Confidence & Assessment */}
87
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
88
+ <div className="bg-white rounded-xl p-5 border-2 border-gray-200 shadow-md">
89
+ <div className="flex items-center justify-between mb-2">
90
+ <span className="text-sm font-semibold text-gray-600 uppercase">Confidence Level</span>
91
+ <span className={`text-lg font-bold ${confidenceInfo.color}`}>
92
+ {confidenceInfo.level}
93
+ </span>
94
+ </div>
95
+ <div className="w-full bg-gray-200 rounded-full h-3">
96
+ <div
97
+ className={`h-3 rounded-full transition-all duration-500 ${
98
+ confidence >= 90 ? 'bg-green-500' :
99
+ confidence >= 75 ? 'bg-blue-500' :
100
+ confidence >= 60 ? 'bg-yellow-500' : 'bg-orange-500'
101
+ }`}
102
+ style={{ width: `${confidence}%` }}
103
+ />
104
+ </div>
105
+ <p className="text-xs text-gray-500 mt-2">
106
+ AI confidence: {confidence.toFixed(1)}%
107
+ </p>
108
+ </div>
109
+
110
+ <div className="bg-white rounded-xl p-5 border-2 border-gray-200 shadow-md">
111
+ <div className="flex items-center gap-3 mb-2">
112
+ <Calendar className="w-5 h-5 text-primary-600" />
113
+ <span className="text-sm font-semibold text-gray-600 uppercase">Recommended Action</span>
114
+ </div>
115
+ <p className="text-lg font-bold text-gray-900">
116
+ {diseaseInfo?.severity === 'high' ? 'Immediate Treatment Required' :
117
+ diseaseInfo?.severity === 'medium' ? 'Monitor & Treat Soon' :
118
+ 'Preventive Measures Recommended'}
119
+ </p>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Tabs — grid keeps all three inside the card on narrow screens */}
124
+ <div className="-mx-1 mb-6 border-b-2 border-gray-200 px-1 sm:mx-0 sm:px-0">
125
+ <div className="grid w-full grid-cols-3 gap-1 sm:gap-2">
126
+ {[
127
+ { id: 'assessment', label: 'Assessment', icon: AlertCircle },
128
+ { id: 'treatment', label: 'Treatment', icon: Droplet },
129
+ { id: 'prevention', label: 'Prevention', icon: Shield },
130
+ ].map(({ id, label, icon: Icon }) => (
131
+ <button
132
+ key={id}
133
+ type="button"
134
+ onClick={() => setActiveTab(id as 'assessment' | 'treatment' | 'prevention')}
135
+ className={`flex min-h-[3.25rem] min-w-0 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-t-lg px-1.5 py-2 text-center text-[11px] font-semibold leading-tight transition-all duration-300 sm:flex-row sm:gap-2 sm:rounded-t-xl sm:px-4 sm:py-3 sm:text-sm ${
136
+ activeTab === id
137
+ ? 'bg-primary-600 text-white shadow-lg'
138
+ : 'text-gray-600 hover:bg-gray-100 hover:text-primary-600'
139
+ }`}
140
+ >
141
+ <Icon className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
142
+ <span className="break-words">{label}</span>
143
+ </button>
144
+ ))}
145
+ </div>
146
+ </div>
147
+
148
+ {/* Tab Content */}
149
+ <div className="min-h-[300px]">
150
+ {activeTab === 'assessment' && (
151
+ <div className="space-y-6">
152
+ <div className="bg-white/80 rounded-xl p-6 border-2 border-gray-200">
153
+ <h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
154
+ <AlertTriangle className="w-6 h-6 text-orange-600" />
155
+ Disease Assessment
156
+ </h4>
157
+ <p className="text-gray-700 mb-4 leading-relaxed">
158
+ {diseaseInfo?.description || `Based on the AI analysis, your ${crop} plant shows signs of ${disease}.`}
159
+ </p>
160
+
161
+ {diseaseInfo?.symptoms && diseaseInfo.symptoms.length > 0 && (
162
+ <div className="mt-5">
163
+ <h5 className="font-bold text-gray-900 mb-3">Expected Symptoms:</h5>
164
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
165
+ {diseaseInfo.symptoms.map((symptom, index) => (
166
+ <div key={index} className="flex items-start gap-2 bg-orange-50 rounded-lg p-3 border border-orange-200">
167
+ <span className="text-orange-600 mt-1">•</span>
168
+ <span className="text-sm text-gray-700">{symptom}</span>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ <div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
176
+ <div className="flex items-start gap-3">
177
+ <Lightbulb className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
178
+ <div>
179
+ <p className="text-sm font-semibold text-blue-900 mb-1">Diagnostic Tip</p>
180
+ <p className="text-sm text-blue-800">
181
+ Compare the visual symptoms on your plant with the expected symptoms listed above.
182
+ If symptoms match closely, the diagnosis is likely accurate. For confirmation,
183
+ consider consulting with an agricultural extension service.
184
+ </p>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ )}
191
+
192
+ {activeTab === 'treatment' && (
193
+ <div className="space-y-6">
194
+ <div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border-2 border-blue-200">
195
+ <h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
196
+ <Droplet className="w-6 h-6 text-blue-600" />
197
+ Treatment Plan
198
+ </h4>
199
+
200
+ {diseaseInfo?.treatment && diseaseInfo.treatment.length > 0 ? (
201
+ <div className="space-y-4">
202
+ <p className="text-gray-700 font-semibold mb-4">
203
+ Immediate treatment steps for {disease}:
204
+ </p>
205
+ <div className="space-y-3">
206
+ {diseaseInfo.treatment.map((step, index) => (
207
+ <div key={index} className="bg-white rounded-lg p-4 border border-blue-200 shadow-sm">
208
+ <div className="flex items-start gap-3">
209
+ <div className="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold">
210
+ {index + 1}
211
+ </div>
212
+ <p className="text-gray-800 flex-1">{step}</p>
213
+ </div>
214
+ </div>
215
+ ))}
216
+ </div>
217
+ </div>
218
+ ) : (
219
+ <p className="text-gray-700">
220
+ Treatment recommendations are being prepared. Please consult with an agricultural expert
221
+ for specific treatment options for {disease} in {crop}.
222
+ </p>
223
+ )}
224
+
225
+ <div className="mt-6 p-4 bg-yellow-50 rounded-lg border border-yellow-300">
226
+ <div className="flex items-start gap-3">
227
+ <AlertTriangle className="w-5 h-5 text-yellow-700 flex-shrink-0 mt-0.5" />
228
+ <div>
229
+ <p className="text-sm font-bold text-yellow-900 mb-1">Important</p>
230
+ <p className="text-sm text-yellow-800">
231
+ Always follow label instructions when applying any treatments. Consider environmental
232
+ impact and use Integrated Pest Management (IPM) practices. For severe cases,
233
+ consult with certified agricultural professionals.
234
+ </p>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ )}
241
+
242
+ {activeTab === 'prevention' && (
243
+ <div className="space-y-6">
244
+ <div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl p-6 border-2 border-green-200">
245
+ <h4 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
246
+ <Shield className="w-6 h-6 text-green-600" />
247
+ Prevention Strategy
248
+ </h4>
249
+
250
+ {diseaseInfo?.prevention && diseaseInfo.prevention.length > 0 ? (
251
+ <div className="space-y-4">
252
+ <p className="text-gray-700 font-semibold mb-4">
253
+ Long-term prevention measures to protect your {crop} crops:
254
+ </p>
255
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
256
+ {diseaseInfo.prevention.map((prevention, index) => (
257
+ <div key={index} className="bg-white rounded-lg p-4 border border-green-200 shadow-sm flex items-start gap-3">
258
+ <CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
259
+ <p className="text-gray-800 text-sm">{prevention}</p>
260
+ </div>
261
+ ))}
262
+ </div>
263
+ </div>
264
+ ) : (
265
+ <p className="text-gray-700">
266
+ Prevention strategies are being developed. General best practices include crop rotation,
267
+ proper spacing, and regular monitoring.
268
+ </p>
269
+ )}
270
+
271
+ <div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
272
+ <div className="flex items-start gap-3">
273
+ <Calendar className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
274
+ <div>
275
+ <p className="text-sm font-bold text-blue-900 mb-1">Monitoring Schedule</p>
276
+ <p className="text-sm text-blue-800">
277
+ Regular monitoring is key to early detection. Check your {crop} fields weekly,
278
+ especially during critical growth stages. Early intervention is more effective
279
+ and cost-efficient than treating advanced disease.
280
+ </p>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ {/* Action Summary */}
290
+ <div className="mt-6 p-5 bg-gradient-to-r from-primary-50 to-blue-50 rounded-xl border-2 border-primary-200">
291
+ <h4 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
292
+ <Lightbulb className="w-5 h-5 text-primary-600" />
293
+ Quick Action Summary
294
+ </h4>
295
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
296
+ <div>
297
+ <p className="font-semibold text-gray-700 mb-1">1. Immediate</p>
298
+ <p className="text-gray-600">
299
+ {diseaseInfo?.severity === 'high'
300
+ ? 'Apply treatment within 24-48 hours'
301
+ : diseaseInfo?.severity === 'medium'
302
+ ? 'Monitor closely and prepare treatment'
303
+ : 'Implement preventive measures'}
304
+ </p>
305
+ </div>
306
+ <div>
307
+ <p className="font-semibold text-gray-700 mb-1">2. Short-term</p>
308
+ <p className="text-gray-600">
309
+ Follow treatment plan and monitor progress over next 7-14 days
310
+ </p>
311
+ </div>
312
+ <div>
313
+ <p className="font-semibold text-gray-700 mb-1">3. Long-term</p>
314
+ <p className="text-gray-600">
315
+ Implement prevention strategies to avoid future occurrences
316
+ </p>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ )
322
+ }
components/DiseaseInfo.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { getDiseaseInfo } from '@/lib/diseaseInfo'
4
+
5
+ interface DiseaseInfoProps {
6
+ diseaseName: string
7
+ crop: string
8
+ }
9
+
10
+ export default function DiseaseInfo({ diseaseName, crop }: DiseaseInfoProps) {
11
+ const info = getDiseaseInfo(diseaseName, crop)
12
+
13
+ if (!info) return null
14
+
15
+ const severityColors = {
16
+ low: 'bg-green-100 text-green-800 border-green-300',
17
+ medium: 'bg-yellow-100 text-yellow-800 border-yellow-300',
18
+ high: 'bg-red-100 text-red-800 border-red-300'
19
+ }
20
+
21
+ return (
22
+ <div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-8 shadow-xl">
23
+ <h3 className="text-3xl font-extrabold text-gray-900 mb-6 flex items-center gap-3">
24
+ <svg className="w-8 h-8 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
26
+ </svg>
27
+ Disease Information
28
+ </h3>
29
+
30
+ {/* Description */}
31
+ <div className="mb-6">
32
+ <h4 className="text-lg font-semibold text-gray-700 mb-2">Description</h4>
33
+ <p className="text-gray-600">{info.description}</p>
34
+ </div>
35
+
36
+ {/* Severity Badge */}
37
+ {!info.name.toLowerCase().includes('healthy') && (
38
+ <div className="mb-6">
39
+ <span className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border-2 font-bold text-sm shadow-md ${severityColors[info.severity]}`}>
40
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
41
+ <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
42
+ </svg>
43
+ Severity: {info.severity.toUpperCase()}
44
+ </span>
45
+ </div>
46
+ )}
47
+
48
+ {/* Symptoms */}
49
+ {info.symptoms.length > 0 && (
50
+ <div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
51
+ <h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
52
+ <svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
54
+ </svg>
55
+ Symptoms
56
+ </h4>
57
+ <ul className="space-y-3 text-gray-700">
58
+ {info.symptoms.map((symptom, index) => (
59
+ <li key={index} className="flex items-start gap-3">
60
+ <span className="text-red-500 mt-1">•</span>
61
+ <span>{symptom}</span>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </div>
66
+ )}
67
+
68
+ {/* Treatment */}
69
+ {info.treatment.length > 0 && (
70
+ <div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
71
+ <h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
72
+ <svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
73
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
74
+ </svg>
75
+ Treatment
76
+ </h4>
77
+ <ul className="space-y-3 text-gray-700">
78
+ {info.treatment.map((treatment, index) => (
79
+ <li key={index} className="flex items-start gap-3">
80
+ <span className="text-blue-500 mt-1">•</span>
81
+ <span>{treatment}</span>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ </div>
86
+ )}
87
+
88
+ {/* Prevention */}
89
+ {info.prevention.length > 0 && (
90
+ <div className="mb-6 bg-white/60 rounded-xl p-5 border border-gray-200">
91
+ <h4 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
92
+ <svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
94
+ </svg>
95
+ Prevention
96
+ </h4>
97
+ <ul className="space-y-3 text-gray-700">
98
+ {info.prevention.map((prevention, index) => (
99
+ <li key={index} className="flex items-start gap-3">
100
+ <span className="text-green-500 mt-1">•</span>
101
+ <span>{prevention}</span>
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ </div>
106
+ )}
107
+
108
+ {/* Disclaimer */}
109
+ <div className="mt-6 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl shadow-md">
110
+ <div className="flex items-start gap-3">
111
+ <svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
112
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
113
+ </svg>
114
+ <p className="text-sm text-blue-900 font-medium">
115
+ <strong>Note:</strong> This information is for reference only. Always consult with agricultural experts or extension services for specific treatment recommendations for your region.
116
+ </p>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ )
121
+ }
components/ExportResults.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ interface ExportResultsProps {
4
+ prediction: any
5
+ crop: string
6
+ imageUrl: string | null
7
+ }
8
+
9
+ export default function ExportResults({ prediction, crop, imageUrl }: ExportResultsProps) {
10
+ const exportToJSON = () => {
11
+ const data = {
12
+ crop,
13
+ disease: prediction.disease,
14
+ confidence: prediction.confidence,
15
+ isHealthy: prediction.is_healthy,
16
+ timestamp: new Date().toISOString(),
17
+ allPredictions: prediction.all_predictions
18
+ }
19
+
20
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
21
+ const url = URL.createObjectURL(blob)
22
+ const a = document.createElement('a')
23
+ a.href = url
24
+ a.download = `cropintel_${crop}_${Date.now()}.json`
25
+ document.body.appendChild(a)
26
+ a.click()
27
+ document.body.removeChild(a)
28
+ URL.revokeObjectURL(url)
29
+ }
30
+
31
+ const exportToCSV = () => {
32
+ const headers = ['Disease', 'Confidence (%)', 'Is Healthy']
33
+ const rows = prediction.all_predictions.map((p: any) => [
34
+ p.disease,
35
+ p.confidence.toFixed(2),
36
+ prediction.is_healthy ? 'Yes' : 'No'
37
+ ])
38
+
39
+ const csv = [
40
+ headers.join(','),
41
+ ...rows.map((row: any[]) => row.join(','))
42
+ ].join('\n')
43
+
44
+ const blob = new Blob([csv], { type: 'text/csv' })
45
+ const url = URL.createObjectURL(blob)
46
+ const a = document.createElement('a')
47
+ a.href = url
48
+ a.download = `cropintel_${crop}_${Date.now()}.csv`
49
+ document.body.appendChild(a)
50
+ a.click()
51
+ document.body.removeChild(a)
52
+ URL.revokeObjectURL(url)
53
+ }
54
+
55
+ const shareResults = async () => {
56
+ const text = `CropIntel Analysis Results:\nCrop: ${crop}\nDisease: ${prediction.disease}\nConfidence: ${prediction.confidence.toFixed(1)}%\n\nAnalyzed with CropIntel AI`
57
+
58
+ if (navigator.share) {
59
+ try {
60
+ await navigator.share({
61
+ title: 'CropIntel Analysis Results',
62
+ text: text,
63
+ })
64
+ } catch (err) {
65
+ console.error('Error sharing:', err)
66
+ }
67
+ } else {
68
+ // Fallback: copy to clipboard
69
+ navigator.clipboard.writeText(text)
70
+ alert('Results copied to clipboard!')
71
+ }
72
+ }
73
+
74
+ return (
75
+ <div className="mt-6 bg-gradient-to-br from-white to-gray-50 rounded-2xl border-2 border-gray-200/50 p-6 shadow-xl">
76
+ <h3 className="text-2xl font-extrabold text-gray-900 mb-5 flex items-center gap-3">
77
+ <svg className="w-7 h-7 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
79
+ </svg>
80
+ Export Results
81
+ </h3>
82
+ <div className="flex flex-wrap gap-4">
83
+ <button
84
+ onClick={exportToJSON}
85
+ className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
86
+ >
87
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
88
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
89
+ </svg>
90
+ Export JSON
91
+ </button>
92
+ <button
93
+ onClick={exportToCSV}
94
+ className="px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
95
+ >
96
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
97
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
98
+ </svg>
99
+ Export CSV
100
+ </button>
101
+ <button
102
+ onClick={shareResults}
103
+ className="px-6 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl hover:from-purple-700 hover:to-purple-800 transition-all duration-300 flex items-center gap-2 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
104
+ >
105
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
106
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
107
+ </svg>
108
+ Share
109
+ </button>
110
+ </div>
111
+ </div>
112
+ )
113
+ }
components/FarmerRegistration.tsx ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { MapPin, Save, X } from 'lucide-react'
6
+ import { mockValidateUsdaFarmCode } from '@/lib/farmerProfile'
7
+
8
+ interface FarmerRegistrationProps {
9
+ onRegister: (location: {
10
+ lat: number
11
+ lng: number
12
+ crops: string[]
13
+ name: string
14
+ email?: string
15
+ usdaFarmCode?: string
16
+ verifiedFarmer: boolean
17
+ }) => void
18
+ crops: string[]
19
+ }
20
+
21
+ export default function FarmerRegistration({ onRegister, crops }: FarmerRegistrationProps) {
22
+ const [mounted, setMounted] = useState(false)
23
+ const [isOpen, setIsOpen] = useState(false)
24
+ const [formData, setFormData] = useState({
25
+ name: '',
26
+ email: '',
27
+ lat: '',
28
+ lng: '',
29
+ usdaFarmCode: '',
30
+ selectedCrops: [] as string[],
31
+ })
32
+
33
+ useEffect(() => setMounted(true), [])
34
+
35
+ useEffect(() => {
36
+ if (!isOpen) return
37
+ const prev = document.body.style.overflow
38
+ document.body.style.overflow = 'hidden'
39
+ return () => {
40
+ document.body.style.overflow = prev
41
+ }
42
+ }, [isOpen])
43
+
44
+ useEffect(() => {
45
+ if (!isOpen) return
46
+ const onKey = (e: KeyboardEvent) => {
47
+ if (e.key === 'Escape') setIsOpen(false)
48
+ }
49
+ window.addEventListener('keydown', onKey)
50
+ return () => window.removeEventListener('keydown', onKey)
51
+ }, [isOpen])
52
+
53
+ const handleGetLocation = () => {
54
+ if (navigator.geolocation) {
55
+ navigator.geolocation.getCurrentPosition(
56
+ (position) => {
57
+ setFormData((prev) => ({
58
+ ...prev,
59
+ lat: position.coords.latitude.toFixed(6),
60
+ lng: position.coords.longitude.toFixed(6),
61
+ }))
62
+ },
63
+ (error) => {
64
+ alert('Unable to get your location. Please enter it manually.')
65
+ console.error('Geolocation error:', error)
66
+ }
67
+ )
68
+ } else {
69
+ alert('Geolocation is not supported by your browser.')
70
+ }
71
+ }
72
+
73
+ const handleCropToggle = (crop: string) => {
74
+ setFormData((prev) => ({
75
+ ...prev,
76
+ selectedCrops: prev.selectedCrops.includes(crop)
77
+ ? prev.selectedCrops.filter((c) => c !== crop)
78
+ : [...prev.selectedCrops, crop],
79
+ }))
80
+ }
81
+
82
+ const handleSubmit = () => {
83
+ const missing: string[] = []
84
+ if (!formData.name.trim()) missing.push('Farm name (green section at top of form)')
85
+ const latOk = formData.lat.trim() !== '' && !Number.isNaN(parseFloat(formData.lat))
86
+ const lngOk = formData.lng.trim() !== '' && !Number.isNaN(parseFloat(formData.lng))
87
+ if (!latOk) missing.push('Latitude')
88
+ if (!lngOk) missing.push('Longitude')
89
+ if (formData.selectedCrops.length === 0) missing.push('At least one crop')
90
+
91
+ if (missing.length > 0) {
92
+ alert(`Please complete the following:\n\n• ${missing.join('\n• ')}`)
93
+ return
94
+ }
95
+
96
+ const code = formData.usdaFarmCode.trim()
97
+ const verifiedFarmer = code.length > 0 ? mockValidateUsdaFarmCode(code) : false
98
+
99
+ onRegister({
100
+ lat: parseFloat(formData.lat),
101
+ lng: parseFloat(formData.lng),
102
+ crops: formData.selectedCrops,
103
+ name: formData.name,
104
+ email: formData.email.trim() || undefined,
105
+ usdaFarmCode: code || undefined,
106
+ verifiedFarmer,
107
+ })
108
+
109
+ setIsOpen(false)
110
+ setFormData({
111
+ name: '',
112
+ email: '',
113
+ lat: '',
114
+ lng: '',
115
+ usdaFarmCode: '',
116
+ selectedCrops: [],
117
+ })
118
+ }
119
+
120
+ return (
121
+ <>
122
+ <button
123
+ onClick={() => setIsOpen(true)}
124
+ className="px-4 py-2 bg-primary-700 hover:bg-primary-800 text-white rounded-xl font-semibold transition-colors flex items-center gap-2 shadow-sm"
125
+ >
126
+ <MapPin className="w-4 h-4" />
127
+ Register Your Farm
128
+ </button>
129
+
130
+ {mounted &&
131
+ isOpen &&
132
+ createPortal(
133
+ <div
134
+ key="farm-registration-fullpage"
135
+ className="fixed inset-0 z-[9999] flex h-[100dvh] w-screen flex-col overflow-hidden bg-white"
136
+ role="dialog"
137
+ aria-modal="true"
138
+ aria-labelledby="farm-registration-title"
139
+ >
140
+ <div className="flex items-center justify-between gap-4 px-5 sm:px-10 lg:px-16 py-4 sm:py-6 border-b border-slate-200 flex-shrink-0 bg-white">
141
+ <h2
142
+ id="farm-registration-title"
143
+ className="text-xl sm:text-2xl font-bold text-slate-900 flex items-center gap-3 min-w-0"
144
+ >
145
+ <MapPin className="w-7 h-7 text-primary-600 shrink-0" />
146
+ <span>Register your farm</span>
147
+ </h2>
148
+ <button
149
+ type="button"
150
+ onClick={() => setIsOpen(false)}
151
+ className="text-slate-500 hover:text-slate-900 transition-colors p-3 hover:bg-slate-100 rounded-xl shrink-0 border border-slate-200"
152
+ aria-label="Close"
153
+ >
154
+ <X className="w-6 h-6" />
155
+ </button>
156
+ </div>
157
+
158
+ <div className="px-5 sm:px-10 lg:px-16 py-5 sm:py-6 bg-primary-50/90 border-b border-primary-100 flex-shrink-0">
159
+ <label className="block text-base font-bold text-slate-800 mb-2" htmlFor="farm-registration-name">
160
+ Farm name <span className="text-red-600">*</span>
161
+ </label>
162
+ <input
163
+ id="farm-registration-name"
164
+ type="text"
165
+ value={formData.name}
166
+ onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
167
+ placeholder="e.g., Smith Family Farm"
168
+ autoComplete="organization"
169
+ className="w-full max-w-3xl px-5 py-4 border-2 border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-600 bg-white text-lg"
170
+ />
171
+ </div>
172
+
173
+ <div className="flex-1 overflow-y-auto overscroll-contain px-5 sm:px-10 lg:px-16 py-6 sm:py-8 space-y-6 sm:space-y-8 min-h-0">
174
+ <div>
175
+ <label className="block text-base font-semibold text-slate-700 mb-2">
176
+ Email (optional)
177
+ </label>
178
+ <input
179
+ type="email"
180
+ value={formData.email}
181
+ onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
182
+ placeholder="farmer@example.com"
183
+ className="w-full max-w-3xl px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
184
+ />
185
+ </div>
186
+
187
+ <div>
188
+ <label className="block text-base font-semibold text-slate-700 mb-2">
189
+ USDA farm / tract code (optional)
190
+ </label>
191
+ <input
192
+ type="text"
193
+ value={formData.usdaFarmCode}
194
+ onChange={(e) => setFormData((prev) => ({ ...prev, usdaFarmCode: e.target.value }))}
195
+ placeholder="e.g., 12345-67890"
196
+ className="w-full max-w-3xl px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all font-mono text-lg"
197
+ />
198
+ <p className="text-sm text-slate-600 mt-2 max-w-3xl">
199
+ If provided and format looks valid, you&apos;ll be marked as a <strong>Verified farmer</strong>{' '}
200
+ (demo validation only).
201
+ </p>
202
+ </div>
203
+
204
+ <div>
205
+ <label className="block text-base font-semibold text-slate-700 mb-2">
206
+ Location <span className="text-red-500">*</span>
207
+ </label>
208
+ <div className="flex flex-col sm:flex-row gap-3 mb-3 max-w-3xl">
209
+ <input
210
+ type="number"
211
+ step="any"
212
+ value={formData.lat}
213
+ onChange={(e) => setFormData((prev) => ({ ...prev, lat: e.target.value }))}
214
+ placeholder="Latitude"
215
+ className="flex-1 min-w-0 px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
216
+ />
217
+ <input
218
+ type="number"
219
+ step="any"
220
+ value={formData.lng}
221
+ onChange={(e) => setFormData((prev) => ({ ...prev, lng: e.target.value }))}
222
+ placeholder="Longitude"
223
+ className="flex-1 min-w-0 px-5 py-4 border-2 border-slate-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-600 transition-all text-lg"
224
+ />
225
+ </div>
226
+ <button
227
+ type="button"
228
+ onClick={handleGetLocation}
229
+ className="w-full max-w-3xl px-5 py-4 bg-primary-100 hover:bg-primary-200 text-primary-900 rounded-xl font-semibold transition-colors flex items-center justify-center gap-2 text-lg"
230
+ >
231
+ <MapPin className="w-5 h-5" />
232
+ Use My Current Location
233
+ </button>
234
+ </div>
235
+
236
+ <div>
237
+ <label className="block text-base font-semibold text-slate-700 mb-3">
238
+ Crops you grow <span className="text-red-500">*</span>
239
+ </label>
240
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 max-w-4xl">
241
+ {crops.map((crop) => (
242
+ <button
243
+ type="button"
244
+ key={crop}
245
+ onClick={() => handleCropToggle(crop)}
246
+ className={`px-4 py-5 sm:py-6 rounded-xl font-bold transition-all border-2 text-lg ${
247
+ formData.selectedCrops.includes(crop)
248
+ ? 'bg-primary-700 text-white border-primary-700 shadow-md'
249
+ : 'bg-slate-100 text-slate-800 border-slate-300 hover:border-primary-400'
250
+ }`}
251
+ >
252
+ {crop.charAt(0).toUpperCase() + crop.slice(1)}
253
+ </button>
254
+ ))}
255
+ </div>
256
+ </div>
257
+
258
+ <div className="bg-blue-50 border border-blue-200 rounded-xl p-4 sm:p-5 max-w-3xl">
259
+ <p className="text-sm sm:text-base text-primary-900">
260
+ <strong>Note:</strong> You&apos;ll receive alerts for disease outbreaks within 250 miles of your registered location for the crops you select.
261
+ </p>
262
+ </div>
263
+ </div>
264
+
265
+ <div className="flex-shrink-0 border-t border-slate-200 px-5 sm:px-10 lg:px-16 py-5 sm:py-6 bg-slate-50">
266
+ <button
267
+ type="button"
268
+ onClick={handleSubmit}
269
+ className="w-full max-w-3xl mx-auto bg-gradient-to-r from-primary-700 to-primary-800 hover:from-primary-800 hover:to-primary-900 text-white font-bold py-4 sm:py-5 px-8 rounded-xl transition-all flex items-center justify-center gap-3 shadow-md text-lg"
270
+ >
271
+ <Save className="w-6 h-6" />
272
+ Register Farm
273
+ </button>
274
+ </div>
275
+ </div>,
276
+ document.body
277
+ )}
278
+ </>
279
+ )
280
+ }
components/FarmerVerificationBadge.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { BadgeCheck, User } from 'lucide-react'
4
+
5
+ type Props = {
6
+ verified: boolean
7
+ compact?: boolean
8
+ }
9
+
10
+ export default function FarmerVerificationBadge({ verified, compact }: Props) {
11
+ if (verified) {
12
+ return (
13
+ <span
14
+ className={`inline-flex items-center gap-1.5 rounded-full border font-semibold bg-emerald-50 text-emerald-900 border-emerald-200 ${
15
+ compact ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm'
16
+ }`}
17
+ >
18
+ <BadgeCheck className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
19
+ Verified farmer
20
+ </span>
21
+ )
22
+ }
23
+ return (
24
+ <span
25
+ className={`inline-flex items-center gap-1.5 rounded-full border font-semibold bg-slate-100 text-slate-700 border-slate-200 ${
26
+ compact ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm'
27
+ }`}
28
+ >
29
+ <User className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
30
+ Unverified
31
+ </span>
32
+ )
33
+ }
components/GoogleMap.tsx ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useLoadScript, GoogleMap, Marker, InfoWindow } from '@react-google-maps/api'
4
+ import { useState, useCallback, useEffect } from 'react'
5
+ import { MapPin } from 'lucide-react'
6
+ import type { OutbreakReport } from '@/lib/outbreakReport'
7
+
8
+ const libraries: ('places' | 'drawing' | 'geometry' | 'visualization')[] = ['places']
9
+
10
+ interface GoogleMapProps {
11
+ reports?: OutbreakReport[]
12
+ onMapClick?: (lat: number, lng: number) => void
13
+ center?: { lat: number; lng: number }
14
+ zoom?: number
15
+ /** When false, hides the floating “click map” hint (parent may show its own). Default true. */
16
+ showMapClickHint?: boolean
17
+ /** Called when the Google Map instance is ready (e.g. to trigger resize after layout changes). */
18
+ onMapReady?: (map: google.maps.Map) => void
19
+ /** Maps API fullscreen control; set false when the parent provides its own fullscreen. Default true. */
20
+ fullscreenControl?: boolean
21
+ }
22
+
23
+ const mapContainerStyle = {
24
+ width: '100%',
25
+ height: '100%',
26
+ }
27
+
28
+ const defaultCenter = {
29
+ lat: 39.8283, // Center of US
30
+ lng: -98.5795,
31
+ }
32
+
33
+ const defaultZoom = 4
34
+
35
+ export default function GoogleMapComponent({
36
+ reports = [],
37
+ onMapClick,
38
+ center = defaultCenter,
39
+ zoom = defaultZoom,
40
+ showMapClickHint = true,
41
+ onMapReady,
42
+ fullscreenControl = true,
43
+ }: GoogleMapProps) {
44
+ const [selectedReport, setSelectedReport] = useState<OutbreakReport | null>(null)
45
+ const [mapCenter, setMapCenter] = useState(center)
46
+ const [loadingTimeout, setLoadingTimeout] = useState(false)
47
+
48
+ const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''
49
+
50
+ const { isLoaded, loadError } = useLoadScript({
51
+ googleMapsApiKey: apiKey,
52
+ libraries,
53
+ })
54
+
55
+ // Add timeout to prevent infinite loading
56
+ useEffect(() => {
57
+ if (!isLoaded && !loadError && apiKey) {
58
+ const timer = setTimeout(() => {
59
+ setLoadingTimeout(true)
60
+ }, 8000) // 8 second timeout
61
+ return () => clearTimeout(timer)
62
+ } else {
63
+ setLoadingTimeout(false)
64
+ }
65
+ }, [isLoaded, loadError, apiKey])
66
+
67
+ const handleMapClick = useCallback(
68
+ (e: google.maps.MapMouseEvent) => {
69
+ if (e.latLng && onMapClick) {
70
+ onMapClick(e.latLng.lat(), e.latLng.lng())
71
+ }
72
+ },
73
+ [onMapClick]
74
+ )
75
+
76
+ const handleMapLoad = useCallback(
77
+ (map: google.maps.Map) => {
78
+ onMapReady?.(map)
79
+ },
80
+ [onMapReady]
81
+ )
82
+
83
+ const getSeverityColor = (severity: string) => {
84
+ switch (severity) {
85
+ case 'high':
86
+ return '#ef4444'
87
+ case 'medium':
88
+ return '#f97316'
89
+ case 'low':
90
+ return '#eab308'
91
+ default:
92
+ return '#64748b'
93
+ }
94
+ }
95
+
96
+ // Create marker icons - only create when map is loaded
97
+ const createMarkerIcon = useCallback((color: string) => {
98
+ if (!isLoaded || typeof google === 'undefined' || !google.maps) {
99
+ return undefined
100
+ }
101
+
102
+ // Create a canvas-based circle icon - most reliable method
103
+ const canvas = document.createElement('canvas')
104
+ canvas.width = 24
105
+ canvas.height = 24
106
+ const ctx = canvas.getContext('2d')
107
+ if (!ctx) return undefined
108
+
109
+ // Draw filled circle
110
+ ctx.beginPath()
111
+ ctx.arc(12, 12, 10, 0, 2 * Math.PI)
112
+ ctx.fillStyle = color
113
+ ctx.fill()
114
+
115
+ // Draw white border
116
+ ctx.strokeStyle = '#ffffff'
117
+ ctx.lineWidth = 3
118
+ ctx.stroke()
119
+
120
+ const dataUrl = canvas.toDataURL()
121
+
122
+ return {
123
+ url: dataUrl,
124
+ scaledSize: new google.maps.Size(24, 24),
125
+ anchor: new google.maps.Point(12, 12),
126
+ }
127
+ }, [isLoaded])
128
+
129
+ if (loadError || loadingTimeout) {
130
+ return (
131
+ <div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-slate-200 bg-slate-50 sm:min-h-[400px]">
132
+ <div className="text-center p-6">
133
+ <MapPin className="w-12 h-12 text-slate-400 mx-auto mb-4" />
134
+ <p className="text-slate-700 font-semibold mb-2 text-lg">Map Unavailable</p>
135
+ <p className="text-slate-500 text-sm mb-1">
136
+ {apiKey
137
+ ? 'Google Maps API key may be invalid or restricted'
138
+ : 'Google Maps API key not configured'}
139
+ </p>
140
+ <p className="text-slate-400 text-xs mt-4">Outbreak reporting will work once the map loads</p>
141
+ </div>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ if (!isLoaded) {
147
+ return (
148
+ <div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-blue-200 bg-blue-50 sm:min-h-[400px]">
149
+ <div className="text-center">
150
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
151
+ <p className="text-blue-600 font-semibold">Loading Google Maps...</p>
152
+ {!apiKey && (
153
+ <p className="text-blue-500 text-xs mt-2">No API key configured</p>
154
+ )}
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ return (
161
+ <div className="relative h-full min-h-0 w-full overflow-hidden rounded-xl">
162
+ <style dangerouslySetInnerHTML={{
163
+ __html: `
164
+ /* Hide Google Maps watermark and "For development purposes only" text */
165
+ .gm-style-cc,
166
+ .gm-style-cc div,
167
+ .gm-style-cc a,
168
+ a[href^="https://developers.google.com/maps"],
169
+ .gm-bundled-control .gm-style-cc,
170
+ div[title*="For development purposes only"],
171
+ div[aria-label*="For development purposes only"] {
172
+ display: none !important;
173
+ visibility: hidden !important;
174
+ opacity: 0 !important;
175
+ height: 0 !important;
176
+ width: 0 !important;
177
+ overflow: hidden !important;
178
+ }
179
+ /* Hide default POI marker images only (avoid div[style*="place"] etc. — it breaks map controls) */
180
+ .gm-style img[src*="poi"],
181
+ .gm-style img[src*="place"],
182
+ .gm-style img[src*="marker"][src*="default"] {
183
+ display: none !important;
184
+ visibility: hidden !important;
185
+ opacity: 0 !important;
186
+ height: 0 !important;
187
+ width: 0 !important;
188
+ pointer-events: none !important;
189
+ }
190
+ `
191
+ }} />
192
+ <GoogleMap
193
+ mapContainerStyle={mapContainerStyle}
194
+ center={mapCenter}
195
+ zoom={zoom}
196
+ onLoad={handleMapLoad}
197
+ onClick={handleMapClick}
198
+ options={{
199
+ disableDefaultUI: false,
200
+ zoomControl: true,
201
+ streetViewControl: false,
202
+ mapTypeControl: true,
203
+ fullscreenControl,
204
+ gestureHandling: 'greedy',
205
+ clickableIcons: false, // Disable clickable POI icons
206
+ restriction: {
207
+ latLngBounds: {
208
+ north: 49.38,
209
+ south: 24.40,
210
+ west: -125.0,
211
+ east: -66.93,
212
+ },
213
+ strictBounds: false,
214
+ },
215
+ styles: [
216
+ {
217
+ featureType: 'poi',
218
+ elementType: 'all',
219
+ stylers: [{ visibility: 'off' }],
220
+ },
221
+ {
222
+ featureType: 'poi',
223
+ elementType: 'labels',
224
+ stylers: [{ visibility: 'off' }],
225
+ },
226
+ {
227
+ featureType: 'poi',
228
+ elementType: 'geometry',
229
+ stylers: [{ visibility: 'off' }],
230
+ },
231
+ {
232
+ featureType: 'poi.business',
233
+ elementType: 'all',
234
+ stylers: [{ visibility: 'off' }],
235
+ },
236
+ {
237
+ featureType: 'poi.attraction',
238
+ elementType: 'all',
239
+ stylers: [{ visibility: 'off' }],
240
+ },
241
+ {
242
+ featureType: 'poi.place_of_worship',
243
+ elementType: 'all',
244
+ stylers: [{ visibility: 'off' }],
245
+ },
246
+ {
247
+ featureType: 'poi.school',
248
+ elementType: 'all',
249
+ stylers: [{ visibility: 'off' }],
250
+ },
251
+ {
252
+ featureType: 'poi.sports_complex',
253
+ elementType: 'all',
254
+ stylers: [{ visibility: 'off' }],
255
+ },
256
+ ],
257
+ }}
258
+ >
259
+ {reports.map((report) => {
260
+ const color = getSeverityColor(report.severity)
261
+
262
+ // Create icon directly here to ensure it's created when map is ready
263
+ if (!isLoaded || typeof google === 'undefined' || !google.maps) {
264
+ return null
265
+ }
266
+
267
+ // Create canvas icon on the fly
268
+ const canvas = document.createElement('canvas')
269
+ canvas.width = 24
270
+ canvas.height = 24
271
+ const ctx = canvas.getContext('2d')
272
+ if (!ctx) return null
273
+
274
+ // Draw colored circle
275
+ ctx.beginPath()
276
+ ctx.arc(12, 12, 10, 0, 2 * Math.PI)
277
+ ctx.fillStyle = color
278
+ ctx.fill()
279
+ ctx.strokeStyle = '#ffffff'
280
+ ctx.lineWidth = 3
281
+ ctx.stroke()
282
+
283
+ const iconUrl = canvas.toDataURL()
284
+
285
+ return (
286
+ <Marker
287
+ key={report.id}
288
+ position={{ lat: report.lat, lng: report.lng }}
289
+ icon={{
290
+ url: iconUrl,
291
+ scaledSize: new google.maps.Size(24, 24),
292
+ anchor: new google.maps.Point(12, 12),
293
+ }}
294
+ clickable={true}
295
+ cursor="pointer"
296
+ title={`${report.crop} - ${report.disease} (${report.severity} severity)`}
297
+ onClick={() => {
298
+ setSelectedReport(report)
299
+ }}
300
+ animation={google.maps.Animation.DROP}
301
+ />
302
+ )
303
+ })}
304
+
305
+ {selectedReport && (
306
+ <InfoWindow
307
+ position={{ lat: selectedReport.lat, lng: selectedReport.lng }}
308
+ onCloseClick={() => setSelectedReport(null)}
309
+ options={{
310
+ pixelOffset: new google.maps.Size(0, -10),
311
+ }}
312
+ >
313
+ <div className="p-3 min-w-[min(18rem,calc(100vw-3rem))] max-w-[min(300px,calc(100vw-2rem))]">
314
+ <div className="flex items-start justify-between mb-2">
315
+ <h3 className="font-bold text-lg text-gray-900">
316
+ {selectedReport.crop.charAt(0).toUpperCase() + selectedReport.crop.slice(1)} - {selectedReport.disease}
317
+ </h3>
318
+ <button
319
+ onClick={() => setSelectedReport(null)}
320
+ className="text-gray-400 hover:text-gray-600 ml-2"
321
+ aria-label="Close"
322
+ >
323
+ ×
324
+ </button>
325
+ </div>
326
+
327
+ <div className="mb-3">
328
+ <span
329
+ className={`inline-block px-3 py-1 rounded-full text-xs font-bold ${
330
+ selectedReport.severity === 'high'
331
+ ? 'bg-red-100 text-red-800 border-2 border-red-300'
332
+ : selectedReport.severity === 'medium'
333
+ ? 'bg-orange-100 text-orange-800 border-2 border-orange-300'
334
+ : 'bg-yellow-100 text-yellow-800 border-2 border-yellow-300'
335
+ }`}
336
+ >
337
+ {selectedReport.severity.toUpperCase()} SEVERITY
338
+ </span>
339
+ </div>
340
+
341
+ {selectedReport.description && (
342
+ <p className="text-sm text-gray-700 mb-3 leading-relaxed">
343
+ {selectedReport.description}
344
+ </p>
345
+ )}
346
+
347
+ {selectedReport.reporterVerified !== undefined && (
348
+ <div className="mb-3">
349
+ <span
350
+ className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full border ${
351
+ selectedReport.reporterVerified
352
+ ? 'bg-emerald-100 text-emerald-900 border-emerald-300'
353
+ : 'bg-slate-100 text-slate-700 border-slate-300'
354
+ }`}
355
+ >
356
+ {selectedReport.reporterVerified ? 'Verified farmer report' : 'Unverified farmer report'}
357
+ </span>
358
+ </div>
359
+ )}
360
+
361
+ <div className="border-t border-gray-200 pt-2 mt-2">
362
+ <p className="text-xs text-gray-500">
363
+ <span className="font-semibold">Location:</span> {selectedReport.lat.toFixed(4)}, {selectedReport.lng.toFixed(4)}
364
+ </p>
365
+ <p className="text-xs text-gray-500 mt-1">
366
+ <span className="font-semibold">Reported:</span> {new Date(selectedReport.date).toLocaleString()}
367
+ </p>
368
+ </div>
369
+ </div>
370
+ </InfoWindow>
371
+ )}
372
+ </GoogleMap>
373
+
374
+ {/* Click instruction overlay */}
375
+ {showMapClickHint && (
376
+ <div className="absolute bottom-3 left-3 right-3 sm:left-6 sm:right-auto sm:bottom-6 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-3 py-2.5 sm:px-5 sm:py-3 rounded-lg shadow-xl z-10 border border-blue-800 pointer-events-none">
377
+ <p className="text-xs sm:text-sm font-semibold flex items-center gap-2">
378
+ <MapPin className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
379
+ Tap or click the map to report an outbreak
380
+ </p>
381
+ </div>
382
+ )}
383
+ </div>
384
+ )
385
+ }
components/HealthComparisonPanel.tsx ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { ArrowLeftRight, Loader2, TrendingDown, TrendingUp, Minus } from 'lucide-react'
5
+ import ImageUpload from '@/components/ImageUpload'
6
+ import { compareHealthTrend, trendLabel, type HealthTrend } from '@/lib/healthComparison'
7
+ import type { PredictionPayload } from '@/lib/stateDiseaseMap'
8
+
9
+ type Props = {
10
+ crop: string
11
+ applyRegionalFilter: (raw: PredictionPayload) => PredictionPayload
12
+ }
13
+
14
+ async function runPredict(file: File, crop: string): Promise<PredictionPayload> {
15
+ const formData = new FormData()
16
+ formData.append('image', file)
17
+ formData.append('crop', crop)
18
+ const response = await fetch('/api/predict', { method: 'POST', body: formData })
19
+ if (!response.ok) {
20
+ const err = await response.json().catch(() => ({}))
21
+ throw new Error(err.error || 'Prediction failed')
22
+ }
23
+ const data = await response.json()
24
+ return {
25
+ disease: data.disease,
26
+ confidence: data.confidence,
27
+ is_healthy: data.is_healthy,
28
+ meets_threshold: data.meets_threshold,
29
+ all_predictions: data.all_predictions,
30
+ }
31
+ }
32
+
33
+ function trendStyles(t: HealthTrend) {
34
+ switch (t) {
35
+ case 'improving':
36
+ return {
37
+ border: 'border-emerald-300',
38
+ bg: 'bg-emerald-50',
39
+ text: 'text-emerald-900',
40
+ Icon: TrendingUp,
41
+ }
42
+ case 'worsening':
43
+ return {
44
+ border: 'border-rose-300',
45
+ bg: 'bg-rose-50',
46
+ text: 'text-rose-900',
47
+ Icon: TrendingDown,
48
+ }
49
+ default:
50
+ return {
51
+ border: 'border-slate-300',
52
+ bg: 'bg-slate-50',
53
+ text: 'text-slate-800',
54
+ Icon: Minus,
55
+ }
56
+ }
57
+ }
58
+
59
+ export default function HealthComparisonPanel({ crop, applyRegionalFilter }: Props) {
60
+ const [pastFile, setPastFile] = useState<File | null>(null)
61
+ const [currentFile, setCurrentFile] = useState<File | null>(null)
62
+ const [pastUrl, setPastUrl] = useState<string | null>(null)
63
+ const [currentUrl, setCurrentUrl] = useState<string | null>(null)
64
+ const [pastPred, setPastPred] = useState<PredictionPayload | null>(null)
65
+ const [currentPred, setCurrentPred] = useState<PredictionPayload | null>(null)
66
+ const [loading, setLoading] = useState(false)
67
+ const [error, setError] = useState<string | null>(null)
68
+
69
+ const onPastSelect = (file: File | null) => {
70
+ setPastFile(file)
71
+ setPastUrl((prev) => {
72
+ if (prev) URL.revokeObjectURL(prev)
73
+ return file ? URL.createObjectURL(file) : null
74
+ })
75
+ setPastPred(null)
76
+ }
77
+
78
+ const onCurrentSelect = (file: File | null) => {
79
+ setCurrentFile(file)
80
+ setCurrentUrl((prev) => {
81
+ if (prev) URL.revokeObjectURL(prev)
82
+ return file ? URL.createObjectURL(file) : null
83
+ })
84
+ setCurrentPred(null)
85
+ }
86
+
87
+ const clearPast = () => onPastSelect(null)
88
+ const clearCurrent = () => onCurrentSelect(null)
89
+
90
+ const handleCompare = async () => {
91
+ if (!pastFile || !currentFile) {
92
+ setError('Please upload both a past and a current photo.')
93
+ return
94
+ }
95
+ setLoading(true)
96
+ setError(null)
97
+ setPastPred(null)
98
+ setCurrentPred(null)
99
+ try {
100
+ const [rawPast, rawCurrent] = await Promise.all([
101
+ runPredict(pastFile, crop),
102
+ runPredict(currentFile, crop),
103
+ ])
104
+ setPastPred(applyRegionalFilter(rawPast))
105
+ setCurrentPred(applyRegionalFilter(rawCurrent))
106
+ } catch (e: unknown) {
107
+ setError(e instanceof Error ? e.message : 'Comparison failed')
108
+ } finally {
109
+ setLoading(false)
110
+ }
111
+ }
112
+
113
+ const trend =
114
+ pastPred && currentPred
115
+ ? compareHealthTrend(
116
+ { disease: pastPred.disease, crop, is_healthy: pastPred.is_healthy },
117
+ { disease: currentPred.disease, crop, is_healthy: currentPred.is_healthy }
118
+ )
119
+ : null
120
+
121
+ const ts = trend ? trendStyles(trend) : null
122
+
123
+ return (
124
+ <div className="mt-8 pt-8 border-t border-slate-200">
125
+ <h3 className="text-base font-semibold text-slate-900 flex items-center gap-2">
126
+ <ArrowLeftRight className="w-5 h-5 text-primary-700" />
127
+ Compare past vs current
128
+ </h3>
129
+ <p className="text-sm text-slate-600 mt-1 mb-5">
130
+ Upload an older leaf photo and a current one (same crop). We run both through the same model and summarize the
131
+ trend.
132
+ </p>
133
+
134
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
135
+ <div>
136
+ <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Past</p>
137
+ <ImageUpload
138
+ selectedImage={pastFile}
139
+ onImageSelect={onPastSelect}
140
+ onClear={clearPast}
141
+ title="Past photo"
142
+ hint="Older image for comparison."
143
+ />
144
+ </div>
145
+ <div>
146
+ <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Current</p>
147
+ <ImageUpload
148
+ selectedImage={currentFile}
149
+ onImageSelect={onCurrentSelect}
150
+ onClear={clearCurrent}
151
+ title="Current photo"
152
+ hint="Most recent image."
153
+ />
154
+ </div>
155
+ </div>
156
+
157
+ <button
158
+ type="button"
159
+ onClick={handleCompare}
160
+ disabled={!pastFile || !currentFile || loading}
161
+ className="touch-manipulation mt-6 min-h-[48px] w-full md:w-auto inline-flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-slate-900 text-white font-semibold shadow-sm hover:bg-slate-800 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
162
+ >
163
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowLeftRight className="w-4 h-4" />}
164
+ {loading ? 'Analyzing both…' : 'Compare photos'}
165
+ </button>
166
+
167
+ {error && (
168
+ <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">{error}</div>
169
+ )}
170
+
171
+ {pastPred && currentPred && trend && ts && (
172
+ <div className="mt-8 space-y-6">
173
+ <div
174
+ className={`flex flex-col sm:flex-row items-center justify-center gap-4 rounded-2xl border-2 px-6 py-5 ${ts.border} ${ts.bg}`}
175
+ >
176
+ <ts.Icon className={`w-10 h-10 shrink-0 ${ts.text}`} />
177
+ <div className="text-center sm:text-left">
178
+ <p className={`text-xs font-semibold uppercase tracking-wide ${ts.text} opacity-80`}>Comparison</p>
179
+ <p className={`text-2xl font-bold ${ts.text}`}>{trendLabel(trend)}</p>
180
+ <p className="text-sm text-slate-600 mt-1 max-w-md">
181
+ Based on estimated severity from each diagnosis (not a substitute for scouting or lab tests).
182
+ </p>
183
+ </div>
184
+ </div>
185
+
186
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
187
+ <ComparisonCard label="Past" imageUrl={pastUrl} prediction={pastPred} accent="border-slate-200" />
188
+ <ComparisonCard label="Current" imageUrl={currentUrl} prediction={currentPred} accent="border-primary-200" />
189
+ </div>
190
+ </div>
191
+ )}
192
+ </div>
193
+ )
194
+ }
195
+
196
+ function ComparisonCard({
197
+ label,
198
+ imageUrl,
199
+ prediction,
200
+ accent,
201
+ }: {
202
+ label: string
203
+ imageUrl: string | null
204
+ prediction: PredictionPayload
205
+ accent: string
206
+ }) {
207
+ return (
208
+ <div className={`rounded-2xl border-2 bg-white overflow-hidden shadow-sm ${accent}`}>
209
+ <div className="px-4 py-2 bg-slate-50 border-b border-slate-200">
210
+ <span className="text-sm font-bold text-slate-800">{label}</span>
211
+ </div>
212
+ {imageUrl && (
213
+ <div className="h-44 w-full bg-slate-100 flex items-center justify-center p-2">
214
+ <img src={imageUrl} alt="" className="max-h-full max-w-full object-contain rounded-lg" />
215
+ </div>
216
+ )}
217
+ <div className="p-4">
218
+ <p className="text-xs text-slate-500 uppercase font-semibold">Prediction</p>
219
+ <p className="text-lg font-semibold text-slate-900 mt-0.5">{prediction.disease}</p>
220
+ <p className="text-sm text-primary-800 font-semibold mt-2 tabular-nums">
221
+ Confidence {typeof prediction.confidence === 'number' ? prediction.confidence.toFixed(1) : '—'}%
222
+ </p>
223
+ </div>
224
+ </div>
225
+ )
226
+ }
components/ImageUpload.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useRef } from 'react'
4
+ import { Image as ImageIcon, Upload, X } from 'lucide-react'
5
+
6
+ interface ImageUploadProps {
7
+ selectedImage: File | null
8
+ onImageSelect: (file: File | null) => void
9
+ onClear: () => void
10
+ /** Override default upload prompt (e.g. "Past photo") */
11
+ title?: string
12
+ hint?: string
13
+ }
14
+
15
+ export default function ImageUpload({
16
+ selectedImage,
17
+ onImageSelect,
18
+ onClear,
19
+ title = 'Upload a leaf photo',
20
+ hint = 'Drag & drop, choose a file, or take a photo.',
21
+ }: ImageUploadProps) {
22
+ const [dragActive, setDragActive] = useState(false)
23
+ const fileInputRef = useRef<HTMLInputElement>(null)
24
+ const cameraInputRef = useRef<HTMLInputElement>(null)
25
+
26
+ const handleDrag = (e: React.DragEvent) => {
27
+ e.preventDefault()
28
+ e.stopPropagation()
29
+ if (e.type === 'dragenter' || e.type === 'dragover') {
30
+ setDragActive(true)
31
+ } else if (e.type === 'dragleave') {
32
+ setDragActive(false)
33
+ }
34
+ }
35
+
36
+ const handleDrop = (e: React.DragEvent) => {
37
+ e.preventDefault()
38
+ e.stopPropagation()
39
+ setDragActive(false)
40
+
41
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
42
+ handleFile(e.dataTransfer.files[0])
43
+ }
44
+ }
45
+
46
+ const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
47
+ if (e.target.files && e.target.files[0]) {
48
+ handleFile(e.target.files[0])
49
+ }
50
+ }
51
+
52
+ const handleFile = (file: File) => {
53
+ if (file.type.startsWith('image/')) {
54
+ onImageSelect(file)
55
+ }
56
+ }
57
+
58
+ const imageUrl = selectedImage ? URL.createObjectURL(selectedImage) : null
59
+
60
+ return (
61
+ <div>
62
+ {!imageUrl ? (
63
+ <div
64
+ className={`border border-dashed rounded-2xl p-6 sm:p-10 text-center transition-all duration-200 bg-slate-50/60 ${
65
+ dragActive
66
+ ? 'border-primary-500 bg-white shadow-md'
67
+ : 'border-slate-300 hover:border-slate-400 hover:bg-white'
68
+ }`}
69
+ onDragEnter={handleDrag}
70
+ onDragLeave={handleDrag}
71
+ onDragOver={handleDrag}
72
+ onDrop={handleDrop}
73
+ >
74
+ <div className="mx-auto w-12 h-12 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center mb-4">
75
+ <ImageIcon className="w-6 h-6 text-primary-700" />
76
+ </div>
77
+ <p className="text-base font-semibold text-slate-900 mb-1">{title}</p>
78
+ <p className="text-sm text-slate-600 mb-5">{hint}</p>
79
+ <div className="flex flex-col sm:flex-row gap-2 justify-center items-stretch sm:items-center max-w-md mx-auto w-full">
80
+ <button
81
+ type="button"
82
+ onClick={() => fileInputRef.current?.click()}
83
+ className="touch-manipulation inline-flex items-center justify-center gap-2 min-h-[44px] px-5 py-2.5 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors shadow-sm"
84
+ >
85
+ <Upload className="w-4 h-4 shrink-0" />
86
+ Choose file
87
+ </button>
88
+ <button
89
+ type="button"
90
+ onClick={() => cameraInputRef.current?.click()}
91
+ className="touch-manipulation inline-flex items-center justify-center gap-2 min-h-[44px] px-5 py-2.5 bg-white text-slate-900 border-2 border-slate-200 rounded-xl font-semibold hover:bg-slate-50 transition-colors shadow-sm"
92
+ >
93
+ <ImageIcon className="w-4 h-4 shrink-0" />
94
+ Take photo
95
+ </button>
96
+ </div>
97
+ <input
98
+ ref={fileInputRef}
99
+ type="file"
100
+ accept="image/*"
101
+ onChange={handleFileInput}
102
+ className="hidden"
103
+ />
104
+ <input
105
+ ref={cameraInputRef}
106
+ type="file"
107
+ accept="image/*"
108
+ capture="environment"
109
+ onChange={handleFileInput}
110
+ className="hidden"
111
+ />
112
+ </div>
113
+ ) : (
114
+ <div className="relative group bg-white rounded-2xl p-4 border border-slate-200 shadow-sm">
115
+ <div className="relative w-full h-auto max-h-[520px] flex items-center justify-center bg-slate-50 rounded-xl overflow-hidden border border-slate-200">
116
+ <img
117
+ src={imageUrl}
118
+ alt="Crop image preview"
119
+ className="w-full h-auto max-h-[500px] object-contain rounded-lg"
120
+ style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
121
+ onError={(e) => {
122
+ console.error('Image failed to load:', imageUrl)
123
+ e.currentTarget.style.display = 'none'
124
+ }}
125
+ />
126
+ </div>
127
+ <button
128
+ type="button"
129
+ onClick={onClear}
130
+ className="touch-manipulation absolute top-3 right-3 sm:top-6 sm:right-6 min-h-[44px] min-w-[44px] px-3 py-2 bg-white/90 hover:bg-white text-slate-900 rounded-xl transition-colors shadow-sm border border-slate-200 flex items-center justify-center gap-2 font-semibold text-sm"
131
+ >
132
+ <X className="w-4 h-4" />
133
+ Remove
134
+ </button>
135
+ </div>
136
+ )}
137
+ </div>
138
+ )
139
+ }
components/LeafletMap.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet'
5
+ import L from 'leaflet'
6
+ import 'leaflet/dist/leaflet.css'
7
+
8
+ // Fix for default marker icons in Next.js
9
+ if (typeof window !== 'undefined') {
10
+ delete (L.Icon.Default.prototype as any)._getIconUrl
11
+ L.Icon.Default.mergeOptions({
12
+ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
13
+ iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
14
+ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
15
+ })
16
+ }
17
+
18
+ interface OutbreakReport {
19
+ id: string
20
+ lat: number
21
+ lng: number
22
+ crop: string
23
+ disease: string
24
+ severity: 'low' | 'medium' | 'high'
25
+ date: string
26
+ description: string
27
+ }
28
+
29
+ interface LeafletMapProps {
30
+ reports?: OutbreakReport[]
31
+ onMapClick?: (lat: number, lng: number) => void
32
+ }
33
+
34
+ // Component to handle map clicks
35
+ function MapClickHandler({ onClick }: { onClick: (lat: number, lng: number) => void }) {
36
+ useMapEvents({
37
+ click: (e) => {
38
+ if (onClick) {
39
+ onClick(e.latlng.lat, e.latlng.lng)
40
+ }
41
+ },
42
+ })
43
+ return null
44
+ }
45
+
46
+ export default function LeafletMap({ reports = [], onMapClick }: LeafletMapProps) {
47
+ useEffect(() => {
48
+ // Ensure we're in the browser
49
+ if (typeof window === 'undefined') return
50
+ }, [])
51
+
52
+ const getSeverityColor = (severity: string) => {
53
+ switch (severity) {
54
+ case 'high':
55
+ return '#ef4444'
56
+ case 'medium':
57
+ return '#f97316'
58
+ case 'low':
59
+ return '#eab308'
60
+ default:
61
+ return '#64748b'
62
+ }
63
+ }
64
+
65
+ const createMarkerIcon = (severity: string) => {
66
+ const color = getSeverityColor(severity)
67
+ return L.divIcon({
68
+ className: 'custom-marker',
69
+ html: `<div style="
70
+ width: 20px;
71
+ height: 20px;
72
+ background-color: ${color};
73
+ border: 3px solid white;
74
+ border-radius: 50%;
75
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
76
+ "></div>`,
77
+ iconSize: [20, 20],
78
+ iconAnchor: [10, 10],
79
+ })
80
+ }
81
+
82
+ return (
83
+ <MapContainer
84
+ center={[39.8283, -98.5795]} // Center of US
85
+ zoom={4}
86
+ style={{ height: '100%', width: '100%', zIndex: 0 }}
87
+ maxBounds={[
88
+ [24.39, -125], // Southwest corner
89
+ [49.38, -66.93], // Northeast corner
90
+ ]}
91
+ minZoom={4}
92
+ maxZoom={10}
93
+ >
94
+ <TileLayer
95
+ attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
96
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
97
+ />
98
+
99
+ <MapClickHandler onClick={onMapClick || (() => {})} />
100
+
101
+ {/* Markers for existing reports */}
102
+ {reports.map((report) => (
103
+ <Marker
104
+ key={report.id}
105
+ position={[report.lat, report.lng]}
106
+ icon={createMarkerIcon(report.severity)}
107
+ >
108
+ <Popup>
109
+ <div className="p-2">
110
+ <h3 className="font-bold text-lg mb-1">
111
+ {report.crop} - {report.disease}
112
+ </h3>
113
+ <p className="text-sm text-gray-600 mb-1">
114
+ <span
115
+ className={`px-2 py-1 rounded text-xs font-bold ${
116
+ report.severity === 'high'
117
+ ? 'bg-red-100 text-red-800'
118
+ : report.severity === 'medium'
119
+ ? 'bg-orange-100 text-orange-800'
120
+ : 'bg-yellow-100 text-yellow-800'
121
+ }`}
122
+ >
123
+ {report.severity.toUpperCase()}
124
+ </span>
125
+ </p>
126
+ {report.description && (
127
+ <p className="text-sm text-gray-700 mt-2">{report.description}</p>
128
+ )}
129
+ <p className="text-xs text-gray-500 mt-2">
130
+ {new Date(report.date).toLocaleString()}
131
+ </p>
132
+ </div>
133
+ </Popup>
134
+ </Marker>
135
+ ))}
136
+ </MapContainer>
137
+ )
138
+ }
components/NotificationSystem.tsx ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useMemo, useRef } from 'react'
4
+ import { Bell, X, AlertTriangle, MapPin, Check } from 'lucide-react'
5
+ import {
6
+ findAffectedFarmers,
7
+ generateNotificationMessage,
8
+ type OutbreakLocation,
9
+ type FarmerLocation,
10
+ type Notification,
11
+ } from '@/lib/notifications'
12
+ import type { OutbreakReport } from '@/lib/outbreakReport'
13
+
14
+ interface NotificationSystemProps {
15
+ outbreaks: OutbreakReport[]
16
+ currentFarmerLocation?: { lat: number; lng: number; crops: string[] }
17
+ }
18
+
19
+ const SEVERITY_SORT = { high: 0, medium: 1, low: 2 } as const
20
+
21
+ export default function NotificationSystem({
22
+ outbreaks,
23
+ currentFarmerLocation,
24
+ }: NotificationSystemProps) {
25
+ const [notifications, setNotifications] = useState<Notification[]>([])
26
+ const [isOpen, setIsOpen] = useState(false)
27
+ /** Outbreak IDs the user dismissed — otherwise the sync effect recreates them */
28
+ const [dismissedOutbreakIds, setDismissedOutbreakIds] = useState<Set<string>>(() => new Set())
29
+ const rootRef = useRef<HTMLDivElement>(null)
30
+
31
+ useEffect(() => {
32
+ if (!isOpen) return
33
+ const onKeyDown = (e: KeyboardEvent) => {
34
+ if (e.key === 'Escape') setIsOpen(false)
35
+ }
36
+ // Bubble-phase click avoids capture-phase pointer handlers stealing taps before button onClick runs.
37
+ const onDocumentClick = (e: MouseEvent) => {
38
+ const root = rootRef.current
39
+ if (root && !root.contains(e.target as Node)) setIsOpen(false)
40
+ }
41
+ window.addEventListener('keydown', onKeyDown)
42
+ document.addEventListener('click', onDocumentClick)
43
+ return () => {
44
+ window.removeEventListener('keydown', onKeyDown)
45
+ document.removeEventListener('click', onDocumentClick)
46
+ }
47
+ }, [isOpen])
48
+
49
+ /** Demo persona when no farm is registered — must match `findAffectedFarmers` ids */
50
+ const DEMO_FARMER_ID = 'farmer-1'
51
+
52
+ // Recompute when registration changes (useState initializer only runs once)
53
+ const farmers = useMemo<FarmerLocation[]>(() => [
54
+ // Arkansas area farmers
55
+ {
56
+ id: 'farmer-1',
57
+ name: 'John Smith',
58
+ email: 'john@example.com',
59
+ lat: 35.5, // Near Russellville, AR (~20 miles)
60
+ lng: -93.2,
61
+ crops: ['corn', 'wheat', 'soybean'],
62
+ radius: 250,
63
+ },
64
+ {
65
+ id: 'farmer-2',
66
+ name: 'Sarah Johnson',
67
+ email: 'sarah@example.com',
68
+ lat: 35.1, // Within 250 miles of Russellville (~30 miles)
69
+ lng: -92.8,
70
+ crops: ['corn', 'rice'],
71
+ radius: 250,
72
+ },
73
+ {
74
+ id: 'farmer-3',
75
+ name: 'Mike Davis',
76
+ email: 'mike@example.com',
77
+ lat: 36.0, // Within 250 miles of Russellville (~50 miles)
78
+ lng: -93.5,
79
+ crops: ['corn', 'wheat', 'soybean'],
80
+ radius: 250,
81
+ },
82
+ {
83
+ id: 'farmer-4',
84
+ name: 'Arkansas Farm Co.',
85
+ email: 'info@arkfarm.com',
86
+ lat: 34.7, // Little Rock area - within 250 miles (~80 miles)
87
+ lng: -92.3,
88
+ crops: ['corn', 'soybean'],
89
+ radius: 250,
90
+ },
91
+ // California farmers
92
+ {
93
+ id: 'farmer-5',
94
+ name: 'Central Valley Farms',
95
+ email: 'contact@cvfarms.com',
96
+ lat: 36.5, // Near Fresno, CA
97
+ lng: -119.5,
98
+ crops: ['wheat', 'corn'],
99
+ radius: 250,
100
+ },
101
+ {
102
+ id: 'farmer-6',
103
+ name: 'Golden State Agriculture',
104
+ email: 'info@gsag.com',
105
+ lat: 37.0, // Near Modesto, CA
106
+ lng: -120.5,
107
+ crops: ['wheat', 'corn', 'soybean'],
108
+ radius: 250,
109
+ },
110
+ // Texas farmers
111
+ {
112
+ id: 'farmer-7',
113
+ name: 'Lone Star Crops',
114
+ email: 'hello@lonestarcrops.com',
115
+ lat: 32.0, // Near Abilene, TX
116
+ lng: -99.5,
117
+ crops: ['corn', 'wheat'],
118
+ radius: 250,
119
+ },
120
+ {
121
+ id: 'farmer-8',
122
+ name: 'Texas Grain Co.',
123
+ email: 'info@texasgrain.com',
124
+ lat: 31.5, // Near San Angelo, TX
125
+ lng: -100.0,
126
+ crops: ['corn', 'soybean'],
127
+ radius: 250,
128
+ },
129
+ // Iowa farmers
130
+ {
131
+ id: 'farmer-9',
132
+ name: 'Iowa Corn Growers',
133
+ email: 'contact@iowacorn.com',
134
+ lat: 41.5, // Near Des Moines, IA
135
+ lng: -93.0,
136
+ crops: ['corn', 'soybean'],
137
+ radius: 250,
138
+ },
139
+ {
140
+ id: 'farmer-10',
141
+ name: 'Midwest Agriculture',
142
+ email: 'info@midwestag.com',
143
+ lat: 42.0, // Near Cedar Rapids, IA
144
+ lng: -91.5,
145
+ crops: ['corn', 'soybean', 'wheat'],
146
+ radius: 250,
147
+ },
148
+ // Illinois farmers
149
+ {
150
+ id: 'farmer-11',
151
+ name: 'Prairie Farms',
152
+ email: 'hello@prairiefarms.com',
153
+ lat: 40.0, // Near Champaign, IL
154
+ lng: -88.5,
155
+ crops: ['corn', 'soybean'],
156
+ radius: 250,
157
+ },
158
+ // Kansas farmers
159
+ {
160
+ id: 'farmer-12',
161
+ name: 'Kansas Wheat Growers',
162
+ email: 'info@kswheat.com',
163
+ lat: 38.5, // Near Wichita, KS
164
+ lng: -98.0,
165
+ crops: ['wheat', 'corn'],
166
+ radius: 250,
167
+ },
168
+ {
169
+ id: 'farmer-13',
170
+ name: 'Sunflower State Farms',
171
+ email: 'contact@sunflowerfarms.com',
172
+ lat: 39.0, // Near Topeka, KS
173
+ lng: -95.5,
174
+ crops: ['wheat', 'corn', 'soybean'],
175
+ radius: 250,
176
+ },
177
+ // Nebraska farmers
178
+ {
179
+ id: 'farmer-14',
180
+ name: 'Cornhusker Agriculture',
181
+ email: 'info@cornhuskerag.com',
182
+ lat: 41.0, // Near Lincoln, NE
183
+ lng: -96.5,
184
+ crops: ['corn', 'soybean'],
185
+ radius: 250,
186
+ },
187
+ // North Carolina farmers
188
+ {
189
+ id: 'farmer-15',
190
+ name: 'Carolina Crops',
191
+ email: 'hello@carolinacrops.com',
192
+ lat: 35.5, // Near Charlotte, NC
193
+ lng: -80.5,
194
+ crops: ['corn', 'soybean'],
195
+ radius: 250,
196
+ },
197
+ // Ohio farmers
198
+ {
199
+ id: 'farmer-16',
200
+ name: 'Buckeye Farms',
201
+ email: 'info@buckeyefarms.com',
202
+ lat: 40.0, // Near Columbus, OH
203
+ lng: -83.0,
204
+ crops: ['corn', 'soybean', 'wheat'],
205
+ radius: 250,
206
+ },
207
+ // Add current user if location is available
208
+ ...(currentFarmerLocation
209
+ ? [
210
+ {
211
+ id: 'current-user',
212
+ name: 'You',
213
+ lat: currentFarmerLocation.lat,
214
+ lng: currentFarmerLocation.lng,
215
+ crops: currentFarmerLocation.crops,
216
+ radius: 250,
217
+ } as FarmerLocation,
218
+ ]
219
+ : []),
220
+ ], [currentFarmerLocation])
221
+
222
+ const toOutbreakLocation = (report: OutbreakReport): OutbreakLocation => ({
223
+ id: report.id,
224
+ lat: report.lat,
225
+ lng: report.lng,
226
+ crop: report.crop,
227
+ disease: report.disease,
228
+ severity: report.severity,
229
+ date: report.date,
230
+ description: report.description,
231
+ })
232
+
233
+ useEffect(() => {
234
+ if (outbreaks.length === 0) {
235
+ setNotifications([])
236
+ return
237
+ }
238
+
239
+ const targetFarmerId = currentFarmerLocation ? 'current-user' : DEMO_FARMER_ID
240
+
241
+ setNotifications((prev) => {
242
+ const readByOutbreak = new Map<string, boolean>()
243
+ const createdByOutbreak = new Map<string, string>()
244
+ for (const n of prev) {
245
+ if (n.read) readByOutbreak.set(n.outbreakId, true)
246
+ createdByOutbreak.set(n.outbreakId, n.createdAt)
247
+ }
248
+
249
+ const next: Notification[] = []
250
+ for (const report of outbreaks) {
251
+ if (dismissedOutbreakIds.has(report.id)) continue
252
+
253
+ const outbreakLocation = toOutbreakLocation(report)
254
+ const affected = findAffectedFarmers(outbreakLocation, farmers)
255
+ const match = affected.find((a) => a.farmer.id === targetFarmerId)
256
+ if (!match) continue
257
+
258
+ const verifiedTail =
259
+ report.reporterVerified === true
260
+ ? ' (verified farmer report)'
261
+ : report.reporterVerified === false
262
+ ? ' (unverified farmer report)'
263
+ : ' (community report)'
264
+
265
+ next.push({
266
+ id: `${report.id}-${targetFarmerId}`,
267
+ farmerId: targetFarmerId,
268
+ outbreakId: report.id,
269
+ distance: match.distance,
270
+ message: `${generateNotificationMessage(outbreakLocation, match.distance)}${verifiedTail}`,
271
+ severity: outbreakLocation.severity,
272
+ read: readByOutbreak.get(report.id) ?? false,
273
+ createdAt: createdByOutbreak.get(report.id) ?? new Date().toISOString(),
274
+ })
275
+ }
276
+
277
+ next.sort((a, b) => {
278
+ const s = SEVERITY_SORT[a.severity] - SEVERITY_SORT[b.severity]
279
+ if (s !== 0) return s
280
+ return a.distance - b.distance
281
+ })
282
+
283
+ return next
284
+ })
285
+ }, [outbreaks, farmers, currentFarmerLocation, dismissedOutbreakIds])
286
+
287
+ const markAsRead = (notificationId: string) => {
288
+ setNotifications((prev) =>
289
+ prev.map((notif) =>
290
+ notif.id === notificationId ? { ...notif, read: true } : notif
291
+ )
292
+ )
293
+ }
294
+
295
+ const markAllAsRead = () => {
296
+ setNotifications((prev) => prev.map((notif) => ({ ...notif, read: true })))
297
+ }
298
+
299
+ const deleteNotification = (notificationId: string) => {
300
+ setNotifications((prev) => {
301
+ const n = prev.find((x) => x.id === notificationId)
302
+ if (n) {
303
+ setDismissedOutbreakIds((s) => new Set(s).add(n.outbreakId))
304
+ }
305
+ return prev.filter((x) => x.id !== notificationId)
306
+ })
307
+ }
308
+
309
+ const unreadCount = notifications.filter((n) => !n.read).length
310
+
311
+ return (
312
+ <div
313
+ ref={rootRef}
314
+ className="relative z-[200] inline-flex shrink-0 items-center justify-center self-center"
315
+ >
316
+ <button
317
+ type="button"
318
+ onClick={() => setIsOpen(!isOpen)}
319
+ className="touch-manipulation relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border-2 border-slate-200 bg-white p-2 shadow-sm transition-all hover:border-primary-400 hover:shadow-md"
320
+ aria-label="Notifications"
321
+ aria-expanded={isOpen}
322
+ aria-haspopup="dialog"
323
+ >
324
+ <Bell className="h-6 w-6 text-slate-700" />
325
+ {unreadCount > 0 && (
326
+ <span className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-red-600 text-xs font-bold text-white">
327
+ {unreadCount > 9 ? '9+' : unreadCount}
328
+ </span>
329
+ )}
330
+ </button>
331
+ {isOpen && (
332
+ <div
333
+ role="dialog"
334
+ aria-modal="true"
335
+ aria-labelledby="disease-alerts-title"
336
+ className="absolute right-0 top-[calc(100%+10px)] flex max-h-[min(420px,70dvh)] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-2xl border border-slate-300 bg-white shadow-xl ring-1 ring-slate-900/10"
337
+ >
338
+ <div className="flex shrink-0 items-start justify-between gap-3 border-b border-slate-200 bg-white p-4">
339
+ <div className="min-w-0 flex-1">
340
+ <h3
341
+ id="disease-alerts-title"
342
+ className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-lg font-bold leading-tight text-slate-900 sm:text-xl"
343
+ >
344
+ <span className="inline-flex shrink-0 items-center gap-2">
345
+ <Bell className="h-5 w-5 shrink-0 text-primary-700" />
346
+ <span>Disease Alerts</span>
347
+ </span>
348
+ {unreadCount > 0 && (
349
+ <span className="inline-flex shrink-0 items-center rounded-full bg-red-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-white shadow-sm">
350
+ {unreadCount} new
351
+ </span>
352
+ )}
353
+ </h3>
354
+ </div>
355
+ <div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
356
+ {unreadCount > 0 && (
357
+ <button
358
+ type="button"
359
+ onClick={markAllAsRead}
360
+ className="whitespace-nowrap rounded-lg px-2 py-1.5 text-xs font-semibold text-primary-700 transition-colors hover:bg-primary-50"
361
+ >
362
+ Mark all read
363
+ </button>
364
+ )}
365
+ <button
366
+ type="button"
367
+ onClick={() => setIsOpen(false)}
368
+ className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800"
369
+ aria-label="Close notifications"
370
+ >
371
+ <X className="h-5 w-5" />
372
+ </button>
373
+ </div>
374
+ </div>
375
+
376
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-white p-2">
377
+ {notifications.length === 0 ? (
378
+ <div className="py-10 text-center text-slate-600">
379
+ <Bell className="mx-auto mb-3 h-12 w-12 text-slate-400" />
380
+ <p className="font-semibold text-slate-800">No alerts yet</p>
381
+ <p className="mt-1 text-sm text-slate-500">
382
+ You&apos;ll be notified when outbreaks occur within 250 miles
383
+ </p>
384
+ </div>
385
+ ) : (
386
+ <div className="space-y-2">
387
+ {notifications.map((notification) => {
388
+ const outbreak = outbreaks.find((o) => o.id === notification.outbreakId) as
389
+ | OutbreakReport
390
+ | undefined
391
+ return (
392
+ <div
393
+ key={notification.id}
394
+ className={`rounded-xl border p-4 transition-all ${
395
+ notification.read
396
+ ? 'border-slate-200 bg-slate-50'
397
+ : 'border-red-200 bg-red-50/95 shadow-sm'
398
+ }`}
399
+ >
400
+ <div className="flex items-start justify-between gap-3">
401
+ <div className="min-w-0 flex-1">
402
+ <div className="mb-2 flex gap-2">
403
+ <AlertTriangle
404
+ className={`mt-0.5 h-5 w-5 shrink-0 ${
405
+ notification.severity === 'high'
406
+ ? 'text-red-600'
407
+ : notification.severity === 'medium'
408
+ ? 'text-orange-600'
409
+ : 'text-yellow-600'
410
+ }`}
411
+ />
412
+ <p className="text-sm font-bold leading-snug text-slate-900">
413
+ {notification.message}
414
+ </p>
415
+ </div>
416
+ {outbreak && (
417
+ <div className="mt-2 space-y-1 pl-0 sm:pl-7">
418
+ {outbreak.reporterVerified !== undefined && (
419
+ <p className="text-xs">
420
+ <span
421
+ className={`inline-block rounded-md border px-2 py-0.5 font-bold ${
422
+ outbreak.reporterVerified
423
+ ? 'border-emerald-200 bg-emerald-100 text-emerald-900'
424
+ : 'border-slate-200 bg-slate-100 text-slate-700'
425
+ }`}
426
+ >
427
+ {outbreak.reporterVerified ? 'Verified farmer' : 'Unverified farmer'}
428
+ </span>
429
+ </p>
430
+ )}
431
+ <p className="flex items-center gap-1 text-xs text-slate-600">
432
+ <MapPin className="h-3 w-3 shrink-0" />
433
+ {notification.distance.toFixed(1)} miles away
434
+ </p>
435
+ <p className="text-xs text-slate-500">
436
+ {new Date(notification.createdAt).toLocaleString()}
437
+ </p>
438
+ </div>
439
+ )}
440
+ </div>
441
+ <div className="flex shrink-0 items-start gap-1">
442
+ {!notification.read && (
443
+ <button
444
+ type="button"
445
+ onClick={() => markAsRead(notification.id)}
446
+ className="rounded-lg p-1.5 text-green-700 transition-colors hover:bg-white"
447
+ title="Mark as read"
448
+ >
449
+ <Check className="h-4 w-4" />
450
+ </button>
451
+ )}
452
+ <button
453
+ type="button"
454
+ onClick={() => deleteNotification(notification.id)}
455
+ className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-800"
456
+ title="Delete"
457
+ >
458
+ <X className="h-4 w-4" />
459
+ </button>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ )
464
+ })}
465
+ </div>
466
+ )}
467
+ </div>
468
+
469
+ {notifications.length > 0 && (
470
+ <div className="shrink-0 border-t border-slate-200 bg-slate-100/90 p-3">
471
+ <p className="text-center text-xs text-slate-600">
472
+ Alerts within 250 miles of your farm (or demo location). Dismissed alerts stay hidden until refresh.
473
+ </p>
474
+ </div>
475
+ )}
476
+ </div>
477
+ )}
478
+ </div>
479
+ )
480
+ }
components/OutbreakMap.tsx ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { WorldMap } from '@/components/ui/world-map'
5
+ import { MapPin, AlertTriangle, X, Plus, Save } from 'lucide-react'
6
+ import { motion } from 'framer-motion'
7
+
8
+ interface OutbreakReport {
9
+ id: string
10
+ lat: number
11
+ lng: number
12
+ crop: string
13
+ disease: string
14
+ severity: 'low' | 'medium' | 'high'
15
+ date: string
16
+ description: string
17
+ }
18
+
19
+ export default function OutbreakMap() {
20
+ const [reports, setReports] = useState<OutbreakReport[]>([])
21
+ const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
22
+ const [showReportForm, setShowReportForm] = useState(false)
23
+ const [formData, setFormData] = useState({
24
+ crop: '',
25
+ disease: '',
26
+ severity: 'medium' as 'low' | 'medium' | 'high',
27
+ description: '',
28
+ })
29
+
30
+ // Convert reports to map dots format
31
+ const mapDots = reports.map((report) => ({
32
+ start: { lat: report.lat, lng: report.lng, label: report.disease },
33
+ end: { lat: report.lat, lng: report.lng, label: report.disease },
34
+ }))
35
+
36
+ const handleMapClick = (e: React.MouseEvent<HTMLDivElement>) => {
37
+ // Find the map container
38
+ const mapContainer = e.currentTarget.querySelector('[class*="aspect-[2/1]"]')
39
+ if (!mapContainer) return
40
+
41
+ const rect = mapContainer.getBoundingClientRect()
42
+ const x = e.clientX - rect.left
43
+ const y = e.clientY - rect.top
44
+
45
+ // Convert pixel coordinates to lat/lng (map is 800x400 viewBox)
46
+ const lng = (x / rect.width) * 360 - 180
47
+ const lat = 90 - (y / rect.height) * 180
48
+
49
+ // Clamp values to valid ranges
50
+ const clampedLat = Math.max(-90, Math.min(90, lat))
51
+ const clampedLng = Math.max(-180, Math.min(180, lng))
52
+
53
+ setSelectedLocation({ lat: clampedLat, lng: clampedLng })
54
+ setShowReportForm(true)
55
+ }
56
+
57
+ const handleSubmitReport = () => {
58
+ if (!selectedLocation || !formData.crop || !formData.disease) {
59
+ alert('Please fill in all required fields')
60
+ return
61
+ }
62
+
63
+ const newReport: OutbreakReport = {
64
+ id: Date.now().toString(),
65
+ lat: selectedLocation.lat,
66
+ lng: selectedLocation.lng,
67
+ crop: formData.crop,
68
+ disease: formData.disease,
69
+ severity: formData.severity,
70
+ date: new Date().toISOString(),
71
+ description: formData.description,
72
+ }
73
+
74
+ setReports([...reports, newReport])
75
+ setShowReportForm(false)
76
+ setSelectedLocation(null)
77
+ setFormData({
78
+ crop: '',
79
+ disease: '',
80
+ severity: 'medium',
81
+ description: '',
82
+ })
83
+ }
84
+
85
+ const getSeverityColor = (severity: string) => {
86
+ switch (severity) {
87
+ case 'high':
88
+ return 'text-red-600 bg-red-50 border-red-200'
89
+ case 'medium':
90
+ return 'text-orange-600 bg-orange-50 border-orange-200'
91
+ case 'low':
92
+ return 'text-yellow-600 bg-yellow-50 border-yellow-200'
93
+ default:
94
+ return 'text-gray-600 bg-gray-50 border-gray-200'
95
+ }
96
+ }
97
+
98
+ return (
99
+ <div className="min-h-screen min-h-[100dvh] bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100 py-6 sm:py-8 px-3 sm:px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-[max(0.75rem,env(safe-area-inset-top))]">
100
+ <div className="container mx-auto max-w-7xl">
101
+ {/* Header */}
102
+ <div className="mb-8 text-center">
103
+ <h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
104
+ Outbreak Reporting System
105
+ </h1>
106
+ <p className="text-lg text-slate-600 max-w-2xl mx-auto">
107
+ Click on the map to report potential crop disease outbreaks in your area
108
+ </p>
109
+ </div>
110
+
111
+ {/* Map Container */}
112
+ <div className="bg-white rounded-xl shadow-lg p-6 mb-6 border border-slate-200">
113
+ <div className="relative">
114
+ <WorldMap dots={mapDots} lineColor="#ef4444" />
115
+ {/* Clickable overlay for map interaction */}
116
+ <div
117
+ onClick={handleMapClick}
118
+ className="absolute inset-0 cursor-crosshair z-10"
119
+ style={{ pointerEvents: 'auto' }}
120
+ />
121
+ {selectedLocation && (
122
+ <div className="absolute top-4 right-4 bg-white rounded-lg shadow-lg p-4 border border-slate-200 z-20">
123
+ <p className="text-sm font-semibold text-slate-900 mb-1">Selected Location</p>
124
+ <p className="text-xs text-slate-600">
125
+ Lat: {selectedLocation.lat.toFixed(4)}, Lng: {selectedLocation.lng.toFixed(4)}
126
+ </p>
127
+ </div>
128
+ )}
129
+ <div className="absolute bottom-4 left-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-20">
130
+ <p className="text-sm font-semibold flex items-center gap-2">
131
+ <MapPin className="w-4 h-4" />
132
+ Click on map to report outbreak
133
+ </p>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ {/* Report Form Modal — avoid motion opacity on full-screen layer (invisible overlays still capture clicks). */}
139
+ {showReportForm && (
140
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
141
+ <div
142
+ role="presentation"
143
+ className="absolute inset-0"
144
+ onClick={() => {
145
+ setShowReportForm(false)
146
+ setSelectedLocation(null)
147
+ }}
148
+ />
149
+ <div
150
+ role="dialog"
151
+ aria-modal="true"
152
+ onClick={(e) => e.stopPropagation()}
153
+ className="relative z-10 max-h-[90dvh] w-full max-w-md overflow-y-auto overscroll-contain rounded-xl border border-slate-200 bg-white p-6 shadow-2xl"
154
+ >
155
+ <div className="flex items-center justify-between mb-4">
156
+ <h2 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
157
+ <AlertTriangle className="w-6 h-6 text-orange-500" />
158
+ Report Outbreak
159
+ </h2>
160
+ <button
161
+ onClick={() => {
162
+ setShowReportForm(false)
163
+ setSelectedLocation(null)
164
+ }}
165
+ className="text-slate-400 hover:text-slate-600 transition-colors"
166
+ >
167
+ <X className="w-5 h-5" />
168
+ </button>
169
+ </div>
170
+
171
+ <div className="space-y-4">
172
+ <div>
173
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
174
+ Crop Type *
175
+ </label>
176
+ <input
177
+ type="text"
178
+ value={formData.crop}
179
+ onChange={(e) => setFormData({ ...formData, crop: e.target.value })}
180
+ placeholder="e.g., Corn, Wheat, Rice"
181
+ className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
182
+ />
183
+ </div>
184
+
185
+ <div>
186
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
187
+ Disease Name *
188
+ </label>
189
+ <input
190
+ type="text"
191
+ value={formData.disease}
192
+ onChange={(e) => setFormData({ ...formData, disease: e.target.value })}
193
+ placeholder="e.g., Rust, Blight, Mosaic"
194
+ className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
195
+ />
196
+ </div>
197
+
198
+ <div>
199
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
200
+ Severity *
201
+ </label>
202
+ <select
203
+ value={formData.severity}
204
+ onChange={(e) =>
205
+ setFormData({
206
+ ...formData,
207
+ severity: e.target.value as 'low' | 'medium' | 'high',
208
+ })
209
+ }
210
+ className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
211
+ >
212
+ <option value="low">Low</option>
213
+ <option value="medium">Medium</option>
214
+ <option value="high">High</option>
215
+ </select>
216
+ </div>
217
+
218
+ <div>
219
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
220
+ Description
221
+ </label>
222
+ <textarea
223
+ value={formData.description}
224
+ onChange={(e) => setFormData({ ...formData, description: e.target.value })}
225
+ placeholder="Additional details about the outbreak..."
226
+ rows={3}
227
+ className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
228
+ />
229
+ </div>
230
+
231
+ <button
232
+ onClick={handleSubmitReport}
233
+ className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors flex items-center justify-center gap-2 shadow-lg"
234
+ >
235
+ <Save className="w-5 h-5" />
236
+ Submit Report
237
+ </button>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ )}
242
+
243
+ {/* Reports List */}
244
+ <div className="bg-white rounded-xl shadow-lg p-6 border border-slate-200">
245
+ <h2 className="text-2xl font-bold text-slate-900 mb-4 flex items-center gap-2">
246
+ <MapPin className="w-6 h-6 text-blue-600" />
247
+ Reported Outbreaks ({reports.length})
248
+ </h2>
249
+
250
+ {reports.length === 0 ? (
251
+ <div className="text-center py-12 text-slate-500">
252
+ <MapPin className="w-12 h-12 mx-auto mb-4 text-slate-300" />
253
+ <p>No outbreaks reported yet. Click on the map to report one.</p>
254
+ </div>
255
+ ) : (
256
+ <div className="space-y-4">
257
+ {reports.map((report) => (
258
+ <motion.div
259
+ key={report.id}
260
+ initial={{ opacity: 0, y: 20 }}
261
+ animate={{ opacity: 1, y: 0 }}
262
+ className="border border-slate-200 rounded-lg p-4 hover:shadow-md transition-shadow"
263
+ >
264
+ <div className="flex items-start justify-between">
265
+ <div className="flex-1">
266
+ <div className="flex items-center gap-3 mb-2">
267
+ <h3 className="text-lg font-semibold text-slate-900">
268
+ {report.crop} - {report.disease}
269
+ </h3>
270
+ <span
271
+ className={`px-3 py-1 rounded-full text-xs font-semibold border ${getSeverityColor(
272
+ report.severity
273
+ )}`}
274
+ >
275
+ {report.severity.toUpperCase()}
276
+ </span>
277
+ </div>
278
+ <p className="text-sm text-slate-600 mb-2">
279
+ Location: {report.lat.toFixed(4)}, {report.lng.toFixed(4)}
280
+ </p>
281
+ {report.description && (
282
+ <p className="text-sm text-slate-700">{report.description}</p>
283
+ )}
284
+ <p className="text-xs text-slate-500 mt-2">
285
+ Reported: {new Date(report.date).toLocaleString()}
286
+ </p>
287
+ </div>
288
+ </div>
289
+ </motion.div>
290
+ ))}
291
+ </div>
292
+ )}
293
+ </div>
294
+ </div>
295
+ </div>
296
+ )
297
+ }
components/PredictionHistory.tsx ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+
5
+ interface PredictionRecord {
6
+ id: string
7
+ timestamp: string
8
+ crop: string
9
+ disease: string
10
+ confidence: number
11
+ imageUrl: string
12
+ }
13
+
14
+ interface PredictionHistoryProps {
15
+ onSelectHistory: (record: PredictionRecord) => void
16
+ }
17
+
18
+ export default function PredictionHistory({ onSelectHistory }: PredictionHistoryProps) {
19
+ const [history, setHistory] = useState<PredictionRecord[]>([])
20
+ const [isOpen, setIsOpen] = useState(false)
21
+
22
+ useEffect(() => {
23
+ // Load history from localStorage (only in browser)
24
+ if (typeof window === 'undefined') return
25
+
26
+ try {
27
+ const saved = localStorage.getItem('cropintel_history')
28
+ if (saved) {
29
+ const parsed = JSON.parse(saved)
30
+ // Validate it's an array
31
+ if (Array.isArray(parsed)) {
32
+ setHistory(parsed)
33
+ }
34
+ }
35
+ } catch (e) {
36
+ console.error('Failed to load history:', e)
37
+ // Clear corrupted data
38
+ localStorage.removeItem('cropintel_history')
39
+ }
40
+ }, [])
41
+
42
+ const clearHistory = () => {
43
+ if (typeof window === 'undefined') return
44
+ if (confirm('Are you sure you want to clear all prediction history?')) {
45
+ localStorage.removeItem('cropintel_history')
46
+ setHistory([])
47
+ }
48
+ }
49
+
50
+ if (history.length === 0) {
51
+ return (
52
+ <div className="bg-white rounded-2xl border-2 border-slate-200 p-6 shadow-xl">
53
+ <div className="flex items-center justify-between mb-4">
54
+ <h3 className="text-xl font-extrabold text-gray-900 flex items-center gap-2">
55
+ <svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
57
+ </svg>
58
+ Prediction History
59
+ </h3>
60
+ <button
61
+ onClick={() => setIsOpen(!isOpen)}
62
+ className="text-primary-600 hover:text-primary-700 font-semibold px-3 py-1 rounded-lg hover:bg-primary-50 transition-colors"
63
+ >
64
+ {isOpen ? 'Hide' : 'Show'}
65
+ </button>
66
+ </div>
67
+ {isOpen && (
68
+ <p className="text-gray-600 text-center py-4">No predictions yet. Your prediction history will appear here.</p>
69
+ )}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <div className="bg-white rounded-2xl border-2 border-slate-200 p-6 shadow-xl">
76
+ <div className="flex items-center justify-between mb-4">
77
+ <h3 className="text-xl font-extrabold text-gray-900 flex items-center gap-2">
78
+ <svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
79
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
80
+ </svg>
81
+ Prediction History ({history.length})
82
+ </h3>
83
+ <div className="flex gap-3">
84
+ <button
85
+ onClick={() => setIsOpen(!isOpen)}
86
+ className="text-primary-600 hover:text-primary-700 font-semibold px-4 py-2 rounded-lg hover:bg-primary-50 transition-colors"
87
+ >
88
+ {isOpen ? 'Hide' : 'Show'}
89
+ </button>
90
+ <button
91
+ onClick={clearHistory}
92
+ className="text-red-600 hover:text-red-700 font-semibold px-4 py-2 rounded-lg hover:bg-red-50 transition-colors"
93
+ >
94
+ Clear
95
+ </button>
96
+ </div>
97
+ </div>
98
+
99
+ {isOpen && (
100
+ <div className="space-y-3 max-h-96 overflow-y-auto">
101
+ {history.map((record) => (
102
+ <div
103
+ key={record.id}
104
+ onClick={() => onSelectHistory(record)}
105
+ className="p-5 bg-white/80 rounded-xl border-2 border-gray-200 hover:border-primary-400 hover:bg-gradient-to-r hover:from-primary-50 hover:to-blue-50 cursor-pointer transition-all duration-300 shadow-md hover:shadow-xl transform hover:scale-[1.02]"
106
+ >
107
+ <div className="flex items-center gap-4">
108
+ <div className="relative">
109
+ <img
110
+ src={record.imageUrl}
111
+ alt="History"
112
+ className="w-20 h-20 object-cover rounded-xl border-2 border-gray-300 shadow-md"
113
+ style={{ display: 'block' }}
114
+ onError={(e) => {
115
+ console.error('Image failed to load:', record.imageUrl)
116
+ }}
117
+ />
118
+ <div className="absolute -top-1 -right-1 w-6 h-6 bg-primary-600 rounded-full border-2 border-white flex items-center justify-center">
119
+ <span className="text-white text-xs font-bold">{Math.round(record.confidence)}</span>
120
+ </div>
121
+ </div>
122
+ <div className="flex-1">
123
+ <div className="flex items-center justify-between mb-2">
124
+ <span className="font-bold text-gray-900 text-lg">{record.disease}</span>
125
+ <span className="text-xs text-gray-500 font-medium">
126
+ {new Date(record.timestamp).toLocaleDateString()}
127
+ </span>
128
+ </div>
129
+ <div className="flex items-center gap-4">
130
+ <span className="text-sm font-semibold text-gray-700 capitalize bg-gray-100 px-3 py-1 rounded-lg">
131
+ {record.crop}
132
+ </span>
133
+ <span className="text-sm font-bold text-primary-600">
134
+ {record.confidence.toFixed(1)}% confidence
135
+ </span>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ )}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ export function savePredictionToHistory(
148
+ crop: string,
149
+ disease: string,
150
+ confidence: number,
151
+ imageUrl: string
152
+ ) {
153
+ // Only run in browser
154
+ if (typeof window === 'undefined') return
155
+
156
+ try {
157
+ const record: PredictionRecord = {
158
+ id: Date.now().toString(),
159
+ timestamp: new Date().toISOString(),
160
+ crop,
161
+ disease,
162
+ confidence,
163
+ imageUrl
164
+ }
165
+
166
+ const saved = localStorage.getItem('cropintel_history')
167
+ let history: PredictionRecord[] = []
168
+
169
+ if (saved) {
170
+ try {
171
+ const parsed = JSON.parse(saved)
172
+ // Validate it's an array
173
+ if (Array.isArray(parsed)) {
174
+ history = parsed
175
+ } else {
176
+ // If corrupted, start fresh
177
+ history = []
178
+ }
179
+ } catch (e) {
180
+ console.error('Failed to parse history:', e)
181
+ // Clear corrupted data and start fresh
182
+ localStorage.removeItem('cropintel_history')
183
+ history = []
184
+ }
185
+ }
186
+
187
+ // Add new record at the beginning
188
+ history.unshift(record)
189
+
190
+ // Keep only last 50 records
191
+ if (history.length > 50) {
192
+ history = history.slice(0, 50)
193
+ }
194
+
195
+ localStorage.setItem('cropintel_history', JSON.stringify(history))
196
+ } catch (e) {
197
+ console.error('Failed to save prediction to history:', e)
198
+ }
199
+ }
components/PredictionResults.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { AlertTriangle, CheckCircle2 } from 'lucide-react'
4
+
5
+ interface Prediction {
6
+ disease: string
7
+ confidence: number
8
+ is_healthy: boolean
9
+ meets_threshold: boolean
10
+ /** True when the model can't confidently match any disease in our catalog. */
11
+ not_in_catalog?: boolean
12
+ /** Farmer-facing explanation shown when not_in_catalog is true. */
13
+ catalog_message?: string
14
+ all_predictions: Array<{
15
+ disease: string
16
+ confidence: number
17
+ }>
18
+ }
19
+
20
+ interface PredictionResultsProps {
21
+ prediction: Prediction
22
+ /** Shown when regional disease filter was applied */
23
+ regionNote?: string
24
+ }
25
+
26
+ /** Model may send 0–1 or 0–100; UI always shows percent to one decimal. */
27
+ function toConfidencePercent(value: number): number {
28
+ if (value > 0 && value <= 1) return value * 100
29
+ return value
30
+ }
31
+
32
+ export default function PredictionResults({
33
+ prediction,
34
+ regionNote,
35
+ }: PredictionResultsProps) {
36
+ const getStatusColor = () => {
37
+ if (prediction.not_in_catalog) {
38
+ return 'bg-amber-50 text-amber-900 border-amber-200'
39
+ }
40
+ if (prediction.is_healthy) {
41
+ return 'bg-emerald-50 text-emerald-900 border-emerald-200'
42
+ }
43
+ if (prediction.meets_threshold) {
44
+ return 'bg-rose-50 text-rose-900 border-rose-200'
45
+ }
46
+ return 'bg-amber-50 text-amber-900 border-amber-200'
47
+ }
48
+
49
+ const getStatusText = () => {
50
+ if (prediction.not_in_catalog) {
51
+ return 'No match in catalog'
52
+ }
53
+ if (prediction.is_healthy) {
54
+ return 'Healthy'
55
+ }
56
+ if (prediction.meets_threshold) {
57
+ return 'Disease detected'
58
+ }
59
+ return 'Low confidence'
60
+ }
61
+
62
+ return (
63
+ <div className="mt-8 p-6 bg-white rounded-2xl border border-slate-200/80 shadow-sm">
64
+ <h2 className="text-lg font-semibold text-slate-900 mb-5 flex items-center gap-2">
65
+ <svg className="w-8 h-8 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
67
+ </svg>
68
+ Results
69
+ </h2>
70
+ {regionNote && (
71
+ <p className="text-xs text-slate-600 mb-4 -mt-2 px-1 py-2 rounded-lg bg-sky-50 border border-sky-100">
72
+ {regionNote}
73
+ </p>
74
+ )}
75
+
76
+ {/* Main Result */}
77
+ <div className="rounded-xl p-5 mb-6 border border-slate-200 bg-slate-50/60">
78
+ <div className="flex items-center justify-between mb-4">
79
+ <div>
80
+ <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
81
+ Top label
82
+ </h3>
83
+ <p className="text-2xl font-semibold text-slate-900 mt-1">
84
+ {prediction.disease}
85
+ </p>
86
+ </div>
87
+ <div className="text-right">
88
+ <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
89
+ Confidence
90
+ </h3>
91
+ <p className="text-2xl font-semibold text-primary-700 mt-1 tabular-nums">
92
+ {Math.min(100, Math.max(0, toConfidencePercent(prediction.confidence))).toFixed(1)}%
93
+ </p>
94
+ </div>
95
+ </div>
96
+
97
+ {/* Status Badge */}
98
+ <div
99
+ className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl border font-semibold text-sm ${getStatusColor()}`}
100
+ >
101
+ {prediction.is_healthy ? (
102
+ <CheckCircle2 className="w-4 h-4" />
103
+ ) : (
104
+ <AlertTriangle className="w-4 h-4" />
105
+ )}
106
+ {getStatusText()}
107
+ </div>
108
+ </div>
109
+
110
+ {/* Not-in-catalog notice: model couldn't confidently match any known disease */}
111
+ {prediction.not_in_catalog && (
112
+ <div className="rounded-xl p-4 mb-6 border border-amber-200 bg-amber-50 text-amber-900">
113
+ <div className="flex items-start gap-2">
114
+ <AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
115
+ <div>
116
+ <p className="font-semibold text-sm">This may not be a disease we detect</p>
117
+ <p className="text-sm mt-1 leading-snug">
118
+ {prediction.catalog_message ||
119
+ "The image doesn't clearly match any disease in our catalog for this crop. The labels below are the closest guesses, shown for reference only — treat with caution and consider an agricultural expert."}
120
+ </p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {/* All Predictions */}
127
+ <div>
128
+ <h3 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
129
+ <svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
130
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
131
+ </svg>
132
+ Other labels
133
+ </h3>
134
+ <div className="space-y-3">
135
+ {prediction.all_predictions.map((pred, index) => {
136
+ const pctRaw = toConfidencePercent(pred.confidence)
137
+ const pctClamped = Math.min(100, Math.max(0, pctRaw))
138
+ const pctOneDecimal = pctClamped.toFixed(1)
139
+ return (
140
+ <div
141
+ key={index}
142
+ className="bg-white rounded-xl border border-slate-200 p-4 transition-all duration-200 hover:border-primary-300 hover:bg-slate-50/50"
143
+ >
144
+ {/* Stacked on phones; row layout from md up */}
145
+ <div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
146
+ <p className="min-w-0 flex-1 text-left text-sm font-medium leading-snug text-slate-900 md:text-base">
147
+ {pred.disease}
148
+ </p>
149
+ <div className="flex w-full min-w-0 items-center gap-3 md:max-w-md md:flex-[1_1_40%]">
150
+ <div className="min-h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-200">
151
+ <div
152
+ className="h-2 max-w-full rounded-full bg-gradient-to-r from-primary-600 to-blue-600 transition-all duration-500"
153
+ style={{ width: `${pctClamped}%` }}
154
+ />
155
+ </div>
156
+ <span className="min-w-[4.5rem] shrink-0 text-right text-sm font-semibold tabular-nums text-slate-700 md:text-base">
157
+ {pctOneDecimal}%
158
+ </span>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ )
163
+ })}
164
+ </div>
165
+ </div>
166
+ </div>
167
+ )
168
+ }
components/StateSelector.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { MapPinned } from 'lucide-react'
4
+ import { US_STATES } from '@/lib/stateDiseaseMap'
5
+
6
+ interface StateSelectorProps {
7
+ selectedState: string
8
+ onStateChange: (code: string) => void
9
+ }
10
+
11
+ export default function StateSelector({ selectedState, onStateChange }: StateSelectorProps) {
12
+ return (
13
+ <div>
14
+ <label
15
+ htmlFor="state-select"
16
+ className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"
17
+ >
18
+ <MapPinned className="w-4 h-4 text-primary-700" />
19
+ State
20
+ </label>
21
+ <select
22
+ id="state-select"
23
+ value={selectedState}
24
+ onChange={(e) => onStateChange(e.target.value)}
25
+ className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-4 focus:ring-primary-200/60 focus:border-primary-400 text-base font-semibold bg-white shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer"
26
+ >
27
+ {US_STATES.map(({ code, name }) => (
28
+ <option key={code} value={code}>
29
+ {name} ({code})
30
+ </option>
31
+ ))}
32
+ </select>
33
+ <p className="text-xs text-slate-500 mt-1.5">
34
+ Regional filter applies when we have a disease list for this crop and state; otherwise all labels are shown.
35
+ </p>
36
+ </div>
37
+ )
38
+ }
components/TipsAndGuidelines.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Camera, ChevronDown, CheckCircle2, TriangleAlert } from 'lucide-react'
5
+
6
+ export default function TipsAndGuidelines() {
7
+ const [isOpen, setIsOpen] = useState(false)
8
+
9
+ return (
10
+ <div className="bg-white rounded-2xl border border-slate-200/80 p-4 sm:p-6 shadow-sm">
11
+ <button
12
+ onClick={() => setIsOpen(!isOpen)}
13
+ className="flex items-center justify-between w-full text-left group gap-4"
14
+ >
15
+ <h3 className="text-base font-semibold text-slate-900 flex items-center gap-2">
16
+ <span className="p-2 rounded-xl bg-primary-50 border border-primary-100">
17
+ <Camera className="w-4 h-4 text-primary-700" />
18
+ </span>
19
+ <span>Capture tips</span>
20
+ </h3>
21
+ <ChevronDown
22
+ className={`w-5 h-5 text-slate-600 transition-transform duration-200 group-hover:text-primary-700 ${isOpen ? 'rotate-180' : ''}`}
23
+ />
24
+ </button>
25
+
26
+ {isOpen && (
27
+ <div className="mt-5 space-y-4 text-slate-700">
28
+ <div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
29
+ <h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
30
+ <Camera className="w-4 h-4 text-primary-700" />
31
+ Image quality
32
+ </h4>
33
+ <ul className="space-y-2">
34
+ {['Use clear, well-lit photos', 'Ensure the leaf/disease area is in focus', 'Avoid blurry or dark images', 'Take photos in natural daylight when possible'].map((tip, i) => (
35
+ <li key={i} className="flex items-start gap-2">
36
+ <CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
37
+ <span>{tip}</span>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ </div>
42
+
43
+ <div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
44
+ <h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
45
+ <span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-white border border-slate-200 text-xs font-bold text-slate-700">
46
+ 2
47
+ </span>
48
+ What to capture
49
+ </h4>
50
+ <ul className="space-y-2">
51
+ {['Focus on the affected area of the plant', 'Include enough context (entire leaf or affected region)', 'Capture both sides of leaves if symptoms are visible', 'Avoid including too much background'].map((tip, i) => (
52
+ <li key={i} className="flex items-start gap-2">
53
+ <CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
54
+ <span>{tip}</span>
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ </div>
59
+
60
+ <div className="rounded-xl p-4 border border-slate-200 bg-slate-50/60">
61
+ <h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
62
+ <span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-white border border-slate-200 text-xs font-bold text-slate-700">
63
+ 3
64
+ </span>
65
+ Best practices
66
+ </h4>
67
+ <ul className="space-y-2">
68
+ {['Take multiple photos from different angles', 'Include healthy parts for comparison if possible', 'Note the crop type and growth stage', 'Check predictions match visual symptoms'].map((tip, i) => (
69
+ <li key={i} className="flex items-start gap-2">
70
+ <CheckCircle2 className="w-4 h-4 text-primary-700 mt-0.5 flex-shrink-0" />
71
+ <span>{tip}</span>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </div>
76
+
77
+ <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl">
78
+ <div className="flex items-start gap-3">
79
+ <TriangleAlert className="w-5 h-5 text-amber-700 flex-shrink-0 mt-0.5" />
80
+ <p className="text-sm text-amber-900">
81
+ <span className="font-semibold">Reminder:</span> Predictions support field decisions—confirm with local agronomists or extension services before treatment.
82
+ </p>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ )}
87
+ </div>
88
+ )
89
+ }
components/USMap.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect } from 'react'
4
+ import { MapPin } from 'lucide-react'
5
+
6
+ interface USMapProps {
7
+ onLocationClick: (lat: number, lng: number) => void
8
+ markers?: Array<{ lat: number; lng: number; label?: string }>
9
+ }
10
+
11
+ export default function USMap({ onLocationClick, markers = [] }: USMapProps) {
12
+ const mapRef = useRef<HTMLDivElement>(null)
13
+ const [isClient, setIsClient] = useState(false)
14
+
15
+ useEffect(() => {
16
+ setIsClient(true)
17
+ }, [])
18
+
19
+ const handleMapClick = (e: React.MouseEvent<HTMLDivElement>) => {
20
+ if (!mapRef.current) return
21
+
22
+ const rect = mapRef.current.getBoundingClientRect()
23
+ const x = e.clientX - rect.left
24
+ const y = e.clientY - rect.top
25
+
26
+ // US map bounds (approximate)
27
+ // Latitude: 24.396308 to 49.384358
28
+ // Longitude: -125.0 to -66.93457
29
+ const mapWidth = rect.width
30
+ const mapHeight = rect.height
31
+
32
+ // Convert pixel coordinates to lat/lng for US
33
+ const lng = -125 + (x / mapWidth) * 58 // -125 to -67
34
+ const lat = 49.38 - (y / mapHeight) * 25 // 49.38 to 24.39
35
+
36
+ // Clamp to US bounds
37
+ const clampedLat = Math.max(24.39, Math.min(49.38, lat))
38
+ const clampedLng = Math.max(-125, Math.min(-66.93, lng))
39
+
40
+ onLocationClick(clampedLat, clampedLng)
41
+ }
42
+
43
+ const projectPoint = (lat: number, lng: number, width: number, height: number) => {
44
+ const x = ((lng + 125) / 58) * width
45
+ const y = ((49.38 - lat) / 25) * height
46
+ return { x, y }
47
+ }
48
+
49
+ return (
50
+ <div className="w-full h-full relative bg-gradient-to-br from-blue-50 to-green-50 rounded-lg border-2 border-slate-300 overflow-hidden">
51
+ {/* US Map SVG */}
52
+ <div
53
+ ref={mapRef}
54
+ onClick={handleMapClick}
55
+ className="w-full h-full cursor-crosshair relative"
56
+ style={{ minHeight: '500px' }}
57
+ >
58
+ <svg
59
+ viewBox="0 0 1000 600"
60
+ className="w-full h-full"
61
+ preserveAspectRatio="xMidYMid meet"
62
+ >
63
+ {/* Simplified US Map Outline */}
64
+ <path
65
+ d="M 100 200 L 150 180 L 200 190 L 250 200 L 300 210 L 350 220 L 400 230 L 450 240 L 500 250 L 550 260 L 600 270 L 650 280 L 700 290 L 750 300 L 800 310 L 850 320 L 900 330 L 950 340 L 900 380 L 850 400 L 800 420 L 750 440 L 700 460 L 650 480 L 600 500 L 550 520 L 500 540 L 450 550 L 400 560 L 350 570 L 300 580 L 250 580 L 200 570 L 150 550 L 100 520 L 80 480 L 70 440 L 65 400 L 70 360 L 80 320 L 90 280 L 95 240 Z"
66
+ fill="#e0f2fe"
67
+ stroke="#0284c7"
68
+ strokeWidth="2"
69
+ />
70
+
71
+ {/* State boundaries (simplified) */}
72
+ <path
73
+ d="M 200 200 L 250 210 L 300 220 L 350 230 L 400 240 L 450 250 L 500 260 L 550 270 L 600 280 L 650 290 L 700 300 L 750 310 L 800 320 L 850 330 L 900 340 L 850 380 L 800 400 L 750 420 L 700 440 L 650 460 L 600 480 L 550 500 L 500 520 L 450 530 L 400 540 L 350 550 L 300 560 L 250 560 L 200 550 L 150 530 L 100 500 L 80 460 L 70 420 L 65 380 L 70 340 L 80 300 L 90 260 L 100 230 Z"
74
+ fill="none"
75
+ stroke="#94a3b8"
76
+ strokeWidth="1"
77
+ opacity="0.3"
78
+ />
79
+
80
+ {/* Markers */}
81
+ {isClient &&
82
+ markers.map((marker, i) => {
83
+ const { x, y } = projectPoint(
84
+ marker.lat,
85
+ marker.lng,
86
+ 1000,
87
+ 600
88
+ )
89
+ return (
90
+ <g key={i}>
91
+ <circle
92
+ cx={x}
93
+ cy={y}
94
+ r="6"
95
+ fill="#ef4444"
96
+ stroke="white"
97
+ strokeWidth="2"
98
+ />
99
+ <circle
100
+ cx={x}
101
+ cy={y}
102
+ r="6"
103
+ fill="#ef4444"
104
+ opacity="0.5"
105
+ >
106
+ <animate
107
+ attributeName="r"
108
+ from="6"
109
+ to="16"
110
+ dur="2s"
111
+ begin="0s"
112
+ repeatCount="indefinite"
113
+ />
114
+ <animate
115
+ attributeName="opacity"
116
+ from="0.5"
117
+ to="0"
118
+ dur="2s"
119
+ begin="0s"
120
+ repeatCount="indefinite"
121
+ />
122
+ </circle>
123
+ </g>
124
+ )
125
+ })}
126
+ </svg>
127
+
128
+ {/* Click instruction overlay */}
129
+ <div className="absolute bottom-4 left-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-10">
130
+ <p className="text-sm font-semibold flex items-center gap-2">
131
+ <MapPin className="w-4 h-4" />
132
+ Click on map to report outbreak
133
+ </p>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ )
138
+ }
components/USOutbreakMap.tsx ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import { MapPin, AlertTriangle, X, Save, Maximize2, Minimize2 } from 'lucide-react'
6
+ import type { OutbreakReport } from '@/lib/outbreakReport'
7
+
8
+ // Dynamically import Google Maps component to avoid SSR issues
9
+ const GoogleMapComponent = dynamic(() => import('./GoogleMap'), {
10
+ ssr: false,
11
+ loading: () => (
12
+ <div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-slate-300 bg-white sm:min-h-[400px]">
13
+ <div className="px-4 text-center">
14
+ <div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
15
+ <p className="text-sm font-semibold text-blue-600 sm:text-base">Loading Google Maps...</p>
16
+ </div>
17
+ </div>
18
+ ),
19
+ })
20
+
21
+ interface USOutbreakMapProps {
22
+ reports?: OutbreakReport[]
23
+ onReportSubmit?: (report: OutbreakReport) => void
24
+ /** Reporter status for new submissions from this browser */
25
+ reporterVerified: boolean
26
+ }
27
+
28
+ function triggerMapResize(map: google.maps.Map | null) {
29
+ if (!map || typeof google === 'undefined') return
30
+ window.setTimeout(() => {
31
+ google.maps.event.trigger(map, 'resize')
32
+ }, 120)
33
+ }
34
+
35
+ function getFullscreenElement(): Element | null {
36
+ const doc = document as Document & {
37
+ webkitFullscreenElement?: Element | null
38
+ }
39
+ return document.fullscreenElement ?? doc.webkitFullscreenElement ?? null
40
+ }
41
+
42
+ async function requestElementFullscreen(el: HTMLElement): Promise<boolean> {
43
+ const anyEl = el as HTMLElement & {
44
+ webkitRequestFullscreen?: () => void
45
+ }
46
+ try {
47
+ if (el.requestFullscreen) {
48
+ await el.requestFullscreen()
49
+ return true
50
+ }
51
+ } catch {
52
+ /* try webkit */
53
+ }
54
+ try {
55
+ if (anyEl.webkitRequestFullscreen) {
56
+ anyEl.webkitRequestFullscreen()
57
+ return true
58
+ }
59
+ } catch {
60
+ /* fall through */
61
+ }
62
+ return false
63
+ }
64
+
65
+ async function exitDocumentFullscreen(): Promise<void> {
66
+ const doc = document as Document & { webkitExitFullscreen?: () => void }
67
+ try {
68
+ if (document.fullscreenElement && document.exitFullscreen) {
69
+ await document.exitFullscreen()
70
+ return
71
+ }
72
+ } catch {
73
+ /* try webkit */
74
+ }
75
+ try {
76
+ if (doc.webkitExitFullscreen) {
77
+ doc.webkitExitFullscreen()
78
+ }
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ }
83
+
84
+ export default function USOutbreakMap({ reports = [], onReportSubmit, reporterVerified }: USOutbreakMapProps) {
85
+ const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
86
+ const [showReportForm, setShowReportForm] = useState(false)
87
+ const mapCardRef = useRef<HTMLDivElement>(null)
88
+ const mapInstanceRef = useRef<google.maps.Map | null>(null)
89
+ const [browserFullscreen, setBrowserFullscreen] = useState(false)
90
+ /** iOS / browsers without element fullscreen */
91
+ const [layoutFullscreen, setLayoutFullscreen] = useState(false)
92
+
93
+ const expanded = browserFullscreen || layoutFullscreen
94
+
95
+ const handleMapReady = useCallback((map: google.maps.Map) => {
96
+ mapInstanceRef.current = map
97
+ }, [])
98
+
99
+ useEffect(() => {
100
+ const syncFs = () => {
101
+ const fsEl = getFullscreenElement()
102
+ setBrowserFullscreen(fsEl === mapCardRef.current)
103
+ }
104
+ syncFs()
105
+ document.addEventListener('fullscreenchange', syncFs)
106
+ document.addEventListener('webkitfullscreenchange', syncFs)
107
+ return () => {
108
+ document.removeEventListener('fullscreenchange', syncFs)
109
+ document.removeEventListener('webkitfullscreenchange', syncFs)
110
+ }
111
+ }, [])
112
+
113
+ useEffect(() => {
114
+ triggerMapResize(mapInstanceRef.current)
115
+ }, [expanded])
116
+
117
+ useEffect(() => {
118
+ const onResize = () => triggerMapResize(mapInstanceRef.current)
119
+ window.addEventListener('orientationchange', onResize)
120
+ window.addEventListener('resize', onResize)
121
+ return () => {
122
+ window.removeEventListener('orientationchange', onResize)
123
+ window.removeEventListener('resize', onResize)
124
+ }
125
+ }, [])
126
+
127
+ const exitAllFullscreen = useCallback(async () => {
128
+ if (getFullscreenElement()) {
129
+ await exitDocumentFullscreen()
130
+ }
131
+ setLayoutFullscreen(false)
132
+ }, [])
133
+
134
+ const toggleFullscreen = useCallback(async () => {
135
+ const el = mapCardRef.current
136
+ if (!el) return
137
+
138
+ if (expanded) {
139
+ await exitAllFullscreen()
140
+ triggerMapResize(mapInstanceRef.current)
141
+ return
142
+ }
143
+
144
+ const enteredFs = await requestElementFullscreen(el)
145
+ if (enteredFs) {
146
+ triggerMapResize(mapInstanceRef.current)
147
+ return
148
+ }
149
+
150
+ setLayoutFullscreen(true)
151
+ triggerMapResize(mapInstanceRef.current)
152
+ }, [expanded, exitAllFullscreen])
153
+
154
+ useEffect(() => {
155
+ if (!layoutFullscreen && !browserFullscreen) return
156
+ const prev = document.body.style.overflow
157
+ document.body.style.overflow = 'hidden'
158
+ return () => {
159
+ document.body.style.overflow = prev
160
+ }
161
+ }, [layoutFullscreen, browserFullscreen])
162
+
163
+ useEffect(() => {
164
+ if (!expanded) return
165
+ const onKey = (e: KeyboardEvent) => {
166
+ if (e.key === 'Escape') void exitAllFullscreen()
167
+ }
168
+ window.addEventListener('keydown', onKey)
169
+ return () => window.removeEventListener('keydown', onKey)
170
+ }, [expanded, exitAllFullscreen])
171
+
172
+ useEffect(() => {
173
+ return () => {
174
+ document.body.style.removeProperty('overflow')
175
+ void exitDocumentFullscreen()
176
+ }
177
+ }, [])
178
+
179
+ // Ensure modal closes on mount/unmount to prevent stuck overlays
180
+ useEffect(() => {
181
+ return () => {
182
+ setShowReportForm(false)
183
+ setSelectedLocation(null)
184
+ }
185
+ }, [])
186
+
187
+ const [formData, setFormData] = useState({
188
+ crop: '',
189
+ disease: '',
190
+ severity: 'medium' as 'low' | 'medium' | 'high',
191
+ description: '',
192
+ })
193
+
194
+ const handleMapClick = (lat: number, lng: number) => {
195
+ // Restrict to US bounds
196
+ const clampedLat = Math.max(24.39, Math.min(49.38, lat))
197
+ const clampedLng = Math.max(-125, Math.min(-66.93, lng))
198
+
199
+ setSelectedLocation({ lat: clampedLat, lng: clampedLng })
200
+ setShowReportForm(true)
201
+ }
202
+
203
+ const handleSubmitReport = () => {
204
+ if (!selectedLocation || !formData.crop || !formData.disease) {
205
+ alert('Please fill in all required fields')
206
+ return
207
+ }
208
+
209
+ const newReport: OutbreakReport = {
210
+ id: Date.now().toString(),
211
+ lat: selectedLocation.lat,
212
+ lng: selectedLocation.lng,
213
+ crop: formData.crop,
214
+ disease: formData.disease,
215
+ severity: formData.severity,
216
+ date: new Date().toISOString(),
217
+ description: formData.description,
218
+ reporterVerified,
219
+ }
220
+
221
+ if (onReportSubmit) {
222
+ onReportSubmit(newReport)
223
+ }
224
+
225
+ setShowReportForm(false)
226
+ setSelectedLocation(null)
227
+ setFormData({
228
+ crop: '',
229
+ disease: '',
230
+ severity: 'medium',
231
+ description: '',
232
+ })
233
+ }
234
+
235
+ return (
236
+ <div className="relative w-full">
237
+ {/* Map card: explicit height so the map fills the area (no aspect-ratio gap) */}
238
+ <div
239
+ ref={mapCardRef}
240
+ className={`flex w-full flex-col overflow-hidden rounded-xl border-2 border-slate-300 bg-white shadow-lg ${
241
+ layoutFullscreen
242
+ ? 'fixed inset-0 z-[5000] h-[100dvh] max-h-[100dvh] min-h-0 w-full max-w-none rounded-none border-slate-300 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]'
243
+ : ''
244
+ } ${expanded ? 'h-full min-h-0' : ''}`}
245
+ >
246
+ <div
247
+ className={`relative flex w-full min-h-0 flex-col ${
248
+ expanded ? 'h-full min-h-0 flex-1' : ''
249
+ }`}
250
+ >
251
+ <div
252
+ className={`relative min-h-0 w-full overflow-hidden ${
253
+ expanded
254
+ ? 'min-h-0 flex-1'
255
+ : 'h-[min(52dvh,560px)] min-h-[280px] sm:min-h-[420px]'
256
+ }`}
257
+ >
258
+ <GoogleMapComponent
259
+ reports={reports}
260
+ onMapClick={handleMapClick}
261
+ center={{ lat: 39.8283, lng: -98.5795 }}
262
+ zoom={4}
263
+ showMapClickHint={false}
264
+ fullscreenControl
265
+ onMapReady={handleMapReady}
266
+ />
267
+
268
+ <div className="pointer-events-auto absolute right-2 top-2 z-[1001] flex gap-1 sm:right-3 sm:top-3">
269
+ <button
270
+ type="button"
271
+ onClick={() => void toggleFullscreen()}
272
+ className="touch-manipulation rounded-lg border border-slate-200 bg-white/95 px-2.5 py-2 text-slate-800 shadow-md backdrop-blur-sm transition hover:bg-white sm:px-3"
273
+ aria-label={expanded ? 'Exit fullscreen map' : 'Fullscreen map'}
274
+ title={expanded ? 'Exit fullscreen' : 'Fullscreen'}
275
+ >
276
+ {expanded ? (
277
+ <Minimize2 className="h-5 w-5 sm:h-5 sm:w-5" />
278
+ ) : (
279
+ <Maximize2 className="h-5 w-5 sm:h-5 sm:w-5" />
280
+ )}
281
+ </button>
282
+ </div>
283
+
284
+ {selectedLocation && !showReportForm && (
285
+ <div className="pointer-events-none absolute left-2 top-12 z-[1000] max-w-[calc(100%-4rem)] rounded-lg border-2 border-blue-200 bg-white p-2 shadow-xl sm:left-3 sm:top-14 sm:p-3">
286
+ <p className="mb-0.5 text-[11px] font-bold text-slate-900 sm:text-xs">📍 Selected</p>
287
+ <p className="break-all font-mono text-[10px] text-slate-600 sm:text-xs">
288
+ {selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
289
+ </p>
290
+ </div>
291
+ )}
292
+ </div>
293
+
294
+ <div className="flex shrink-0 items-center gap-2 border-t border-slate-200 bg-slate-50 px-3 py-2.5 sm:px-4 sm:py-3">
295
+ <MapPin className="h-4 w-4 shrink-0 text-blue-700" />
296
+ <p className="text-xs font-semibold leading-snug text-slate-800 sm:text-sm">
297
+ Tap or click the map to report an outbreak
298
+ </p>
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ {/* Report Form Modal — no AnimatePresence exit on full-screen layer (opacity-0 still captures clicks). */}
304
+ {showReportForm && (
305
+ <div className="fixed inset-0 z-[6000] flex items-end justify-center sm:items-center sm:p-4">
306
+ <div
307
+ role="presentation"
308
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
309
+ onClick={() => {
310
+ setShowReportForm(false)
311
+ setSelectedLocation(null)
312
+ }}
313
+ />
314
+ <div
315
+ onClick={(e) => e.stopPropagation()}
316
+ className="relative z-10 max-h-[90dvh] w-full max-w-md overflow-y-auto overscroll-contain rounded-t-2xl border border-slate-200 bg-white p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-2xl sm:rounded-2xl sm:p-6 sm:pb-6"
317
+ >
318
+ <div className="flex items-center justify-between mb-6">
319
+ <h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
320
+ <div className="p-2 bg-orange-100 rounded-lg">
321
+ <AlertTriangle className="w-6 h-6 text-orange-600" />
322
+ </div>
323
+ Report Outbreak
324
+ </h2>
325
+ <button
326
+ onClick={() => {
327
+ setShowReportForm(false)
328
+ setSelectedLocation(null)
329
+ }}
330
+ className="text-slate-400 hover:text-slate-600 transition-colors p-1 hover:bg-slate-100 rounded-lg"
331
+ >
332
+ <X className="w-5 h-5" />
333
+ </button>
334
+ </div>
335
+
336
+ {selectedLocation && (
337
+ <div className="mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
338
+ <p className="text-xs text-blue-700 font-semibold mb-1">Location</p>
339
+ <p className="text-sm text-blue-900 font-mono">
340
+ {selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
341
+ </p>
342
+ </div>
343
+ )}
344
+
345
+ <div className="space-y-4">
346
+ <div>
347
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
348
+ Crop Type <span className="text-red-500">*</span>
349
+ </label>
350
+ <input
351
+ type="text"
352
+ value={formData.crop}
353
+ onChange={(e) => setFormData({ ...formData, crop: e.target.value })}
354
+ placeholder="e.g., Corn, Wheat, Rice"
355
+ className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
356
+ />
357
+ </div>
358
+
359
+ <div>
360
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
361
+ Disease Name <span className="text-red-500">*</span>
362
+ </label>
363
+ <input
364
+ type="text"
365
+ value={formData.disease}
366
+ onChange={(e) => setFormData({ ...formData, disease: e.target.value })}
367
+ placeholder="e.g., Rust, Blight, Mosaic"
368
+ className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
369
+ />
370
+ </div>
371
+
372
+ <div>
373
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
374
+ Severity <span className="text-red-500">*</span>
375
+ </label>
376
+ <select
377
+ value={formData.severity}
378
+ onChange={(e) =>
379
+ setFormData({
380
+ ...formData,
381
+ severity: e.target.value as 'low' | 'medium' | 'high',
382
+ })
383
+ }
384
+ className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all bg-white"
385
+ >
386
+ <option value="low">🟡 Low</option>
387
+ <option value="medium">🟠 Medium</option>
388
+ <option value="high">🔴 High</option>
389
+ </select>
390
+ </div>
391
+
392
+ <div>
393
+ <label className="block text-sm font-semibold text-slate-700 mb-2">
394
+ Description
395
+ </label>
396
+ <textarea
397
+ value={formData.description}
398
+ onChange={(e) => setFormData({ ...formData, description: e.target.value })}
399
+ placeholder="Additional details about the outbreak..."
400
+ rows={3}
401
+ className="w-full px-4 py-2.5 border-2 border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all resize-none"
402
+ />
403
+ </div>
404
+
405
+ <button
406
+ onClick={handleSubmitReport}
407
+ className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold py-3 px-6 rounded-lg transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-xl transform hover:scale-[1.02]"
408
+ >
409
+ <Save className="w-5 h-5" />
410
+ Submit Report
411
+ </button>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ )}
416
+ </div>
417
+ )
418
+ }
components/ui/theme-provider.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
5
+
6
+ export function ThemeProvider({
7
+ children,
8
+ ...props
9
+ }: React.ComponentProps<typeof NextThemesProvider>) {
10
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>
11
+ }
components/ui/world-map.tsx ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import { motion } from "framer-motion";
5
+ import DottedMap from "dotted-map";
6
+ import Image from "next/image";
7
+ import { useTheme } from "next-themes";
8
+
9
+ interface MapProps {
10
+ dots?: Array<{
11
+ start: { lat: number; lng: number; label?: string };
12
+ end: { lat: number; lng: number; label?: string };
13
+ }>;
14
+ lineColor?: string;
15
+ }
16
+
17
+ export function WorldMap({
18
+ dots = [],
19
+ lineColor = "#0ea5e9",
20
+ }: MapProps) {
21
+ const svgRef = useRef<SVGSVGElement>(null);
22
+ const map = new DottedMap({ height: 100, grid: "diagonal" });
23
+
24
+ const { theme } = useTheme();
25
+
26
+ const svgMap = map.getSVG({
27
+ radius: 0.22,
28
+ color: theme === "dark" ? "#FFFFFF40" : "#00000040",
29
+ shape: "circle",
30
+ backgroundColor: theme === "dark" ? "black" : "white",
31
+ });
32
+
33
+ const projectPoint = (lat: number, lng: number) => {
34
+ const x = (lng + 180) * (800 / 360);
35
+ const y = (90 - lat) * (400 / 180);
36
+ return { x, y };
37
+ };
38
+
39
+ const createCurvedPath = (
40
+ start: { x: number; y: number },
41
+ end: { x: number; y: number }
42
+ ) => {
43
+ const midX = (start.x + end.x) / 2;
44
+ const midY = Math.min(start.y, end.y) - 50;
45
+ return `M ${start.x} ${start.y} Q ${midX} ${midY} ${end.x} ${end.y}`;
46
+ };
47
+
48
+ return (
49
+ <div className="w-full aspect-[2/1] dark:bg-black bg-white rounded-lg relative font-sans">
50
+ <Image
51
+ src={`data:image/svg+xml;utf8,${encodeURIComponent(svgMap)}`}
52
+ className="h-full w-full [mask-image:linear-gradient(to_bottom,transparent,white_10%,white_90%,transparent)] pointer-events-none select-none"
53
+ alt="world map"
54
+ height="495"
55
+ width="1056"
56
+ draggable={false}
57
+ />
58
+ <svg
59
+ ref={svgRef}
60
+ viewBox="0 0 800 400"
61
+ className="w-full h-full absolute inset-0 pointer-events-none select-none"
62
+ >
63
+ {dots.map((dot, i) => {
64
+ const startPoint = projectPoint(dot.start.lat, dot.start.lng);
65
+ const endPoint = projectPoint(dot.end.lat, dot.end.lng);
66
+ return (
67
+ <g key={`path-group-${i}`}>
68
+ <motion.path
69
+ d={createCurvedPath(startPoint, endPoint)}
70
+ fill="none"
71
+ stroke="url(#path-gradient)"
72
+ strokeWidth="1"
73
+ initial={{
74
+ pathLength: 0,
75
+ }}
76
+ animate={{
77
+ pathLength: 1,
78
+ }}
79
+ transition={{
80
+ duration: 1,
81
+ delay: 0.5 * i,
82
+ ease: "easeOut",
83
+ }}
84
+ key={`start-upper-${i}`}
85
+ ></motion.path>
86
+ </g>
87
+ );
88
+ })}
89
+
90
+ <defs>
91
+ <linearGradient id="path-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
92
+ <stop offset="0%" stopColor="white" stopOpacity="0" />
93
+ <stop offset="5%" stopColor={lineColor} stopOpacity="1" />
94
+ <stop offset="95%" stopColor={lineColor} stopOpacity="1" />
95
+ <stop offset="100%" stopColor="white" stopOpacity="0" />
96
+ </linearGradient>
97
+ </defs>
98
+
99
+ {dots.map((dot, i) => (
100
+ <g key={`points-group-${i}`}>
101
+ <g key={`start-${i}`}>
102
+ <circle
103
+ cx={projectPoint(dot.start.lat, dot.start.lng).x}
104
+ cy={projectPoint(dot.start.lat, dot.start.lng).y}
105
+ r="2"
106
+ fill={lineColor}
107
+ />
108
+ <circle
109
+ cx={projectPoint(dot.start.lat, dot.start.lng).x}
110
+ cy={projectPoint(dot.start.lat, dot.start.lng).y}
111
+ r="2"
112
+ fill={lineColor}
113
+ opacity="0.5"
114
+ >
115
+ <animate
116
+ attributeName="r"
117
+ from="2"
118
+ to="8"
119
+ dur="1.5s"
120
+ begin="0s"
121
+ repeatCount="indefinite"
122
+ />
123
+ <animate
124
+ attributeName="opacity"
125
+ from="0.5"
126
+ to="0"
127
+ dur="1.5s"
128
+ begin="0s"
129
+ repeatCount="indefinite"
130
+ />
131
+ </circle>
132
+ </g>
133
+ <g key={`end-${i}`}>
134
+ <circle
135
+ cx={projectPoint(dot.end.lat, dot.end.lng).x}
136
+ cy={projectPoint(dot.end.lat, dot.end.lng).y}
137
+ r="2"
138
+ fill={lineColor}
139
+ />
140
+ <circle
141
+ cx={projectPoint(dot.end.lat, dot.end.lng).x}
142
+ cy={projectPoint(dot.end.lat, dot.end.lng).y}
143
+ r="2"
144
+ fill={lineColor}
145
+ opacity="0.5"
146
+ >
147
+ <animate
148
+ attributeName="r"
149
+ from="2"
150
+ to="8"
151
+ dur="1.5s"
152
+ begin="0s"
153
+ repeatCount="indefinite"
154
+ />
155
+ <animate
156
+ attributeName="opacity"
157
+ from="0.5"
158
+ to="0"
159
+ dur="1.5s"
160
+ begin="0s"
161
+ repeatCount="indefinite"
162
+ />
163
+ </circle>
164
+ </g>
165
+ </g>
166
+ ))}
167
+ </svg>
168
+ </div>
169
+ );
170
+ }
cropintel_model_metrics_overall.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ crop,version,accuracy,precision,recall,f1_score
2
+ corn,v1_20260118_144945,0.9476190476190476,0.9472791410761335,0.9476190476190476,0.9473238418683883
3
+ rice,v1_20260118_161225,0.9922480620155039,0.9923664191106051,0.9922480620155039,0.9922153632970051
4
+ soybean,v1_20260118_225345,0.9661016949152542,0.9693502824858756,0.9661016949152542,0.9613091759205145
5
+ wheat,v1_20260118_234239,0.9293893129770993,0.9321654830534337,0.9293893129770993,0.9291718787641892
cropintel_model_metrics_overall.png ADDED
cropintel_model_metrics_per_class.csv ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ crop,version,class_name,support,precision,recall,f1_score
2
+ corn,v1_20260118_144945,Blight,115,0.9145299145299145,0.9304347826086956,0.9224137931034483
3
+ corn,v1_20260118_144945,Common Rust,131,0.9624060150375939,0.9770992366412213,0.9696969696969697
4
+ corn,v1_20260118_144945,Gray Leaf Spot,57,0.8703703703703703,0.8245614035087719,0.8468468468468469
5
+ corn,v1_20260118_144945,Healthy,117,1.0,0.9914529914529915,0.9957081545064378
6
+ rice,v1_20260118_161225,Bacterial Leaf Blight,64,0.9846153846153847,1.0,0.9922480620155039
7
+ rice,v1_20260118_161225,Brown Spot,65,0.9848484848484849,1.0,0.9923664122137404
8
+ rice,v1_20260118_161225,Healthy,66,1.0,1.0,1.0
9
+ rice,v1_20260118_161225,Rice Blast,63,1.0,0.9682539682539683,0.9838709677419355
10
+ soybean,v1_20260118_225345,D Mossaic Virus,2,1.0,0.5,0.6666666666666666
11
+ soybean,v1_20260118_225345,D septoria,2,1.0,0.5,0.6666666666666666
12
+ soybean,v1_20260118_225345,Southern blight,7,0.875,1.0,0.9333333333333333
13
+ soybean,v1_20260118_225345,Sudden Death Syndrone,11,1.0,1.0,1.0
14
+ soybean,v1_20260118_225345,Yellow Mosaic,11,1.0,1.0,1.0
15
+ soybean,v1_20260118_225345,bacterial_blight,5,1.0,1.0,1.0
16
+ soybean,v1_20260118_225345,ferrugen,7,1.0,1.0,1.0
17
+ soybean,v1_20260118_225345,powdery_mildew,14,0.9333333333333333,1.0,0.9655172413793104
18
+ wheat,v1_20260118_234239,Healthy,100,0.9433962264150944,1.0,0.970873786407767
19
+ wheat,v1_20260118_234239,Leaf Rust,127,0.9272727272727272,0.8031496062992126,0.8607594936708861
20
+ wheat,v1_20260118_234239,Powdery Mildew,108,0.981651376146789,0.9907407407407407,0.9861751152073732
21
+ wheat,v1_20260118_234239,Stem Rust,58,0.7121212121212122,0.8103448275862069,0.7580645161290323
22
+ wheat,v1_20260118_234239,Stripe (Yellow) Rust,131,0.9849624060150376,1.0,0.9924242424242424
docker-compose.ml.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel ML stack: reproducible Python + TensorFlow without touching host Python.
2
+ # Data and trained weights live in mounted volumes (still gitignored on the host).
3
+ #
4
+ # Quick smoke test (no Kaggle):
5
+ # docker compose -f docker-compose.ml.yml build
6
+ # docker compose -f docker-compose.ml.yml run --rm ml \
7
+ # python -m ml.scripts.create_synthetic_dataset --crop corn --force
8
+ # docker compose -f docker-compose.ml.yml run --rm ml \
9
+ # python -m ml.training.train_crop --crop corn --epochs 2 --no-fine-tune
10
+ #
11
+ # With Kaggle (mount token; dataset terms must be accepted on the website):
12
+ # docker compose -f docker-compose.ml.yml run --rm -v "$HOME/.kaggle:/root/.kaggle:ro" ml \
13
+ # python -m ml.scripts.download_datasets
14
+
15
+ services:
16
+ ml:
17
+ build:
18
+ context: .
19
+ dockerfile: Dockerfile.ml
20
+ image: cropintel-ml:local
21
+ working_dir: /app
22
+ environment:
23
+ PYTHONPATH: /app
24
+ volumes:
25
+ - ./ml/data:/app/ml/data
26
+ - ./ml/models:/app/ml/models
27
+ # Optional Kaggle token (only if downloading inside Docker):
28
+ # docker compose -f docker-compose.ml.yml run --rm -v ~/.kaggle:/root/.kaggle:ro ml ...
docker-compose.prod.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel production stack (single VPS).
2
+ #
3
+ # Differences from docker-compose.yml (dev):
4
+ # - no repo bind-mount: the image is the artifact (next build baked in)
5
+ # - supervisord runs both the Next.js server and the Python inference service
6
+ # - restart policy, healthcheck, and log rotation for unattended operation
7
+ #
8
+ # Usage:
9
+ # export CROPINTEL_MODELS_URL='https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip'
10
+ # docker compose -f docker-compose.prod.yml up -d --build
11
+ #
12
+ # See docs/DEPLOYMENT.md for the full runbook (reverse proxy, backups, promotion).
13
+
14
+ services:
15
+ app:
16
+ build:
17
+ context: .
18
+ args:
19
+ # NEXT_PUBLIC_* is inlined at build time, so the outbreak map needs the
20
+ # key baked into the image — not just present in `environment` at runtime.
21
+ - NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
22
+ ports:
23
+ - "3050:3050"
24
+ environment:
25
+ - CROPINTEL_MODELS_URL=${CROPINTEL_MODELS_URL:-https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip}
26
+ - NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
27
+ - CROPINTEL_ADMIN_TOKEN=${CROPINTEL_ADMIN_TOKEN:-}
28
+ volumes:
29
+ - ./ml/models:/app/ml/models
30
+ - ./data:/app/data
31
+ restart: unless-stopped
32
+ healthcheck:
33
+ test: ["CMD", "curl", "-fsS", "http://localhost:3050/api/health"]
34
+ interval: 30s
35
+ timeout: 10s
36
+ retries: 3
37
+ start_period: 90s
38
+ logging:
39
+ driver: json-file
40
+ options:
41
+ max-size: "20m"
42
+ max-file: "5"
docker-compose.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel local stack — no Kaggle if you set CROPINTEL_MODELS_URL to a .zip of ml/models/
2
+ #
3
+ # Usage:
4
+ # export CROPINTEL_MODELS_URL='https://github.com/ORG/REPO/releases/download/v1/cropintel-models.zip'
5
+ # docker compose up --build
6
+ #
7
+ # Or bind-mount models you already have on the host:
8
+ # docker compose run --rm -v ./ml/models:/app/ml/models app npm run dev
9
+
10
+ services:
11
+ app:
12
+ build: .
13
+ ports:
14
+ - "3050:3050"
15
+ environment:
16
+ - CROPINTEL_MODELS_URL=${CROPINTEL_MODELS_URL:-}
17
+ - NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}
18
+ volumes:
19
+ - ./ml/models:/app/ml/models
20
+ - ./:/app
21
+ - /app/node_modules
22
+ - /app/.next
docker/entrypoint.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+ cd /app
4
+
5
+ if [ -n "${CROPINTEL_MODELS_URL}" ]; then
6
+ if [ ! -f ml/models/.cropintel-fetch-ok ]; then
7
+ echo "Fetching pre-built models from CROPINTEL_MODELS_URL…"
8
+ python3 -m ml.scripts.fetch_models --url "${CROPINTEL_MODELS_URL}"
9
+ touch ml/models/.cropintel-fetch-ok
10
+ else
11
+ echo "Models already present (ml/models/.cropintel-fetch-ok); skip fetch."
12
+ fi
13
+ else
14
+ echo "Note: CROPINTEL_MODELS_URL not set. Place models under ml/models/ or predictions will fail."
15
+ fi
16
+
17
+ exec "$@"
docker/supervisord.conf ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [supervisord]
2
+ nodaemon=true
3
+ logfile=/dev/null
4
+ logfile_maxbytes=0
5
+ pidfile=/tmp/supervisord.pid
6
+
7
+ [program:inference]
8
+ command=python3 -m uvicorn ml.serve.inference_app:app --host %(ENV_INFERENCE_BIND_HOST)s --port 8000
9
+ directory=/app
10
+ autorestart=true
11
+ startretries=10
12
+ stopsignal=TERM
13
+ stdout_logfile=/dev/stdout
14
+ stdout_logfile_maxbytes=0
15
+ stderr_logfile=/dev/stderr
16
+ stderr_logfile_maxbytes=0
17
+ environment=CROPINTEL_BACKEND="tflite",CROPINTEL_PREDICTION_LOG="/app/data/predictions.jsonl"
18
+
19
+ [program:web]
20
+ command=node node_modules/.bin/next start -H 0.0.0.0 -p 3050
21
+ directory=/app
22
+ autorestart=true
23
+ startretries=10
24
+ stopsignal=TERM
25
+ stdout_logfile=/dev/stdout
26
+ stdout_logfile_maxbytes=0
27
+ stderr_logfile=/dev/stderr
28
+ stderr_logfile_maxbytes=0
29
+ environment=NODE_ENV="production",INFERENCE_URL="http://127.0.0.1:8000"
docs/CURSOR_AND_COLLABORATORS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Notes for Cursor / new contributors
2
+
3
+ - **Kaggle is optional.** Cloning the repo does not include `ml/models/` (gitignored). Without models, `/api/predict` fails.
4
+ - **Fix:** Set `CROPINTEL_MODELS_URL` to a direct HTTPS link to a `.zip` built from `ml/models/` (see `python3 -m ml.scripts.package_models`), then run `python3 -m ml.scripts.fetch_models`, or use **Docker Compose** with that env var (see root `README.md`).
5
+ - **Training / accuracy:** Reproducing paper-style metrics requires downloading Kaggle datasets per `ml/scripts/download_datasets.py` and training locally; that path is separate from “run the app.”
docs/DEPLOYMENT.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CropIntel Production Deployment (single VPS)
2
+
3
+ One Docker container runs both the Next.js web app and the Python inference
4
+ service (supervisord manages the two processes). Models are fetched once at
5
+ container start from a release zip. Right-sized for a single server — no
6
+ Kubernetes, no Redis, no external model registry.
7
+
8
+ ## Architecture
9
+
10
+ ```
11
+ internet ── Caddy (TLS, :443) ── Next.js (:3050, public)
12
+ │ POST /api/predict ──► FastAPI inference
13
+ │ GET /api/health ──► service (127.0.0.1:8000,
14
+ │ never exposed)
15
+ └─ models: ml/models/<crop>/<version>/model.tflite
16
+ audit log: data/predictions.jsonl
17
+ ```
18
+
19
+ - `app/api/predict/route.ts` validates + rate-limits, then forwards the upload
20
+ to the inference service (`ml/serve/inference_app.py`), which keeps all crop
21
+ models loaded in memory (TFLite, ~9 MB per crop).
22
+ - `GET /api/health` aggregates web liveness + per-crop model readiness — point
23
+ the compose healthcheck and your uptime monitor at it.
24
+
25
+ ## Prerequisites
26
+
27
+ - VPS with 2 vCPU / 4 GB RAM (TFLite backend; Keras would need ~4× more)
28
+ - Docker + compose plugin
29
+ - A domain pointed at the VPS (for TLS)
30
+
31
+ ## First deploy
32
+
33
+ ```bash
34
+ git clone <repo> /opt/cropintel && cd /opt/cropintel
35
+
36
+ # .env — models bundle + optional secrets
37
+ cat > .env <<'EOF'
38
+ CROPINTEL_MODELS_URL=https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip
39
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
40
+ CROPINTEL_ADMIN_TOKEN=<random string> # protects POST /admin/reload
41
+ EOF
42
+
43
+ docker compose -f docker-compose.prod.yml up -d --build
44
+ curl -fsS http://localhost:3050/api/health # expect {"web":"ok","inference":{"ready":true,...}}
45
+ ```
46
+
47
+ The models zip is produced by:
48
+ ```bash
49
+ python -m ml.scripts.package_models --tflite-only -o cropintel-models-mobile.zip
50
+ ```
51
+ and uploaded to a GitHub Release (or any direct-download URL).
52
+
53
+ ### Reverse proxy (TLS)
54
+
55
+ Caddy on the host is the simplest option:
56
+
57
+ ```
58
+ # /etc/caddy/Caddyfile
59
+ yourdomain.example {
60
+ reverse_proxy 127.0.0.1:3050
61
+ }
62
+ ```
63
+
64
+ Caddy sets `X-Forwarded-For` automatically. The in-memory rate limiter keys on
65
+ the client IP — behind any proxy that does NOT set `X-Forwarded-For`, all
66
+ clients share one bucket. Verify your proxy sets it.
67
+
68
+ ## Updating
69
+
70
+ | What changed | Do |
71
+ |---|---|
72
+ | Code | `git pull && docker compose -f docker-compose.prod.yml up -d --build` |
73
+ | Models (new bundle) | update `CROPINTEL_MODELS_URL`, then `rm ml/models/.cropintel-fetch-ok && docker compose -f docker-compose.prod.yml restart` |
74
+ | Models (promote a version already on disk) | see below |
75
+
76
+ **Gotcha:** `ml/models/.cropintel-fetch-ok` is a sentinel that suppresses
77
+ re-downloading the models bundle on every container start. A new bundle URL is
78
+ silently ignored until you delete this file.
79
+
80
+ ## Model promotion / rollback
81
+
82
+ Versions live in `ml/models/<crop>/v1_YYYYMMDD_HHMMSS/`. The serving version is
83
+ pinned by `ml/models/<crop>/production.json`; without it, the latest complete
84
+ version serves (legacy behavior).
85
+
86
+ ```bash
87
+ # status of every crop (serving version, test + external accuracy)
88
+ python -m ml.scripts.promote_model --status
89
+
90
+ # promote (gated on metrics.json accuracy + a passing external_eval.json)
91
+ python -m ml.scripts.promote_model --crop rice --version v1_20260612_103000
92
+
93
+ # instant rollback to the previous pointer
94
+ python -m ml.scripts.promote_model --crop rice --rollback
95
+
96
+ # apply without restarting the container
97
+ curl -X POST -H "X-Admin-Token: $CROPINTEL_ADMIN_TOKEN" localhost:8000/admin/reload
98
+ ```
99
+
100
+ The promotion gate requires an external evaluation (out-of-training-distribution
101
+ images), produced with:
102
+
103
+ ```bash
104
+ python -m ml.scripts.test_external --crop rice --path ml/field_test/rice --save-json
105
+ ```
106
+
107
+ Never promote on in-dataset test accuracy alone — the rice and soybean models
108
+ both scored 100% in-dataset while failing badly on external images (shortcut
109
+ learning). The honest number is external accuracy.
110
+
111
+ ## Monitoring & logs
112
+
113
+ - **Uptime**: point an external pinger (UptimeRobot / healthchecks.io free tier)
114
+ at `https://yourdomain.example/api/health` every minute. An on-box monitor
115
+ cannot alert you when the box itself dies.
116
+ - **Process restarts**: `restart: unless-stopped` + supervisord auto-restart
117
+ handle crashes; the compose healthcheck flags a wedged container.
118
+ - **Process logs**: `docker compose -f docker-compose.prod.yml logs -f`
119
+ (json-file driver rotates at 20 MB × 5 files).
120
+ - **Prediction audit log**: `data/predictions.jsonl` — one line per request
121
+ (crop, model version, disease, confidence, entropy, verification status,
122
+ image quality, latency, image sha256; no image bytes). Use it for drift
123
+ analysis: a rising `not_in_catalog`/`unknown` rate for a crop means the field
124
+ distribution is moving away from training.
125
+
126
+ Rotate it with host logrotate — `/etc/logrotate.d/cropintel`:
127
+ ```
128
+ /opt/cropintel/data/predictions.jsonl {
129
+ size 50M
130
+ rotate 10
131
+ copytruncate
132
+ compress
133
+ missingok
134
+ }
135
+ ```
136
+
137
+ ## Backups
138
+
139
+ ```bash
140
+ # nightly at 03:00 — models + pointers + audit log, keep 7
141
+ 0 3 * * * /opt/cropintel/scripts/ops/backup.sh /opt/cropintel /var/backups/cropintel
142
+ ```
143
+
144
+ Models are also re-fetchable from the release zip, so this is cheap insurance,
145
+ not a disaster-recovery plan. Add an `rclone copy` of `/var/backups/cropintel`
146
+ to object storage if you want offsite copies.
147
+
148
+ ## Troubleshooting
149
+
150
+ | Symptom | Check |
151
+ |---|---|
152
+ | `/api/health` 503 | `curl localhost:8000/readyz` inside the container — shows per-crop load errors |
153
+ | "Model not ready" for one crop | that crop has no complete version dir; fetch models or train |
154
+ | Predictions slow / queueing | the service is single-worker by design (TFLite interpreters are not thread-safe); sustained load beyond ~10 req/s needs a second look |
155
+ | New models bundle ignored | delete `ml/models/.cropintel-fetch-ok` and restart |
156
+ | Rate limiting all users together | proxy not setting `X-Forwarded-For` |