Spaces:
Sleeping
Sleeping
Wert
Browse files- backend/app/main.py +3 -1
- backend/app/models.py +24 -0
- backend/app/routes/auth.py +457 -0
- backend/app/schemas.py +192 -0
- backend/app/scripts/seed_mandi_prices.py +125 -0
- backend/app/scripts/seed_users.py +108 -0
- frontend/src/App.jsx +14 -5
- frontend/src/components/common/Header.jsx +81 -2
- frontend/src/components/schemes/SchemeDetails.jsx +1 -1
- frontend/src/context/AuthContext.jsx +176 -0
- frontend/src/pages/FarmerProfilePage.jsx +818 -0
- frontend/src/pages/HomePage.jsx +42 -2
- frontend/src/pages/LoginPage.jsx +212 -0
- frontend/src/pages/SchemesPage.jsx +1 -1
- frontend/src/pages/SignupPage.jsx +295 -0
- frontend/src/services/authApi.js +118 -0
- frontend/src/services/index.js +1 -0
- frontend/src/services/schemesApi.js +22 -66
- frontend/src/utils/constants.js +5 -0
backend/app/main.py
CHANGED
|
@@ -22,7 +22,7 @@ from app.middleware.error_handler import register_error_handlers
|
|
| 22 |
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
| 23 |
from app.middleware.rate_limiter import RateLimitMiddleware
|
| 24 |
from app.middleware.request_id import RequestIDMiddleware
|
| 25 |
-
from app.routes import disease, weather, apmc, schemes, voice
|
| 26 |
from app.utils.constants import (
|
| 27 |
API_LEGACY_PREFIX,
|
| 28 |
API_V1_PREFIX,
|
|
@@ -161,6 +161,7 @@ register_error_handlers(app)
|
|
| 161 |
# ---------------------------------------------------------------------------
|
| 162 |
|
| 163 |
# Current version routers (v1)
|
|
|
|
| 164 |
app.include_router(disease.router, prefix=API_V1_PREFIX, tags=["v1 - Disease"])
|
| 165 |
app.include_router(weather.router, prefix=API_V1_PREFIX, tags=["v1 - Weather"])
|
| 166 |
app.include_router(apmc.router, prefix=API_V1_PREFIX, tags=["v1 - APMC"])
|
|
@@ -168,6 +169,7 @@ app.include_router(schemes.router, prefix=API_V1_PREFIX, tags=["v1 - Schemes"])
|
|
| 168 |
app.include_router(voice.router, prefix=API_V1_PREFIX, tags=["v1 - Voice"])
|
| 169 |
|
| 170 |
# Legacy (unversioned) routes for backward compatibility
|
|
|
|
| 171 |
app.include_router(disease.router, prefix=API_LEGACY_PREFIX)
|
| 172 |
app.include_router(weather.router, prefix=API_LEGACY_PREFIX)
|
| 173 |
app.include_router(apmc.router, prefix=API_LEGACY_PREFIX)
|
|
|
|
| 22 |
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
| 23 |
from app.middleware.rate_limiter import RateLimitMiddleware
|
| 24 |
from app.middleware.request_id import RequestIDMiddleware
|
| 25 |
+
from app.routes import disease, weather, apmc, schemes, voice, auth
|
| 26 |
from app.utils.constants import (
|
| 27 |
API_LEGACY_PREFIX,
|
| 28 |
API_V1_PREFIX,
|
|
|
|
| 161 |
# ---------------------------------------------------------------------------
|
| 162 |
|
| 163 |
# Current version routers (v1)
|
| 164 |
+
app.include_router(auth.router, prefix=API_V1_PREFIX, tags=["v1 - Auth"])
|
| 165 |
app.include_router(disease.router, prefix=API_V1_PREFIX, tags=["v1 - Disease"])
|
| 166 |
app.include_router(weather.router, prefix=API_V1_PREFIX, tags=["v1 - Weather"])
|
| 167 |
app.include_router(apmc.router, prefix=API_V1_PREFIX, tags=["v1 - APMC"])
|
|
|
|
| 169 |
app.include_router(voice.router, prefix=API_V1_PREFIX, tags=["v1 - Voice"])
|
| 170 |
|
| 171 |
# Legacy (unversioned) routes for backward compatibility
|
| 172 |
+
app.include_router(auth.router, prefix=API_LEGACY_PREFIX)
|
| 173 |
app.include_router(disease.router, prefix=API_LEGACY_PREFIX)
|
| 174 |
app.include_router(weather.router, prefix=API_LEGACY_PREFIX)
|
| 175 |
app.include_router(apmc.router, prefix=API_LEGACY_PREFIX)
|
backend/app/models.py
CHANGED
|
@@ -92,6 +92,30 @@ class MandiPrice(Base):
|
|
| 92 |
)
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
class GovernmentScheme(Base):
|
| 96 |
"""
|
| 97 |
Government Schemes Information Model
|
|
|
|
| 92 |
)
|
| 93 |
|
| 94 |
|
| 95 |
+
class User(Base):
|
| 96 |
+
"""
|
| 97 |
+
User Model for authentication and profile management.
|
| 98 |
+
"""
|
| 99 |
+
__tablename__ = "users"
|
| 100 |
+
|
| 101 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 102 |
+
mobile_number = Column(String(15), unique=True, nullable=False, index=True)
|
| 103 |
+
name = Column(String(100), nullable=False)
|
| 104 |
+
state = Column(String(100), nullable=False, index=True)
|
| 105 |
+
district = Column(String(100), nullable=False, index=True)
|
| 106 |
+
taluka = Column(String(100), nullable=False, index=True)
|
| 107 |
+
crops = Column(String(500), nullable=True) # JSON array of max 2 crops
|
| 108 |
+
is_active = Column(Integer, default=1, nullable=False)
|
| 109 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 110 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 111 |
+
|
| 112 |
+
__table_args__ = (
|
| 113 |
+
Index('idx_user_location', 'state', 'district', 'taluka'),
|
| 114 |
+
Index('idx_user_mobile', 'mobile_number'),
|
| 115 |
+
CheckConstraint("length(mobile_number) >= 10", name='check_mobile_length'),
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
class GovernmentScheme(Base):
|
| 120 |
"""
|
| 121 |
Government Schemes Information Model
|
backend/app/routes/auth.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication API Routes
|
| 3 |
+
|
| 4 |
+
Provides endpoints for user signup, login with OTP (dummy implementation),
|
| 5 |
+
and location master data for dropdowns.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import secrets
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
from sqlalchemy.exc import IntegrityError
|
| 14 |
+
|
| 15 |
+
from app.database import get_db
|
| 16 |
+
from app.models import User
|
| 17 |
+
from app.schemas import (
|
| 18 |
+
UserSignupRequest,
|
| 19 |
+
UserLoginRequest,
|
| 20 |
+
OTPRequestSchema,
|
| 21 |
+
UserResponse,
|
| 22 |
+
AuthResponse,
|
| 23 |
+
LocationDataResponse,
|
| 24 |
+
UpdateProfileRequest,
|
| 25 |
+
)
|
| 26 |
+
import json
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
router = APIRouter()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Location Master Data
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Hardcoded location hierarchy for the project scope.
|
| 37 |
+
# This can be moved to a database table or JSON file if needed.
|
| 38 |
+
|
| 39 |
+
LOCATION_DATA = {
|
| 40 |
+
"states": ["Gujarat"],
|
| 41 |
+
"districts": {
|
| 42 |
+
"Gujarat": ["Rajkot", "Ahmedabad", "Surat", "Vadodara", "Bhavnagar"]
|
| 43 |
+
},
|
| 44 |
+
"talukas": {
|
| 45 |
+
"Rajkot": ["Gondal", "Jetpur", "Dhoraji", "Upleta", "Jasdan", "Kotda Sangani"],
|
| 46 |
+
"Ahmedabad": ["Daskroi", "Sanand", "Dholka", "Viramgam", "Mandal"],
|
| 47 |
+
"Surat": ["Chorasi", "Kamrej", "Palsana", "Olpad", "Bardoli"],
|
| 48 |
+
"Vadodara": ["Padra", "Karjan", "Dabhoi", "Savli", "Waghodia"],
|
| 49 |
+
"Bhavnagar": ["Ghogha", "Sihor", "Palitana", "Talaja", "Mahuva"]
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def generate_dummy_token(user_id: int) -> str:
|
| 55 |
+
"""Generate a dummy token for authentication (not secure, for demo only)."""
|
| 56 |
+
random_part = secrets.token_hex(16)
|
| 57 |
+
return f"dummy_token_{user_id}_{random_part}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def parse_crops(crops_str: str) -> list:
|
| 61 |
+
"""Parse crops JSON string to list."""
|
| 62 |
+
if not crops_str:
|
| 63 |
+
return []
|
| 64 |
+
try:
|
| 65 |
+
return json.loads(crops_str)
|
| 66 |
+
except (json.JSONDecodeError, TypeError):
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def user_to_response(user: User) -> UserResponse:
|
| 71 |
+
"""Convert User model to UserResponse schema."""
|
| 72 |
+
return UserResponse(
|
| 73 |
+
id=user.id,
|
| 74 |
+
mobile_number=user.mobile_number,
|
| 75 |
+
name=user.name,
|
| 76 |
+
state=user.state,
|
| 77 |
+
district=user.district,
|
| 78 |
+
taluka=user.taluka,
|
| 79 |
+
crops=parse_crops(user.crops),
|
| 80 |
+
is_active=bool(user.is_active),
|
| 81 |
+
created_at=user.created_at
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Location Data Endpoint
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
@router.get(
|
| 90 |
+
"/auth/locations",
|
| 91 |
+
response_model=LocationDataResponse,
|
| 92 |
+
summary="Get location master data",
|
| 93 |
+
description="Retrieve states, districts, and talukas for signup dropdowns"
|
| 94 |
+
)
|
| 95 |
+
async def get_location_data():
|
| 96 |
+
"""
|
| 97 |
+
Get location master data for populating signup form dropdowns.
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
- List of states
|
| 101 |
+
- Districts mapped by state
|
| 102 |
+
- Talukas mapped by district
|
| 103 |
+
"""
|
| 104 |
+
logger.info("Location data requested")
|
| 105 |
+
return LocationDataResponse(
|
| 106 |
+
states=LOCATION_DATA["states"],
|
| 107 |
+
districts=LOCATION_DATA["districts"],
|
| 108 |
+
talukas=LOCATION_DATA["talukas"]
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Signup Endpoint
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
@router.post(
|
| 117 |
+
"/auth/signup",
|
| 118 |
+
response_model=AuthResponse,
|
| 119 |
+
summary="User signup",
|
| 120 |
+
description="Register a new user with mobile number and profile details"
|
| 121 |
+
)
|
| 122 |
+
async def signup(
|
| 123 |
+
request: UserSignupRequest,
|
| 124 |
+
db: Session = Depends(get_db)
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
Register a new user.
|
| 128 |
+
|
| 129 |
+
- **mobile_number**: Unique 10-15 digit mobile number
|
| 130 |
+
- **name**: User's full name
|
| 131 |
+
- **state**: State from dropdown
|
| 132 |
+
- **district**: District from dropdown
|
| 133 |
+
- **taluka**: Taluka from dropdown
|
| 134 |
+
|
| 135 |
+
Returns user data and authentication token on success.
|
| 136 |
+
"""
|
| 137 |
+
try:
|
| 138 |
+
# Validate location data
|
| 139 |
+
if request.state not in LOCATION_DATA["states"]:
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=400,
|
| 142 |
+
detail=f"Invalid state: {request.state}. Available states: {LOCATION_DATA['states']}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if request.district not in LOCATION_DATA["districts"].get(request.state, []):
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=400,
|
| 148 |
+
detail=f"Invalid district: {request.district} for state {request.state}"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if request.taluka not in LOCATION_DATA["talukas"].get(request.district, []):
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=400,
|
| 154 |
+
detail=f"Invalid taluka: {request.taluka} for district {request.district}"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Check if mobile number already exists
|
| 158 |
+
existing_user = db.query(User).filter(
|
| 159 |
+
User.mobile_number == request.mobile_number
|
| 160 |
+
).first()
|
| 161 |
+
|
| 162 |
+
if existing_user:
|
| 163 |
+
raise HTTPException(
|
| 164 |
+
status_code=400,
|
| 165 |
+
detail="Mobile number already registered. Please login instead."
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Create new user
|
| 169 |
+
new_user = User(
|
| 170 |
+
mobile_number=request.mobile_number,
|
| 171 |
+
name=request.name.strip(),
|
| 172 |
+
state=request.state,
|
| 173 |
+
district=request.district,
|
| 174 |
+
taluka=request.taluka,
|
| 175 |
+
is_active=1
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
db.add(new_user)
|
| 179 |
+
db.commit()
|
| 180 |
+
db.refresh(new_user)
|
| 181 |
+
|
| 182 |
+
logger.info(f"New user registered: {new_user.mobile_number} ({new_user.name})")
|
| 183 |
+
|
| 184 |
+
token = generate_dummy_token(new_user.id)
|
| 185 |
+
|
| 186 |
+
return AuthResponse(
|
| 187 |
+
success=True,
|
| 188 |
+
message="Signup successful",
|
| 189 |
+
user=user_to_response(new_user),
|
| 190 |
+
token=token
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
except HTTPException:
|
| 194 |
+
raise
|
| 195 |
+
except IntegrityError as e:
|
| 196 |
+
db.rollback()
|
| 197 |
+
logger.error(f"Integrity error during signup: {str(e)}")
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=400,
|
| 200 |
+
detail="Mobile number already registered"
|
| 201 |
+
)
|
| 202 |
+
except Exception as e:
|
| 203 |
+
db.rollback()
|
| 204 |
+
logger.error(f"Error during signup: {str(e)}", exc_info=True)
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=500,
|
| 207 |
+
detail=f"Signup failed: {str(e)}"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
# OTP Request Endpoint (Dummy)
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
|
| 215 |
+
@router.post(
|
| 216 |
+
"/auth/request-otp",
|
| 217 |
+
response_model=dict,
|
| 218 |
+
summary="Request OTP",
|
| 219 |
+
description="Request OTP for login (dummy implementation - always succeeds)"
|
| 220 |
+
)
|
| 221 |
+
async def request_otp(
|
| 222 |
+
request: OTPRequestSchema,
|
| 223 |
+
db: Session = Depends(get_db)
|
| 224 |
+
):
|
| 225 |
+
"""
|
| 226 |
+
Request OTP for login.
|
| 227 |
+
|
| 228 |
+
This is a dummy implementation that always succeeds.
|
| 229 |
+
In production, this would send an actual OTP via SMS.
|
| 230 |
+
|
| 231 |
+
- **mobile_number**: Registered mobile number
|
| 232 |
+
|
| 233 |
+
Returns success message.
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
# Check if user exists
|
| 237 |
+
user = db.query(User).filter(
|
| 238 |
+
User.mobile_number == request.mobile_number
|
| 239 |
+
).first()
|
| 240 |
+
|
| 241 |
+
if not user:
|
| 242 |
+
raise HTTPException(
|
| 243 |
+
status_code=404,
|
| 244 |
+
detail="Mobile number not registered. Please signup first."
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
if not user.is_active:
|
| 248 |
+
raise HTTPException(
|
| 249 |
+
status_code=403,
|
| 250 |
+
detail="Account is deactivated. Please contact support."
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Dummy OTP - in production, generate and send actual OTP
|
| 254 |
+
logger.info(f"OTP requested for: {request.mobile_number} (dummy - any 4-6 digit OTP will work)")
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"success": True,
|
| 258 |
+
"message": "OTP sent successfully (dummy: use any 4-6 digit code)",
|
| 259 |
+
"mobile_number": request.mobile_number
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
except HTTPException:
|
| 263 |
+
raise
|
| 264 |
+
except Exception as e:
|
| 265 |
+
logger.error(f"Error requesting OTP: {str(e)}", exc_info=True)
|
| 266 |
+
raise HTTPException(
|
| 267 |
+
status_code=500,
|
| 268 |
+
detail=f"Failed to request OTP: {str(e)}"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ---------------------------------------------------------------------------
|
| 273 |
+
# Login Endpoint
|
| 274 |
+
# ---------------------------------------------------------------------------
|
| 275 |
+
|
| 276 |
+
@router.post(
|
| 277 |
+
"/auth/login",
|
| 278 |
+
response_model=AuthResponse,
|
| 279 |
+
summary="User login",
|
| 280 |
+
description="Login with mobile number and OTP (dummy OTP - any 4-6 digit code works)"
|
| 281 |
+
)
|
| 282 |
+
async def login(
|
| 283 |
+
request: UserLoginRequest,
|
| 284 |
+
db: Session = Depends(get_db)
|
| 285 |
+
):
|
| 286 |
+
"""
|
| 287 |
+
Login with mobile number and OTP.
|
| 288 |
+
|
| 289 |
+
This is a dummy implementation where any 4-6 digit OTP will work.
|
| 290 |
+
In production, this would validate against an actual OTP.
|
| 291 |
+
|
| 292 |
+
- **mobile_number**: Registered mobile number
|
| 293 |
+
- **otp**: 4-6 digit OTP code (any code works in dummy mode)
|
| 294 |
+
|
| 295 |
+
Returns user data and authentication token on success.
|
| 296 |
+
"""
|
| 297 |
+
try:
|
| 298 |
+
# Find user by mobile number
|
| 299 |
+
user = db.query(User).filter(
|
| 300 |
+
User.mobile_number == request.mobile_number
|
| 301 |
+
).first()
|
| 302 |
+
|
| 303 |
+
if not user:
|
| 304 |
+
raise HTTPException(
|
| 305 |
+
status_code=404,
|
| 306 |
+
detail="Mobile number not registered. Please signup first."
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
if not user.is_active:
|
| 310 |
+
raise HTTPException(
|
| 311 |
+
status_code=403,
|
| 312 |
+
detail="Account is deactivated. Please contact support."
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# Dummy OTP validation - accept any 4-6 digit code
|
| 316 |
+
# In production, validate against stored OTP with expiry
|
| 317 |
+
if not request.otp.isdigit() or len(request.otp) < 4 or len(request.otp) > 6:
|
| 318 |
+
raise HTTPException(
|
| 319 |
+
status_code=400,
|
| 320 |
+
detail="Invalid OTP format. OTP must be 4-6 digits."
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
logger.info(f"User logged in: {user.mobile_number} ({user.name})")
|
| 324 |
+
|
| 325 |
+
token = generate_dummy_token(user.id)
|
| 326 |
+
|
| 327 |
+
return AuthResponse(
|
| 328 |
+
success=True,
|
| 329 |
+
message="Login successful",
|
| 330 |
+
user=user_to_response(user),
|
| 331 |
+
token=token
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
except HTTPException:
|
| 335 |
+
raise
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"Error during login: {str(e)}", exc_info=True)
|
| 338 |
+
raise HTTPException(
|
| 339 |
+
status_code=500,
|
| 340 |
+
detail=f"Login failed: {str(e)}"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
# Get Current User Endpoint
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
@router.get(
|
| 349 |
+
"/auth/me",
|
| 350 |
+
response_model=UserResponse,
|
| 351 |
+
summary="Get current user",
|
| 352 |
+
description="Get current user details by mobile number (for demo purposes)"
|
| 353 |
+
)
|
| 354 |
+
async def get_current_user(
|
| 355 |
+
mobile_number: str,
|
| 356 |
+
db: Session = Depends(get_db)
|
| 357 |
+
):
|
| 358 |
+
"""
|
| 359 |
+
Get current user details.
|
| 360 |
+
|
| 361 |
+
In production, this would use the JWT token to identify the user.
|
| 362 |
+
For demo purposes, accepts mobile number as query parameter.
|
| 363 |
+
|
| 364 |
+
- **mobile_number**: User's mobile number
|
| 365 |
+
|
| 366 |
+
Returns user profile data.
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
user = db.query(User).filter(
|
| 370 |
+
User.mobile_number == mobile_number
|
| 371 |
+
).first()
|
| 372 |
+
|
| 373 |
+
if not user:
|
| 374 |
+
raise HTTPException(
|
| 375 |
+
status_code=404,
|
| 376 |
+
detail="User not found"
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
return user_to_response(user)
|
| 380 |
+
|
| 381 |
+
except HTTPException:
|
| 382 |
+
raise
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"Error fetching user: {str(e)}", exc_info=True)
|
| 385 |
+
raise HTTPException(
|
| 386 |
+
status_code=500,
|
| 387 |
+
detail=f"Failed to fetch user: {str(e)}"
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# ---------------------------------------------------------------------------
|
| 392 |
+
# Update Profile Endpoint
|
| 393 |
+
# ---------------------------------------------------------------------------
|
| 394 |
+
|
| 395 |
+
@router.put(
|
| 396 |
+
"/auth/profile",
|
| 397 |
+
response_model=UserResponse,
|
| 398 |
+
summary="Update user profile",
|
| 399 |
+
description="Update user profile including crops selection (max 2 crops)"
|
| 400 |
+
)
|
| 401 |
+
async def update_profile(
|
| 402 |
+
mobile_number: str,
|
| 403 |
+
request: UpdateProfileRequest,
|
| 404 |
+
db: Session = Depends(get_db)
|
| 405 |
+
):
|
| 406 |
+
"""
|
| 407 |
+
Update user profile.
|
| 408 |
+
|
| 409 |
+
In production, this would use the JWT token to identify the user.
|
| 410 |
+
For demo purposes, accepts mobile number as query parameter.
|
| 411 |
+
|
| 412 |
+
- **mobile_number**: User's mobile number (query param)
|
| 413 |
+
- **name**: Optional new name
|
| 414 |
+
- **crops**: Optional list of crops (max 2)
|
| 415 |
+
|
| 416 |
+
Returns updated user profile data.
|
| 417 |
+
"""
|
| 418 |
+
try:
|
| 419 |
+
user = db.query(User).filter(
|
| 420 |
+
User.mobile_number == mobile_number
|
| 421 |
+
).first()
|
| 422 |
+
|
| 423 |
+
if not user:
|
| 424 |
+
raise HTTPException(
|
| 425 |
+
status_code=404,
|
| 426 |
+
detail="User not found"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
# Update name if provided
|
| 430 |
+
if request.name is not None:
|
| 431 |
+
user.name = request.name.strip()
|
| 432 |
+
|
| 433 |
+
# Update crops if provided
|
| 434 |
+
if request.crops is not None:
|
| 435 |
+
if len(request.crops) > 2:
|
| 436 |
+
raise HTTPException(
|
| 437 |
+
status_code=400,
|
| 438 |
+
detail="Maximum 2 crops allowed"
|
| 439 |
+
)
|
| 440 |
+
user.crops = json.dumps(request.crops)
|
| 441 |
+
|
| 442 |
+
db.commit()
|
| 443 |
+
db.refresh(user)
|
| 444 |
+
|
| 445 |
+
logger.info(f"Profile updated for: {user.mobile_number} ({user.name})")
|
| 446 |
+
|
| 447 |
+
return user_to_response(user)
|
| 448 |
+
|
| 449 |
+
except HTTPException:
|
| 450 |
+
raise
|
| 451 |
+
except Exception as e:
|
| 452 |
+
db.rollback()
|
| 453 |
+
logger.error(f"Error updating profile: {str(e)}", exc_info=True)
|
| 454 |
+
raise HTTPException(
|
| 455 |
+
status_code=500,
|
| 456 |
+
detail=f"Failed to update profile: {str(e)}"
|
| 457 |
+
)
|
backend/app/schemas.py
CHANGED
|
@@ -467,3 +467,195 @@ class SchemeFilterRequest(BaseModel):
|
|
| 467 |
"is_active": True
|
| 468 |
}
|
| 469 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
"is_active": True
|
| 468 |
}
|
| 469 |
}
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
# ---------------------------------------------------------------------------
|
| 473 |
+
# Authentication Schemas
|
| 474 |
+
# ---------------------------------------------------------------------------
|
| 475 |
+
|
| 476 |
+
class UserBase(BaseModel):
|
| 477 |
+
"""Base schema for user data"""
|
| 478 |
+
mobile_number: str = Field(
|
| 479 |
+
...,
|
| 480 |
+
min_length=10,
|
| 481 |
+
max_length=15,
|
| 482 |
+
pattern=r"^\d{10,15}$",
|
| 483 |
+
description="Mobile number (10-15 digits)"
|
| 484 |
+
)
|
| 485 |
+
name: str = Field(..., min_length=2, max_length=100, description="User's full name")
|
| 486 |
+
state: str = Field(..., min_length=1, max_length=100, description="State name")
|
| 487 |
+
district: str = Field(..., min_length=1, max_length=100, description="District name")
|
| 488 |
+
taluka: str = Field(..., min_length=1, max_length=100, description="Taluka name")
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
class UserSignupRequest(UserBase):
|
| 492 |
+
"""Schema for user signup request"""
|
| 493 |
+
|
| 494 |
+
@validator('name')
|
| 495 |
+
def validate_name(cls, v):
|
| 496 |
+
"""Validate name is not empty after stripping"""
|
| 497 |
+
if not v.strip():
|
| 498 |
+
raise ValueError('Name cannot be empty')
|
| 499 |
+
return v.strip()
|
| 500 |
+
|
| 501 |
+
@validator('mobile_number')
|
| 502 |
+
def validate_mobile(cls, v):
|
| 503 |
+
"""Validate mobile number contains only digits"""
|
| 504 |
+
if not v.isdigit():
|
| 505 |
+
raise ValueError('Mobile number must contain only digits')
|
| 506 |
+
return v
|
| 507 |
+
|
| 508 |
+
class Config:
|
| 509 |
+
json_schema_extra = {
|
| 510 |
+
"example": {
|
| 511 |
+
"mobile_number": "9876543210",
|
| 512 |
+
"name": "Josh Patel",
|
| 513 |
+
"state": "Gujarat",
|
| 514 |
+
"district": "Rajkot",
|
| 515 |
+
"taluka": "Gondal"
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
class UserLoginRequest(BaseModel):
|
| 521 |
+
"""Schema for user login request"""
|
| 522 |
+
mobile_number: str = Field(
|
| 523 |
+
...,
|
| 524 |
+
min_length=10,
|
| 525 |
+
max_length=15,
|
| 526 |
+
pattern=r"^\d{10,15}$",
|
| 527 |
+
description="Mobile number (10-15 digits)"
|
| 528 |
+
)
|
| 529 |
+
otp: str = Field(
|
| 530 |
+
...,
|
| 531 |
+
min_length=4,
|
| 532 |
+
max_length=6,
|
| 533 |
+
pattern=r"^\d{4,6}$",
|
| 534 |
+
description="OTP code (4-6 digits)"
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
class Config:
|
| 538 |
+
json_schema_extra = {
|
| 539 |
+
"example": {
|
| 540 |
+
"mobile_number": "9876543210",
|
| 541 |
+
"otp": "1234"
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
class OTPRequestSchema(BaseModel):
|
| 547 |
+
"""Schema for OTP request"""
|
| 548 |
+
mobile_number: str = Field(
|
| 549 |
+
...,
|
| 550 |
+
min_length=10,
|
| 551 |
+
max_length=15,
|
| 552 |
+
pattern=r"^\d{10,15}$",
|
| 553 |
+
description="Mobile number (10-15 digits)"
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
class Config:
|
| 557 |
+
json_schema_extra = {
|
| 558 |
+
"example": {
|
| 559 |
+
"mobile_number": "9876543210"
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
class UserResponse(BaseModel):
|
| 565 |
+
"""Schema for user response"""
|
| 566 |
+
id: int = Field(..., description="User ID")
|
| 567 |
+
mobile_number: str = Field(..., description="Mobile number")
|
| 568 |
+
name: str = Field(..., description="User's full name")
|
| 569 |
+
state: str = Field(..., description="State name")
|
| 570 |
+
district: str = Field(..., description="District name")
|
| 571 |
+
taluka: str = Field(..., description="Taluka name")
|
| 572 |
+
crops: Optional[List[str]] = Field(default_factory=list, description="User's selected crops (max 2)")
|
| 573 |
+
is_active: bool = Field(..., description="Whether user is active")
|
| 574 |
+
created_at: datetime = Field(..., description="Account creation timestamp")
|
| 575 |
+
|
| 576 |
+
class Config:
|
| 577 |
+
from_attributes = True
|
| 578 |
+
json_schema_extra = {
|
| 579 |
+
"example": {
|
| 580 |
+
"id": 1,
|
| 581 |
+
"mobile_number": "9876543210",
|
| 582 |
+
"name": "Josh Patel",
|
| 583 |
+
"state": "Gujarat",
|
| 584 |
+
"district": "Rajkot",
|
| 585 |
+
"taluka": "Gondal",
|
| 586 |
+
"crops": ["Wheat", "Cotton"],
|
| 587 |
+
"is_active": True,
|
| 588 |
+
"created_at": "2024-01-01T00:00:00Z"
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
class AuthResponse(BaseModel):
|
| 594 |
+
"""Schema for authentication response"""
|
| 595 |
+
success: bool = Field(..., description="Whether authentication was successful")
|
| 596 |
+
message: str = Field(..., description="Response message")
|
| 597 |
+
user: Optional[UserResponse] = Field(None, description="User data if authenticated")
|
| 598 |
+
token: Optional[str] = Field(None, description="Authentication token")
|
| 599 |
+
|
| 600 |
+
class Config:
|
| 601 |
+
json_schema_extra = {
|
| 602 |
+
"example": {
|
| 603 |
+
"success": True,
|
| 604 |
+
"message": "Login successful",
|
| 605 |
+
"user": {
|
| 606 |
+
"id": 1,
|
| 607 |
+
"mobile_number": "9876543210",
|
| 608 |
+
"name": "Josh Patel",
|
| 609 |
+
"state": "Gujarat",
|
| 610 |
+
"district": "Rajkot",
|
| 611 |
+
"taluka": "Gondal",
|
| 612 |
+
"is_active": True,
|
| 613 |
+
"created_at": "2024-01-01T00:00:00Z"
|
| 614 |
+
},
|
| 615 |
+
"token": "dummy_token_12345"
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
class LocationDataResponse(BaseModel):
|
| 621 |
+
"""Schema for location data response (states, districts, talukas)"""
|
| 622 |
+
states: List[str] = Field(..., description="List of available states")
|
| 623 |
+
districts: dict = Field(..., description="Districts mapped by state")
|
| 624 |
+
talukas: dict = Field(..., description="Talukas mapped by district")
|
| 625 |
+
|
| 626 |
+
class Config:
|
| 627 |
+
json_schema_extra = {
|
| 628 |
+
"example": {
|
| 629 |
+
"states": ["Gujarat"],
|
| 630 |
+
"districts": {"Gujarat": ["Rajkot", "Ahmedabad"]},
|
| 631 |
+
"talukas": {"Rajkot": ["Gondal", "Jetpur"]}
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
class UpdateProfileRequest(BaseModel):
|
| 637 |
+
"""Schema for updating user profile"""
|
| 638 |
+
name: Optional[str] = Field(None, min_length=2, max_length=100, description="User's full name")
|
| 639 |
+
crops: Optional[List[str]] = Field(None, max_length=2, description="User's selected crops (max 2)")
|
| 640 |
+
|
| 641 |
+
@validator('crops')
|
| 642 |
+
def validate_crops(cls, v):
|
| 643 |
+
"""Validate crops list has max 2 items"""
|
| 644 |
+
if v is not None and len(v) > 2:
|
| 645 |
+
raise ValueError('Maximum 2 crops allowed')
|
| 646 |
+
return v
|
| 647 |
+
|
| 648 |
+
@validator('name')
|
| 649 |
+
def validate_name(cls, v):
|
| 650 |
+
"""Validate name is not empty after stripping"""
|
| 651 |
+
if v is not None and not v.strip():
|
| 652 |
+
raise ValueError('Name cannot be empty')
|
| 653 |
+
return v.strip() if v else v
|
| 654 |
+
|
| 655 |
+
class Config:
|
| 656 |
+
json_schema_extra = {
|
| 657 |
+
"example": {
|
| 658 |
+
"name": "Josh Patel",
|
| 659 |
+
"crops": ["Wheat", "Cotton"]
|
| 660 |
+
}
|
| 661 |
+
}
|
backend/app/scripts/seed_mandi_prices.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed Mandi Prices Script
|
| 3 |
+
|
| 4 |
+
Loads mandi prices from JSON file into the database for testing and demo purposes.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
# Add parent directory to path for imports
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 14 |
+
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
from sqlalchemy.exc import IntegrityError
|
| 17 |
+
from app.database import SessionLocal, init_db
|
| 18 |
+
from app.models import MandiPrice
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_date(date_str: str) -> datetime:
|
| 22 |
+
"""Parse date string to datetime object."""
|
| 23 |
+
try:
|
| 24 |
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
| 25 |
+
except Exception:
|
| 26 |
+
return datetime.now()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def seed_mandi_prices(db: Session, data_file: Path) -> dict:
|
| 30 |
+
"""
|
| 31 |
+
Seed mandi prices from JSON file into the database.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
dict with counts of created and skipped records
|
| 35 |
+
"""
|
| 36 |
+
if not data_file.exists():
|
| 37 |
+
print(f" Error: Data file not found: {data_file}")
|
| 38 |
+
return {"created": 0, "skipped": 0, "error": "File not found"}
|
| 39 |
+
|
| 40 |
+
with open(data_file, "r", encoding="utf-8") as f:
|
| 41 |
+
prices = json.load(f)
|
| 42 |
+
|
| 43 |
+
print(f" Loaded {len(prices)} price records from JSON")
|
| 44 |
+
|
| 45 |
+
created = 0
|
| 46 |
+
skipped = 0
|
| 47 |
+
|
| 48 |
+
for price_data in prices:
|
| 49 |
+
try:
|
| 50 |
+
# Check if record already exists
|
| 51 |
+
existing = db.query(MandiPrice).filter(
|
| 52 |
+
MandiPrice.commodity == price_data["commodity"],
|
| 53 |
+
MandiPrice.mandi_name == price_data["mandi_name"],
|
| 54 |
+
MandiPrice.arrival_date == parse_date(price_data["arrival_date"])
|
| 55 |
+
).first()
|
| 56 |
+
|
| 57 |
+
if existing:
|
| 58 |
+
skipped += 1
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
# Create new price record
|
| 62 |
+
price = MandiPrice(
|
| 63 |
+
commodity=price_data["commodity"],
|
| 64 |
+
mandi_name=price_data["mandi_name"],
|
| 65 |
+
state=price_data["state"],
|
| 66 |
+
district=price_data["district"],
|
| 67 |
+
price_per_quintal=price_data["price_per_quintal"],
|
| 68 |
+
arrival_date=parse_date(price_data["arrival_date"]),
|
| 69 |
+
min_price=price_data.get("min_price"),
|
| 70 |
+
max_price=price_data.get("max_price"),
|
| 71 |
+
modal_price=price_data.get("modal_price")
|
| 72 |
+
)
|
| 73 |
+
db.add(price)
|
| 74 |
+
created += 1
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f" Error adding price: {e}")
|
| 78 |
+
skipped += 1
|
| 79 |
+
|
| 80 |
+
db.commit()
|
| 81 |
+
return {"created": created, "skipped": skipped}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main():
|
| 85 |
+
"""Main entry point for seeding mandi prices."""
|
| 86 |
+
print("=" * 60)
|
| 87 |
+
print("FarmHelp Mandi Prices Seeding Script")
|
| 88 |
+
print("=" * 60)
|
| 89 |
+
|
| 90 |
+
# Initialize database tables
|
| 91 |
+
print("\nInitializing database...")
|
| 92 |
+
init_db()
|
| 93 |
+
|
| 94 |
+
# Data file path
|
| 95 |
+
data_dir = Path(__file__).parent.parent.parent / "data"
|
| 96 |
+
data_file = data_dir / "mandi_prices.json"
|
| 97 |
+
|
| 98 |
+
print(f"\nLoading data from: {data_file}")
|
| 99 |
+
|
| 100 |
+
# Create session and seed prices
|
| 101 |
+
db = SessionLocal()
|
| 102 |
+
try:
|
| 103 |
+
print("\nSeeding mandi prices...")
|
| 104 |
+
result = seed_mandi_prices(db, data_file)
|
| 105 |
+
|
| 106 |
+
print("\n" + "-" * 40)
|
| 107 |
+
print(f"Summary: {result['created']} created, {result['skipped']} skipped")
|
| 108 |
+
print("-" * 40)
|
| 109 |
+
|
| 110 |
+
# Summary stats
|
| 111 |
+
commodities = db.query(MandiPrice.commodity).distinct().all()
|
| 112 |
+
total = db.query(MandiPrice).count()
|
| 113 |
+
|
| 114 |
+
print(f"\nTotal records in database: {total}")
|
| 115 |
+
print(f"Commodities: {', '.join([c[0] for c in commodities])}")
|
| 116 |
+
|
| 117 |
+
finally:
|
| 118 |
+
db.close()
|
| 119 |
+
|
| 120 |
+
print("\nDone!")
|
| 121 |
+
print("=" * 60)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|
backend/app/scripts/seed_users.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed Users Script
|
| 3 |
+
|
| 4 |
+
Creates initial dummy users in the database for testing and demo purposes.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path for imports
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 13 |
+
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
from sqlalchemy.exc import IntegrityError
|
| 16 |
+
from app.database import SessionLocal, init_db
|
| 17 |
+
from app.models import User
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
SEED_USERS = [
|
| 21 |
+
{
|
| 22 |
+
"mobile_number": "9876543210",
|
| 23 |
+
"name": "Josh Patel",
|
| 24 |
+
"state": "Gujarat",
|
| 25 |
+
"district": "Rajkot",
|
| 26 |
+
"taluka": "Gondal",
|
| 27 |
+
"is_active": 1
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def seed_users(db: Session) -> dict:
|
| 33 |
+
"""
|
| 34 |
+
Seed initial users into the database.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
dict with counts of created and skipped users
|
| 38 |
+
"""
|
| 39 |
+
created = 0
|
| 40 |
+
skipped = 0
|
| 41 |
+
|
| 42 |
+
for user_data in SEED_USERS:
|
| 43 |
+
try:
|
| 44 |
+
# Check if user already exists
|
| 45 |
+
existing = db.query(User).filter(
|
| 46 |
+
User.mobile_number == user_data["mobile_number"]
|
| 47 |
+
).first()
|
| 48 |
+
|
| 49 |
+
if existing:
|
| 50 |
+
print(f" Skipped (exists): {user_data['name']} ({user_data['mobile_number']})")
|
| 51 |
+
skipped += 1
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
# Create new user
|
| 55 |
+
user = User(**user_data)
|
| 56 |
+
db.add(user)
|
| 57 |
+
db.commit()
|
| 58 |
+
|
| 59 |
+
print(f" Created: {user_data['name']} ({user_data['mobile_number']})")
|
| 60 |
+
created += 1
|
| 61 |
+
|
| 62 |
+
except IntegrityError as e:
|
| 63 |
+
db.rollback()
|
| 64 |
+
print(f" Error (integrity): {user_data['name']} - {str(e)}")
|
| 65 |
+
skipped += 1
|
| 66 |
+
except Exception as e:
|
| 67 |
+
db.rollback()
|
| 68 |
+
print(f" Error: {user_data['name']} - {str(e)}")
|
| 69 |
+
skipped += 1
|
| 70 |
+
|
| 71 |
+
return {"created": created, "skipped": skipped}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
"""Main entry point for seeding users."""
|
| 76 |
+
print("=" * 60)
|
| 77 |
+
print("FarmHelp User Seeding Script")
|
| 78 |
+
print("=" * 60)
|
| 79 |
+
|
| 80 |
+
# Initialize database tables
|
| 81 |
+
print("\nInitializing database...")
|
| 82 |
+
init_db()
|
| 83 |
+
|
| 84 |
+
# Create session and seed users
|
| 85 |
+
db = SessionLocal()
|
| 86 |
+
try:
|
| 87 |
+
print("\nSeeding users...")
|
| 88 |
+
result = seed_users(db)
|
| 89 |
+
|
| 90 |
+
print("\n" + "-" * 40)
|
| 91 |
+
print(f"Summary: {result['created']} created, {result['skipped']} skipped")
|
| 92 |
+
print("-" * 40)
|
| 93 |
+
|
| 94 |
+
# List all users
|
| 95 |
+
print("\nAll users in database:")
|
| 96 |
+
users = db.query(User).all()
|
| 97 |
+
for user in users:
|
| 98 |
+
print(f" - {user.name} | {user.mobile_number} | {user.state}, {user.district}, {user.taluka}")
|
| 99 |
+
|
| 100 |
+
finally:
|
| 101 |
+
db.close()
|
| 102 |
+
|
| 103 |
+
print("\nDone!")
|
| 104 |
+
print("=" * 60)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
main()
|
frontend/src/App.jsx
CHANGED
|
@@ -4,6 +4,7 @@ import { AnimatePresence } from "framer-motion";
|
|
| 4 |
import { AppProvider } from "@context/AppContext";
|
| 5 |
import { VoiceProvider } from "@context/VoiceContext";
|
| 6 |
import { LocationProvider } from "@context/LocationContext";
|
|
|
|
| 7 |
import { Layout, PageTransition, LoadingSpinner, ErrorBoundary } from "@components/common";
|
| 8 |
import useApp from "@hooks/useApp";
|
| 9 |
|
|
@@ -12,6 +13,9 @@ import useApp from "@hooks/useApp";
|
|
| 12 |
// ---------------------------------------------------------------------------
|
| 13 |
|
| 14 |
const HomePage = lazy(() => import("@pages/HomePage"));
|
|
|
|
|
|
|
|
|
|
| 15 |
const DiseaseDetectionPage = lazy(() => import("@pages/DiseaseDetectionPage"));
|
| 16 |
const WeatherPage = lazy(() => import("@pages/WeatherPage"));
|
| 17 |
const APMCPricePage = lazy(() => import("@pages/APMCPricePage"));
|
|
@@ -49,6 +53,9 @@ function AppShell() {
|
|
| 49 |
<PageTransition key={location.pathname}>
|
| 50 |
<Routes location={location}>
|
| 51 |
<Route path="/" element={<HomePage />} />
|
|
|
|
|
|
|
|
|
|
| 52 |
<Route path="/disease" element={<DiseaseDetectionPage />} />
|
| 53 |
<Route path="/weather" element={<WeatherPage />} />
|
| 54 |
<Route path="/apmc" element={<APMCPricePage />} />
|
|
@@ -73,11 +80,13 @@ function AppShell() {
|
|
| 73 |
function App() {
|
| 74 |
return (
|
| 75 |
<AppProvider>
|
| 76 |
-
<
|
| 77 |
-
<
|
| 78 |
-
<
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
</AppProvider>
|
| 82 |
);
|
| 83 |
}
|
|
|
|
| 4 |
import { AppProvider } from "@context/AppContext";
|
| 5 |
import { VoiceProvider } from "@context/VoiceContext";
|
| 6 |
import { LocationProvider } from "@context/LocationContext";
|
| 7 |
+
import { AuthProvider } from "@context/AuthContext";
|
| 8 |
import { Layout, PageTransition, LoadingSpinner, ErrorBoundary } from "@components/common";
|
| 9 |
import useApp from "@hooks/useApp";
|
| 10 |
|
|
|
|
| 13 |
// ---------------------------------------------------------------------------
|
| 14 |
|
| 15 |
const HomePage = lazy(() => import("@pages/HomePage"));
|
| 16 |
+
const LoginPage = lazy(() => import("@pages/LoginPage"));
|
| 17 |
+
const SignupPage = lazy(() => import("@pages/SignupPage"));
|
| 18 |
+
const FarmerProfilePage = lazy(() => import("@pages/FarmerProfilePage"));
|
| 19 |
const DiseaseDetectionPage = lazy(() => import("@pages/DiseaseDetectionPage"));
|
| 20 |
const WeatherPage = lazy(() => import("@pages/WeatherPage"));
|
| 21 |
const APMCPricePage = lazy(() => import("@pages/APMCPricePage"));
|
|
|
|
| 53 |
<PageTransition key={location.pathname}>
|
| 54 |
<Routes location={location}>
|
| 55 |
<Route path="/" element={<HomePage />} />
|
| 56 |
+
<Route path="/login" element={<LoginPage />} />
|
| 57 |
+
<Route path="/signup" element={<SignupPage />} />
|
| 58 |
+
<Route path="/profile" element={<FarmerProfilePage />} />
|
| 59 |
<Route path="/disease" element={<DiseaseDetectionPage />} />
|
| 60 |
<Route path="/weather" element={<WeatherPage />} />
|
| 61 |
<Route path="/apmc" element={<APMCPricePage />} />
|
|
|
|
| 80 |
function App() {
|
| 81 |
return (
|
| 82 |
<AppProvider>
|
| 83 |
+
<AuthProvider>
|
| 84 |
+
<LocationProvider>
|
| 85 |
+
<VoiceProvider>
|
| 86 |
+
<AppShell />
|
| 87 |
+
</VoiceProvider>
|
| 88 |
+
</LocationProvider>
|
| 89 |
+
</AuthProvider>
|
| 90 |
</AppProvider>
|
| 91 |
);
|
| 92 |
}
|
frontend/src/components/common/Header.jsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
/**
|
| 2 |
* Header - Top navigation bar with logo, navigation links,
|
| 3 |
-
* language toggle,
|
| 4 |
*
|
| 5 |
* Features a subtle gradient background and responsive
|
| 6 |
* hamburger menu on mobile.
|
| 7 |
*/
|
| 8 |
|
| 9 |
import { useState, useCallback } from "react";
|
| 10 |
-
import { Link, useLocation } from "react-router-dom";
|
| 11 |
import PropTypes from "prop-types";
|
| 12 |
import {
|
| 13 |
Bars3Icon,
|
|
@@ -15,8 +15,11 @@ import {
|
|
| 15 |
LanguageIcon,
|
| 16 |
SunIcon,
|
| 17 |
MoonIcon,
|
|
|
|
|
|
|
| 18 |
} from "@heroicons/react/24/outline";
|
| 19 |
import { ROUTES, LANGUAGES, APP_NAME } from "@utils/constants";
|
|
|
|
| 20 |
|
| 21 |
const NAV_ITEMS = [
|
| 22 |
{ label: "Home", path: ROUTES.HOME },
|
|
@@ -29,6 +32,8 @@ const NAV_ITEMS = [
|
|
| 29 |
function Header({ language, onToggleLanguage, theme, onToggleTheme }) {
|
| 30 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 31 |
const location = useLocation();
|
|
|
|
|
|
|
| 32 |
|
| 33 |
const toggleMobile = useCallback(() => {
|
| 34 |
setMobileMenuOpen((prev) => !prev);
|
|
@@ -38,6 +43,12 @@ function Header({ language, onToggleLanguage, theme, onToggleTheme }) {
|
|
| 38 |
setMobileMenuOpen(false);
|
| 39 |
}, []);
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const languageLabel = language === LANGUAGES.HI ? "EN" : "HI";
|
| 42 |
const isDark = theme === "dark";
|
| 43 |
|
|
@@ -122,6 +133,37 @@ function Header({ language, onToggleLanguage, theme, onToggleTheme }) {
|
|
| 122 |
</button>
|
| 123 |
)}
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
{/* Mobile menu button */}
|
| 126 |
<button
|
| 127 |
type="button"
|
|
@@ -168,6 +210,43 @@ function Header({ language, onToggleLanguage, theme, onToggleTheme }) {
|
|
| 168 |
</Link>
|
| 169 |
);
|
| 170 |
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
</nav>
|
| 173 |
)}
|
|
|
|
| 1 |
/**
|
| 2 |
* Header - Top navigation bar with logo, navigation links,
|
| 3 |
+
* language toggle, dark mode toggle, and user profile.
|
| 4 |
*
|
| 5 |
* Features a subtle gradient background and responsive
|
| 6 |
* hamburger menu on mobile.
|
| 7 |
*/
|
| 8 |
|
| 9 |
import { useState, useCallback } from "react";
|
| 10 |
+
import { Link, useLocation, useNavigate } from "react-router-dom";
|
| 11 |
import PropTypes from "prop-types";
|
| 12 |
import {
|
| 13 |
Bars3Icon,
|
|
|
|
| 15 |
LanguageIcon,
|
| 16 |
SunIcon,
|
| 17 |
MoonIcon,
|
| 18 |
+
UserCircleIcon,
|
| 19 |
+
ArrowRightOnRectangleIcon,
|
| 20 |
} from "@heroicons/react/24/outline";
|
| 21 |
import { ROUTES, LANGUAGES, APP_NAME } from "@utils/constants";
|
| 22 |
+
import { useAuth } from "@context/AuthContext";
|
| 23 |
|
| 24 |
const NAV_ITEMS = [
|
| 25 |
{ label: "Home", path: ROUTES.HOME },
|
|
|
|
| 32 |
function Header({ language, onToggleLanguage, theme, onToggleTheme }) {
|
| 33 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 34 |
const location = useLocation();
|
| 35 |
+
const navigate = useNavigate();
|
| 36 |
+
const { isAuthenticated, user, logout } = useAuth();
|
| 37 |
|
| 38 |
const toggleMobile = useCallback(() => {
|
| 39 |
setMobileMenuOpen((prev) => !prev);
|
|
|
|
| 43 |
setMobileMenuOpen(false);
|
| 44 |
}, []);
|
| 45 |
|
| 46 |
+
const handleLogout = useCallback(() => {
|
| 47 |
+
logout();
|
| 48 |
+
navigate(ROUTES.HOME);
|
| 49 |
+
closeMobile();
|
| 50 |
+
}, [logout, navigate, closeMobile]);
|
| 51 |
+
|
| 52 |
const languageLabel = language === LANGUAGES.HI ? "EN" : "HI";
|
| 53 |
const isDark = theme === "dark";
|
| 54 |
|
|
|
|
| 133 |
</button>
|
| 134 |
)}
|
| 135 |
|
| 136 |
+
{/* User Profile / Login */}
|
| 137 |
+
<div className="hidden md:flex items-center gap-1.5">
|
| 138 |
+
{isAuthenticated && user ? (
|
| 139 |
+
<>
|
| 140 |
+
<Link
|
| 141 |
+
to={ROUTES.PROFILE}
|
| 142 |
+
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
| 143 |
+
>
|
| 144 |
+
<UserCircleIcon className="h-5 w-5" aria-hidden="true" />
|
| 145 |
+
<span className="max-w-[100px] truncate">{user.name?.split(" ")[0]}</span>
|
| 146 |
+
</Link>
|
| 147 |
+
<button
|
| 148 |
+
type="button"
|
| 149 |
+
onClick={handleLogout}
|
| 150 |
+
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
| 151 |
+
aria-label="Logout"
|
| 152 |
+
title="Logout"
|
| 153 |
+
>
|
| 154 |
+
<ArrowRightOnRectangleIcon className="h-5 w-5" aria-hidden="true" />
|
| 155 |
+
</button>
|
| 156 |
+
</>
|
| 157 |
+
) : (
|
| 158 |
+
<Link
|
| 159 |
+
to={ROUTES.LOGIN}
|
| 160 |
+
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
| 161 |
+
>
|
| 162 |
+
Login
|
| 163 |
+
</Link>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
{/* Mobile menu button */}
|
| 168 |
<button
|
| 169 |
type="button"
|
|
|
|
| 210 |
</Link>
|
| 211 |
);
|
| 212 |
})}
|
| 213 |
+
|
| 214 |
+
{/* Mobile Auth Links */}
|
| 215 |
+
<div className="border-t border-neutral-200 mt-2 pt-2">
|
| 216 |
+
{isAuthenticated && user ? (
|
| 217 |
+
<>
|
| 218 |
+
<Link
|
| 219 |
+
to={ROUTES.PROFILE}
|
| 220 |
+
onClick={closeMobile}
|
| 221 |
+
className={[
|
| 222 |
+
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
| 223 |
+
location.pathname === ROUTES.PROFILE
|
| 224 |
+
? "bg-primary-50 text-primary-700"
|
| 225 |
+
: "text-neutral-600 hover:bg-neutral-100",
|
| 226 |
+
].join(" ")}
|
| 227 |
+
>
|
| 228 |
+
<UserCircleIcon className="h-5 w-5" />
|
| 229 |
+
My Profile
|
| 230 |
+
</Link>
|
| 231 |
+
<button
|
| 232 |
+
type="button"
|
| 233 |
+
onClick={handleLogout}
|
| 234 |
+
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors"
|
| 235 |
+
>
|
| 236 |
+
<ArrowRightOnRectangleIcon className="h-5 w-5" />
|
| 237 |
+
Logout
|
| 238 |
+
</button>
|
| 239 |
+
</>
|
| 240 |
+
) : (
|
| 241 |
+
<Link
|
| 242 |
+
to={ROUTES.LOGIN}
|
| 243 |
+
onClick={closeMobile}
|
| 244 |
+
className="block px-3 py-2 rounded-lg text-sm font-medium bg-primary-600 text-white text-center hover:bg-primary-700 transition-colors"
|
| 245 |
+
>
|
| 246 |
+
Login
|
| 247 |
+
</Link>
|
| 248 |
+
)}
|
| 249 |
+
</div>
|
| 250 |
</div>
|
| 251 |
</nav>
|
| 252 |
)}
|
frontend/src/components/schemes/SchemeDetails.jsx
CHANGED
|
@@ -29,7 +29,7 @@ function SchemeDetails({ scheme, isOpen, onClose }) {
|
|
| 29 |
};
|
| 30 |
|
| 31 |
return (
|
| 32 |
-
<Modal isOpen={isOpen} onClose={onClose} size="
|
| 33 |
<div className="max-h-[80vh] overflow-y-auto">
|
| 34 |
{/* Header */}
|
| 35 |
<div className="sticky top-0 bg-white border-b border-neutral-200 px-6 py-4 flex items-start justify-between z-10">
|
|
|
|
| 29 |
};
|
| 30 |
|
| 31 |
return (
|
| 32 |
+
<Modal isOpen={isOpen} onClose={onClose} size="full">
|
| 33 |
<div className="max-h-[80vh] overflow-y-auto">
|
| 34 |
{/* Header */}
|
| 35 |
<div className="sticky top-0 bg-white border-b border-neutral-200 px-6 py-4 flex items-start justify-between z-10">
|
frontend/src/context/AuthContext.jsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* eslint-disable react-refresh/only-export-components */
|
| 2 |
+
/**
|
| 3 |
+
* AuthContext - Authentication state management.
|
| 4 |
+
*
|
| 5 |
+
* Manages:
|
| 6 |
+
* - User authentication state
|
| 7 |
+
* - Login/Logout operations
|
| 8 |
+
* - Token persistence
|
| 9 |
+
* - Current user data
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { createContext, useReducer, useEffect, useCallback, useContext } from "react";
|
| 13 |
+
import PropTypes from "prop-types";
|
| 14 |
+
import storage from "@utils/storage";
|
| 15 |
+
import { STORAGE_KEYS } from "@utils/constants";
|
| 16 |
+
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
// Initial State
|
| 19 |
+
// ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
function loadPersistedAuth() {
|
| 22 |
+
const user = storage.get(STORAGE_KEYS.AUTH_USER, null);
|
| 23 |
+
const token = storage.get(STORAGE_KEYS.AUTH_TOKEN, null);
|
| 24 |
+
return {
|
| 25 |
+
user,
|
| 26 |
+
token,
|
| 27 |
+
isAuthenticated: Boolean(user && token),
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function buildInitialState() {
|
| 32 |
+
const persisted = loadPersistedAuth();
|
| 33 |
+
return {
|
| 34 |
+
...persisted,
|
| 35 |
+
loading: false,
|
| 36 |
+
error: null,
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// ---------------------------------------------------------------------------
|
| 41 |
+
// Action Types
|
| 42 |
+
// ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
const ACTION_TYPES = {
|
| 45 |
+
SET_LOADING: "SET_LOADING",
|
| 46 |
+
LOGIN_SUCCESS: "LOGIN_SUCCESS",
|
| 47 |
+
LOGOUT: "LOGOUT",
|
| 48 |
+
SET_ERROR: "SET_ERROR",
|
| 49 |
+
CLEAR_ERROR: "CLEAR_ERROR",
|
| 50 |
+
UPDATE_USER: "UPDATE_USER",
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// ---------------------------------------------------------------------------
|
| 54 |
+
// Reducer
|
| 55 |
+
// ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
function authReducer(state, action) {
|
| 58 |
+
switch (action.type) {
|
| 59 |
+
case ACTION_TYPES.SET_LOADING:
|
| 60 |
+
return { ...state, loading: action.payload, error: null };
|
| 61 |
+
|
| 62 |
+
case ACTION_TYPES.LOGIN_SUCCESS:
|
| 63 |
+
return {
|
| 64 |
+
...state,
|
| 65 |
+
user: action.payload.user,
|
| 66 |
+
token: action.payload.token,
|
| 67 |
+
isAuthenticated: true,
|
| 68 |
+
loading: false,
|
| 69 |
+
error: null,
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
case ACTION_TYPES.LOGOUT:
|
| 73 |
+
return {
|
| 74 |
+
...state,
|
| 75 |
+
user: null,
|
| 76 |
+
token: null,
|
| 77 |
+
isAuthenticated: false,
|
| 78 |
+
loading: false,
|
| 79 |
+
error: null,
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
case ACTION_TYPES.SET_ERROR:
|
| 83 |
+
return { ...state, error: action.payload, loading: false };
|
| 84 |
+
|
| 85 |
+
case ACTION_TYPES.CLEAR_ERROR:
|
| 86 |
+
return { ...state, error: null };
|
| 87 |
+
|
| 88 |
+
case ACTION_TYPES.UPDATE_USER:
|
| 89 |
+
return { ...state, user: { ...state.user, ...action.payload } };
|
| 90 |
+
|
| 91 |
+
default:
|
| 92 |
+
return state;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---------------------------------------------------------------------------
|
| 97 |
+
// Context
|
| 98 |
+
// ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
export const AuthContext = createContext(null);
|
| 101 |
+
|
| 102 |
+
// ---------------------------------------------------------------------------
|
| 103 |
+
// Provider
|
| 104 |
+
// ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
export function AuthProvider({ children }) {
|
| 107 |
+
const [state, dispatch] = useReducer(authReducer, null, buildInitialState);
|
| 108 |
+
|
| 109 |
+
// Persist auth data whenever it changes
|
| 110 |
+
useEffect(() => {
|
| 111 |
+
if (state.user && state.token) {
|
| 112 |
+
storage.set(STORAGE_KEYS.AUTH_USER, state.user);
|
| 113 |
+
storage.set(STORAGE_KEYS.AUTH_TOKEN, state.token);
|
| 114 |
+
} else {
|
| 115 |
+
storage.remove(STORAGE_KEYS.AUTH_USER);
|
| 116 |
+
storage.remove(STORAGE_KEYS.AUTH_TOKEN);
|
| 117 |
+
}
|
| 118 |
+
}, [state.user, state.token]);
|
| 119 |
+
|
| 120 |
+
// --- Actions ---------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
const setLoading = useCallback((loading) => {
|
| 123 |
+
dispatch({ type: ACTION_TYPES.SET_LOADING, payload: loading });
|
| 124 |
+
}, []);
|
| 125 |
+
|
| 126 |
+
const loginSuccess = useCallback((user, token) => {
|
| 127 |
+
dispatch({
|
| 128 |
+
type: ACTION_TYPES.LOGIN_SUCCESS,
|
| 129 |
+
payload: { user, token },
|
| 130 |
+
});
|
| 131 |
+
}, []);
|
| 132 |
+
|
| 133 |
+
const logout = useCallback(() => {
|
| 134 |
+
dispatch({ type: ACTION_TYPES.LOGOUT });
|
| 135 |
+
}, []);
|
| 136 |
+
|
| 137 |
+
const setError = useCallback((error) => {
|
| 138 |
+
dispatch({ type: ACTION_TYPES.SET_ERROR, payload: error });
|
| 139 |
+
}, []);
|
| 140 |
+
|
| 141 |
+
const clearError = useCallback(() => {
|
| 142 |
+
dispatch({ type: ACTION_TYPES.CLEAR_ERROR });
|
| 143 |
+
}, []);
|
| 144 |
+
|
| 145 |
+
const updateUser = useCallback((userData) => {
|
| 146 |
+
dispatch({ type: ACTION_TYPES.UPDATE_USER, payload: userData });
|
| 147 |
+
}, []);
|
| 148 |
+
|
| 149 |
+
const value = {
|
| 150 |
+
...state,
|
| 151 |
+
setLoading,
|
| 152 |
+
loginSuccess,
|
| 153 |
+
logout,
|
| 154 |
+
setError,
|
| 155 |
+
clearError,
|
| 156 |
+
updateUser,
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
AuthProvider.propTypes = {
|
| 163 |
+
children: PropTypes.node.isRequired,
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
// ---------------------------------------------------------------------------
|
| 167 |
+
// Hook
|
| 168 |
+
// ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
export function useAuth() {
|
| 171 |
+
const context = useContext(AuthContext);
|
| 172 |
+
if (!context) {
|
| 173 |
+
throw new Error("useAuth must be used within an AuthProvider");
|
| 174 |
+
}
|
| 175 |
+
return context;
|
| 176 |
+
}
|
frontend/src/pages/FarmerProfilePage.jsx
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* FarmerProfilePage - Personalized farmer dashboard.
|
| 3 |
+
*
|
| 4 |
+
* Displays:
|
| 5 |
+
* - User profile with edit capability
|
| 6 |
+
* - Personalized weather info based on location
|
| 7 |
+
* - APMC prices for selected crops
|
| 8 |
+
* - Government schemes relevant to user's state
|
| 9 |
+
*
|
| 10 |
+
* Only accessible to logged-in users.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { useState, useCallback, useEffect } from "react";
|
| 14 |
+
import { useNavigate, Link } from "react-router-dom";
|
| 15 |
+
import { motion } from "framer-motion";
|
| 16 |
+
import toast from "react-hot-toast";
|
| 17 |
+
import {
|
| 18 |
+
UserIcon,
|
| 19 |
+
PhoneIcon,
|
| 20 |
+
MapPinIcon,
|
| 21 |
+
CheckIcon,
|
| 22 |
+
XMarkIcon,
|
| 23 |
+
CloudIcon,
|
| 24 |
+
CurrencyRupeeIcon,
|
| 25 |
+
DocumentTextIcon,
|
| 26 |
+
ArrowRightIcon,
|
| 27 |
+
ExclamationTriangleIcon,
|
| 28 |
+
SunIcon,
|
| 29 |
+
ArrowTrendingUpIcon,
|
| 30 |
+
ArrowTrendingDownIcon,
|
| 31 |
+
PencilIcon,
|
| 32 |
+
} from "@heroicons/react/24/outline";
|
| 33 |
+
|
| 34 |
+
import { Input, Button, Card, Select, LoadingSpinner, Tabs } from "@components/common";
|
| 35 |
+
import { useAuth } from "@context/AuthContext";
|
| 36 |
+
import { updateProfile } from "@services/authApi";
|
| 37 |
+
import { getCommodities, getPrices, getTrends } from "@services/apmcApi";
|
| 38 |
+
import { getForecast, getAlerts } from "@services/weatherApi";
|
| 39 |
+
import { getSchemesByState } from "@services/schemesApi";
|
| 40 |
+
import { ROUTES } from "@utils/constants";
|
| 41 |
+
|
| 42 |
+
// Animation variants
|
| 43 |
+
const containerVariants = {
|
| 44 |
+
hidden: {},
|
| 45 |
+
show: { transition: { staggerChildren: 0.1 } },
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const itemVariants = {
|
| 49 |
+
hidden: { opacity: 0, y: 16 },
|
| 50 |
+
show: { opacity: 1, y: 0, transition: { duration: 0.35, ease: "easeOut" } },
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// Weather icon mapping
|
| 54 |
+
function getWeatherIcon(code) {
|
| 55 |
+
if (code <= 3) return SunIcon;
|
| 56 |
+
return CloudIcon;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Sub-components
|
| 60 |
+
function WeatherCard({ weather, alerts, loading, error, location }) {
|
| 61 |
+
if (loading) {
|
| 62 |
+
return (
|
| 63 |
+
<Card className="p-6">
|
| 64 |
+
<div className="flex items-center gap-2 mb-4">
|
| 65 |
+
<CloudIcon className="h-5 w-5 text-accent-600" />
|
| 66 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Weather</h3>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="flex justify-center py-8">
|
| 69 |
+
<LoadingSpinner size="md" message="Loading weather..." />
|
| 70 |
+
</div>
|
| 71 |
+
</Card>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if (error) {
|
| 76 |
+
return (
|
| 77 |
+
<Card className="p-6">
|
| 78 |
+
<div className="flex items-center gap-2 mb-4">
|
| 79 |
+
<CloudIcon className="h-5 w-5 text-accent-600" />
|
| 80 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Weather</h3>
|
| 81 |
+
</div>
|
| 82 |
+
<p className="text-sm text-neutral-500">{error}</p>
|
| 83 |
+
</Card>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (!weather?.forecast?.daily) {
|
| 88 |
+
return (
|
| 89 |
+
<Card className="p-6">
|
| 90 |
+
<div className="flex items-center gap-2 mb-4">
|
| 91 |
+
<CloudIcon className="h-5 w-5 text-accent-600" />
|
| 92 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Weather</h3>
|
| 93 |
+
</div>
|
| 94 |
+
<p className="text-sm text-neutral-500">No weather data available</p>
|
| 95 |
+
</Card>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const daily = weather.forecast.daily;
|
| 100 |
+
const today = {
|
| 101 |
+
tempMax: daily.temperature_2m_max?.[0] ?? "--",
|
| 102 |
+
tempMin: daily.temperature_2m_min?.[0] ?? "--",
|
| 103 |
+
precipitation: daily.precipitation_sum?.[0] ?? 0,
|
| 104 |
+
weatherCode: daily.weather_code?.[0] ?? 0,
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<Card className="p-6">
|
| 109 |
+
<div className="flex items-center justify-between mb-4">
|
| 110 |
+
<div className="flex items-center gap-2">
|
| 111 |
+
<CloudIcon className="h-5 w-5 text-accent-600" />
|
| 112 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Weather</h3>
|
| 113 |
+
</div>
|
| 114 |
+
<Link
|
| 115 |
+
to={ROUTES.WEATHER}
|
| 116 |
+
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
| 117 |
+
>
|
| 118 |
+
View Full <ArrowRightIcon className="h-4 w-4" />
|
| 119 |
+
</Link>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div className="flex items-center gap-2 text-xs text-neutral-500 mb-4">
|
| 123 |
+
<MapPinIcon className="h-4 w-4" />
|
| 124 |
+
<span>{location.taluka}, {location.district}</span>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div className="flex items-center gap-4 mb-4">
|
| 128 |
+
<div className="flex-1">
|
| 129 |
+
<p className="text-3xl font-bold text-neutral-900 dark:text-white">
|
| 130 |
+
{Math.round(today.tempMax)}°C
|
| 131 |
+
</p>
|
| 132 |
+
<p className="text-sm text-neutral-500">
|
| 133 |
+
Low: {Math.round(today.tempMin)}°C
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
<div className="text-right">
|
| 137 |
+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
| 138 |
+
Precipitation
|
| 139 |
+
</p>
|
| 140 |
+
<p className="text-lg font-semibold text-accent-600">
|
| 141 |
+
{today.precipitation} mm
|
| 142 |
+
</p>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
{/* 5-day forecast */}
|
| 147 |
+
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
|
| 148 |
+
<p className="text-xs font-medium text-neutral-500 mb-3">5-Day Forecast</p>
|
| 149 |
+
<div className="grid grid-cols-5 gap-2">
|
| 150 |
+
{daily.time?.slice(0, 5).map((date, idx) => {
|
| 151 |
+
const dayName = new Date(date).toLocaleDateString("en-IN", { weekday: "short" });
|
| 152 |
+
return (
|
| 153 |
+
<div key={date} className="text-center">
|
| 154 |
+
<p className="text-xs text-neutral-500">{dayName}</p>
|
| 155 |
+
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
|
| 156 |
+
{Math.round(daily.temperature_2m_max?.[idx] ?? 0)}°
|
| 157 |
+
</p>
|
| 158 |
+
<p className="text-xs text-neutral-400">
|
| 159 |
+
{Math.round(daily.temperature_2m_min?.[idx] ?? 0)}°
|
| 160 |
+
</p>
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
})}
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Alerts */}
|
| 168 |
+
{alerts?.alerts?.length > 0 && (
|
| 169 |
+
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
| 170 |
+
<div className="flex items-center gap-2 mb-1">
|
| 171 |
+
<ExclamationTriangleIcon className="h-4 w-4 text-amber-600" />
|
| 172 |
+
<span className="text-sm font-medium text-amber-800">Weather Alert</span>
|
| 173 |
+
</div>
|
| 174 |
+
<p className="text-xs text-amber-700">{alerts.alerts[0].message}</p>
|
| 175 |
+
</div>
|
| 176 |
+
)}
|
| 177 |
+
</Card>
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function CropPriceCard({ crop, priceData, trends, loading }) {
|
| 182 |
+
if (loading) {
|
| 183 |
+
return (
|
| 184 |
+
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
| 185 |
+
<LoadingSpinner size="sm" />
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const avgPrice = priceData?.avg_price || priceData?.prices?.[0]?.price_per_quintal;
|
| 191 |
+
const trendDirection = trends?.trend?.direction;
|
| 192 |
+
const trendPercent = trends?.trend?.percent_change;
|
| 193 |
+
|
| 194 |
+
return (
|
| 195 |
+
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
| 196 |
+
<div className="flex items-center justify-between mb-2">
|
| 197 |
+
<h4 className="font-medium text-neutral-900 dark:text-white">{crop}</h4>
|
| 198 |
+
{trendDirection && (
|
| 199 |
+
<div
|
| 200 |
+
className={`flex items-center gap-1 text-xs font-medium ${
|
| 201 |
+
trendDirection === "up"
|
| 202 |
+
? "text-green-600"
|
| 203 |
+
: trendDirection === "down"
|
| 204 |
+
? "text-red-600"
|
| 205 |
+
: "text-neutral-500"
|
| 206 |
+
}`}
|
| 207 |
+
>
|
| 208 |
+
{trendDirection === "up" ? (
|
| 209 |
+
<ArrowTrendingUpIcon className="h-4 w-4" />
|
| 210 |
+
) : trendDirection === "down" ? (
|
| 211 |
+
<ArrowTrendingDownIcon className="h-4 w-4" />
|
| 212 |
+
) : null}
|
| 213 |
+
{trendPercent ? `${Math.abs(trendPercent).toFixed(1)}%` : "Stable"}
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
</div>
|
| 217 |
+
<p className="text-2xl font-bold text-primary-600">
|
| 218 |
+
{avgPrice ? `₹${Math.round(avgPrice).toLocaleString("en-IN")}` : "N/A"}
|
| 219 |
+
<span className="text-sm font-normal text-neutral-500">/qtl</span>
|
| 220 |
+
</p>
|
| 221 |
+
{priceData?.prices?.length > 0 && (
|
| 222 |
+
<p className="text-xs text-neutral-500 mt-1">
|
| 223 |
+
Best: {priceData.prices[0].mandi_name} ({priceData.prices[0].state})
|
| 224 |
+
</p>
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function APMCPricesCard({ crops, pricesData, trendsData, loading, error }) {
|
| 231 |
+
if (loading && Object.keys(pricesData).length === 0) {
|
| 232 |
+
return (
|
| 233 |
+
<Card className="p-6">
|
| 234 |
+
<div className="flex items-center gap-2 mb-4">
|
| 235 |
+
<CurrencyRupeeIcon className="h-5 w-5 text-secondary-600" />
|
| 236 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">APMC Prices</h3>
|
| 237 |
+
</div>
|
| 238 |
+
<div className="flex justify-center py-8">
|
| 239 |
+
<LoadingSpinner size="md" message="Loading prices..." />
|
| 240 |
+
</div>
|
| 241 |
+
</Card>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
if (crops.length === 0) {
|
| 246 |
+
return (
|
| 247 |
+
<Card className="p-6">
|
| 248 |
+
<div className="flex items-center gap-2 mb-4">
|
| 249 |
+
<CurrencyRupeeIcon className="h-5 w-5 text-secondary-600" />
|
| 250 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">APMC Prices</h3>
|
| 251 |
+
</div>
|
| 252 |
+
<div className="text-center py-6">
|
| 253 |
+
<p className="text-sm text-neutral-500 mb-3">
|
| 254 |
+
Add your crops to see personalized APMC prices
|
| 255 |
+
</p>
|
| 256 |
+
<p className="text-xs text-neutral-400">
|
| 257 |
+
Scroll down to add crops in your profile
|
| 258 |
+
</p>
|
| 259 |
+
</div>
|
| 260 |
+
</Card>
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return (
|
| 265 |
+
<Card className="p-6">
|
| 266 |
+
<div className="flex items-center justify-between mb-4">
|
| 267 |
+
<div className="flex items-center gap-2">
|
| 268 |
+
<CurrencyRupeeIcon className="h-5 w-5 text-secondary-600" />
|
| 269 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">APMC Prices</h3>
|
| 270 |
+
</div>
|
| 271 |
+
<Link
|
| 272 |
+
to={ROUTES.APMC}
|
| 273 |
+
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
| 274 |
+
>
|
| 275 |
+
View All <ArrowRightIcon className="h-4 w-4" />
|
| 276 |
+
</Link>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<p className="text-xs text-neutral-500 mb-4">Your selected crops</p>
|
| 280 |
+
|
| 281 |
+
<div className="space-y-3">
|
| 282 |
+
{crops.map((crop) => (
|
| 283 |
+
<CropPriceCard
|
| 284 |
+
key={crop}
|
| 285 |
+
crop={crop}
|
| 286 |
+
priceData={pricesData[crop]}
|
| 287 |
+
trends={trendsData[crop]}
|
| 288 |
+
loading={loading && !pricesData[crop]}
|
| 289 |
+
/>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
</Card>
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function SchemesCard({ schemes, loading, error, state }) {
|
| 297 |
+
if (loading) {
|
| 298 |
+
return (
|
| 299 |
+
<Card className="p-6">
|
| 300 |
+
<div className="flex items-center gap-2 mb-4">
|
| 301 |
+
<DocumentTextIcon className="h-5 w-5 text-green-600" />
|
| 302 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Government Schemes</h3>
|
| 303 |
+
</div>
|
| 304 |
+
<div className="flex justify-center py-8">
|
| 305 |
+
<LoadingSpinner size="md" message="Loading schemes..." />
|
| 306 |
+
</div>
|
| 307 |
+
</Card>
|
| 308 |
+
);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
if (error || !schemes?.length) {
|
| 312 |
+
return (
|
| 313 |
+
<Card className="p-6">
|
| 314 |
+
<div className="flex items-center gap-2 mb-4">
|
| 315 |
+
<DocumentTextIcon className="h-5 w-5 text-green-600" />
|
| 316 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Government Schemes</h3>
|
| 317 |
+
</div>
|
| 318 |
+
<p className="text-sm text-neutral-500">
|
| 319 |
+
{error || "No schemes available for your state"}
|
| 320 |
+
</p>
|
| 321 |
+
</Card>
|
| 322 |
+
);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
const displaySchemes = schemes.slice(0, 4);
|
| 326 |
+
|
| 327 |
+
return (
|
| 328 |
+
<Card className="p-6">
|
| 329 |
+
<div className="flex items-center justify-between mb-4">
|
| 330 |
+
<div className="flex items-center gap-2">
|
| 331 |
+
<DocumentTextIcon className="h-5 w-5 text-green-600" />
|
| 332 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Government Schemes</h3>
|
| 333 |
+
</div>
|
| 334 |
+
<Link
|
| 335 |
+
to={ROUTES.SCHEMES}
|
| 336 |
+
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
| 337 |
+
>
|
| 338 |
+
View All <ArrowRightIcon className="h-4 w-4" />
|
| 339 |
+
</Link>
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<p className="text-xs text-neutral-500 mb-4">
|
| 343 |
+
Available for {state}
|
| 344 |
+
</p>
|
| 345 |
+
|
| 346 |
+
<div className="space-y-3">
|
| 347 |
+
{displaySchemes.map((scheme) => (
|
| 348 |
+
<div
|
| 349 |
+
key={scheme.id}
|
| 350 |
+
className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg"
|
| 351 |
+
>
|
| 352 |
+
<h4 className="text-sm font-medium text-neutral-900 dark:text-white mb-1 line-clamp-1">
|
| 353 |
+
{scheme.scheme_name}
|
| 354 |
+
</h4>
|
| 355 |
+
<p className="text-xs text-neutral-500 line-clamp-2">
|
| 356 |
+
{scheme.description}
|
| 357 |
+
</p>
|
| 358 |
+
{scheme.benefit_amount && (
|
| 359 |
+
<p className="text-xs font-medium text-green-600 mt-1">
|
| 360 |
+
Benefit: {scheme.benefit_amount}
|
| 361 |
+
</p>
|
| 362 |
+
)}
|
| 363 |
+
</div>
|
| 364 |
+
))}
|
| 365 |
+
</div>
|
| 366 |
+
|
| 367 |
+
{schemes.length > 4 && (
|
| 368 |
+
<p className="text-xs text-neutral-400 text-center mt-4">
|
| 369 |
+
+{schemes.length - 4} more schemes available
|
| 370 |
+
</p>
|
| 371 |
+
)}
|
| 372 |
+
</Card>
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function ProfileEditSection({
|
| 377 |
+
user,
|
| 378 |
+
formData,
|
| 379 |
+
setFormData,
|
| 380 |
+
errors,
|
| 381 |
+
setErrors,
|
| 382 |
+
loading,
|
| 383 |
+
commodities,
|
| 384 |
+
commoditiesLoading,
|
| 385 |
+
selectedCrop,
|
| 386 |
+
setSelectedCrop,
|
| 387 |
+
onSubmit,
|
| 388 |
+
onAddCrop,
|
| 389 |
+
onRemoveCrop,
|
| 390 |
+
}) {
|
| 391 |
+
const handleNameChange = useCallback(
|
| 392 |
+
(e) => {
|
| 393 |
+
setFormData((prev) => ({ ...prev, name: e.target.value }));
|
| 394 |
+
setErrors((prev) => ({ ...prev, name: null }));
|
| 395 |
+
},
|
| 396 |
+
[setFormData, setErrors]
|
| 397 |
+
);
|
| 398 |
+
|
| 399 |
+
const availableCropOptions = commodities
|
| 400 |
+
.filter((crop) => !formData.crops.includes(crop))
|
| 401 |
+
.map((crop) => ({ value: crop, label: crop }));
|
| 402 |
+
|
| 403 |
+
return (
|
| 404 |
+
<Card className="p-6">
|
| 405 |
+
<div className="flex items-center gap-2 mb-4">
|
| 406 |
+
<PencilIcon className="h-5 w-5 text-primary-600" />
|
| 407 |
+
<h3 className="font-semibold text-neutral-900 dark:text-white">Edit Profile</h3>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<form onSubmit={onSubmit} className="space-y-5">
|
| 411 |
+
<Input
|
| 412 |
+
name="name"
|
| 413 |
+
label="Full Name"
|
| 414 |
+
type="text"
|
| 415 |
+
placeholder="Enter your full name"
|
| 416 |
+
value={formData.name}
|
| 417 |
+
onChange={handleNameChange}
|
| 418 |
+
error={errors.name}
|
| 419 |
+
required
|
| 420 |
+
maxLength={100}
|
| 421 |
+
disabled={loading}
|
| 422 |
+
/>
|
| 423 |
+
|
| 424 |
+
{/* Crops Selection */}
|
| 425 |
+
<div>
|
| 426 |
+
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
| 427 |
+
My Crops (Max 2)
|
| 428 |
+
</label>
|
| 429 |
+
|
| 430 |
+
{formData.crops.length > 0 && (
|
| 431 |
+
<div className="flex flex-wrap gap-2 mb-3">
|
| 432 |
+
{formData.crops.map((crop) => (
|
| 433 |
+
<div
|
| 434 |
+
key={crop}
|
| 435 |
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-50 text-primary-700 rounded-full text-sm font-medium"
|
| 436 |
+
>
|
| 437 |
+
<CheckIcon className="h-4 w-4" />
|
| 438 |
+
{crop}
|
| 439 |
+
<button
|
| 440 |
+
type="button"
|
| 441 |
+
onClick={() => onRemoveCrop(crop)}
|
| 442 |
+
className="ml-1 hover:text-primary-900 focus:outline-none"
|
| 443 |
+
disabled={loading}
|
| 444 |
+
>
|
| 445 |
+
<XMarkIcon className="h-4 w-4" />
|
| 446 |
+
</button>
|
| 447 |
+
</div>
|
| 448 |
+
))}
|
| 449 |
+
</div>
|
| 450 |
+
)}
|
| 451 |
+
|
| 452 |
+
{formData.crops.length < 2 && (
|
| 453 |
+
<div className="flex gap-2">
|
| 454 |
+
{commoditiesLoading ? (
|
| 455 |
+
<div className="flex-1 flex items-center justify-center py-2">
|
| 456 |
+
<LoadingSpinner size="sm" message="Loading crops..." />
|
| 457 |
+
</div>
|
| 458 |
+
) : (
|
| 459 |
+
<>
|
| 460 |
+
<div className="flex-1">
|
| 461 |
+
<Select
|
| 462 |
+
name="selectedCrop"
|
| 463 |
+
placeholder="Select a crop to add"
|
| 464 |
+
options={availableCropOptions}
|
| 465 |
+
value={selectedCrop}
|
| 466 |
+
onChange={(e) => setSelectedCrop(e.target.value)}
|
| 467 |
+
disabled={loading || availableCropOptions.length === 0}
|
| 468 |
+
/>
|
| 469 |
+
</div>
|
| 470 |
+
<Button
|
| 471 |
+
type="button"
|
| 472 |
+
variant="secondary"
|
| 473 |
+
onClick={onAddCrop}
|
| 474 |
+
disabled={loading || !selectedCrop}
|
| 475 |
+
>
|
| 476 |
+
Add
|
| 477 |
+
</Button>
|
| 478 |
+
</>
|
| 479 |
+
)}
|
| 480 |
+
</div>
|
| 481 |
+
)}
|
| 482 |
+
|
| 483 |
+
{formData.crops.length === 0 && (
|
| 484 |
+
<p className="mt-2 text-sm text-neutral-500">
|
| 485 |
+
Add crops to see personalized APMC prices above
|
| 486 |
+
</p>
|
| 487 |
+
)}
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<Button type="submit" variant="primary" fullWidth loading={loading}>
|
| 491 |
+
Save Changes
|
| 492 |
+
</Button>
|
| 493 |
+
</form>
|
| 494 |
+
</Card>
|
| 495 |
+
);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
// Main Component
|
| 499 |
+
function FarmerProfilePage() {
|
| 500 |
+
const navigate = useNavigate();
|
| 501 |
+
const { user, isAuthenticated, updateUser } = useAuth();
|
| 502 |
+
|
| 503 |
+
// Form state
|
| 504 |
+
const [formData, setFormData] = useState({ name: "", crops: [] });
|
| 505 |
+
const [errors, setErrors] = useState({});
|
| 506 |
+
const [loading, setLoading] = useState(false);
|
| 507 |
+
const [selectedCrop, setSelectedCrop] = useState("");
|
| 508 |
+
|
| 509 |
+
// Commodities
|
| 510 |
+
const [commodities, setCommodities] = useState([]);
|
| 511 |
+
const [commoditiesLoading, setCommoditiesLoading] = useState(true);
|
| 512 |
+
|
| 513 |
+
// Weather state
|
| 514 |
+
const [weather, setWeather] = useState(null);
|
| 515 |
+
const [weatherAlerts, setWeatherAlerts] = useState(null);
|
| 516 |
+
const [weatherLoading, setWeatherLoading] = useState(true);
|
| 517 |
+
const [weatherError, setWeatherError] = useState(null);
|
| 518 |
+
|
| 519 |
+
// APMC state
|
| 520 |
+
const [pricesData, setPricesData] = useState({});
|
| 521 |
+
const [trendsData, setTrendsData] = useState({});
|
| 522 |
+
const [pricesLoading, setPricesLoading] = useState(false);
|
| 523 |
+
|
| 524 |
+
// Schemes state
|
| 525 |
+
const [schemes, setSchemes] = useState([]);
|
| 526 |
+
const [schemesLoading, setSchemesLoading] = useState(true);
|
| 527 |
+
const [schemesError, setSchemesError] = useState(null);
|
| 528 |
+
|
| 529 |
+
// Redirect if not authenticated
|
| 530 |
+
useEffect(() => {
|
| 531 |
+
if (!isAuthenticated) {
|
| 532 |
+
navigate(ROUTES.LOGIN);
|
| 533 |
+
}
|
| 534 |
+
}, [isAuthenticated, navigate]);
|
| 535 |
+
|
| 536 |
+
// Initialize form with user data
|
| 537 |
+
useEffect(() => {
|
| 538 |
+
if (user) {
|
| 539 |
+
setFormData({
|
| 540 |
+
name: user.name || "",
|
| 541 |
+
crops: user.crops || [],
|
| 542 |
+
});
|
| 543 |
+
}
|
| 544 |
+
}, [user]);
|
| 545 |
+
|
| 546 |
+
// Fetch commodities
|
| 547 |
+
useEffect(() => {
|
| 548 |
+
async function fetchCommodities() {
|
| 549 |
+
try {
|
| 550 |
+
const data = await getCommodities();
|
| 551 |
+
const commodityList = data.commodities?.map((c) => c.commodity) || [];
|
| 552 |
+
setCommodities(commodityList);
|
| 553 |
+
} catch (error) {
|
| 554 |
+
console.error("Failed to fetch commodities:", error);
|
| 555 |
+
} finally {
|
| 556 |
+
setCommoditiesLoading(false);
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
fetchCommodities();
|
| 560 |
+
}, []);
|
| 561 |
+
|
| 562 |
+
// Fetch weather based on user location
|
| 563 |
+
useEffect(() => {
|
| 564 |
+
if (!user?.state || !user?.district || !user?.taluka) return;
|
| 565 |
+
|
| 566 |
+
async function fetchWeather() {
|
| 567 |
+
setWeatherLoading(true);
|
| 568 |
+
setWeatherError(null);
|
| 569 |
+
try {
|
| 570 |
+
const location = {
|
| 571 |
+
state: user.state,
|
| 572 |
+
district: user.district,
|
| 573 |
+
taluka: user.taluka,
|
| 574 |
+
};
|
| 575 |
+
const [forecastData, alertsData] = await Promise.all([
|
| 576 |
+
getForecast(location),
|
| 577 |
+
getAlerts(location).catch(() => null),
|
| 578 |
+
]);
|
| 579 |
+
setWeather(forecastData);
|
| 580 |
+
setWeatherAlerts(alertsData);
|
| 581 |
+
} catch (error) {
|
| 582 |
+
console.error("Failed to fetch weather:", error);
|
| 583 |
+
setWeatherError("Unable to load weather data");
|
| 584 |
+
} finally {
|
| 585 |
+
setWeatherLoading(false);
|
| 586 |
+
}
|
| 587 |
+
}
|
| 588 |
+
fetchWeather();
|
| 589 |
+
}, [user?.state, user?.district, user?.taluka]);
|
| 590 |
+
|
| 591 |
+
// Fetch APMC prices for user's crops
|
| 592 |
+
useEffect(() => {
|
| 593 |
+
const crops = user?.crops || [];
|
| 594 |
+
if (crops.length === 0) {
|
| 595 |
+
setPricesData({});
|
| 596 |
+
setTrendsData({});
|
| 597 |
+
return;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
async function fetchPricesForCrops() {
|
| 601 |
+
setPricesLoading(true);
|
| 602 |
+
const newPrices = {};
|
| 603 |
+
const newTrends = {};
|
| 604 |
+
|
| 605 |
+
await Promise.all(
|
| 606 |
+
crops.map(async (crop) => {
|
| 607 |
+
try {
|
| 608 |
+
const [priceResult, trendResult] = await Promise.all([
|
| 609 |
+
getPrices({ commodity: crop, state: user.state, limit: 5 }),
|
| 610 |
+
getTrends(crop, { state: user.state, days: 7 }).catch(() => null),
|
| 611 |
+
]);
|
| 612 |
+
newPrices[crop] = priceResult;
|
| 613 |
+
if (trendResult) newTrends[crop] = trendResult;
|
| 614 |
+
} catch (error) {
|
| 615 |
+
console.error(`Failed to fetch prices for ${crop}:`, error);
|
| 616 |
+
}
|
| 617 |
+
})
|
| 618 |
+
);
|
| 619 |
+
|
| 620 |
+
setPricesData(newPrices);
|
| 621 |
+
setTrendsData(newTrends);
|
| 622 |
+
setPricesLoading(false);
|
| 623 |
+
}
|
| 624 |
+
fetchPricesForCrops();
|
| 625 |
+
}, [user?.crops, user?.state]);
|
| 626 |
+
|
| 627 |
+
// Fetch schemes for user's state
|
| 628 |
+
useEffect(() => {
|
| 629 |
+
if (!user?.state) return;
|
| 630 |
+
|
| 631 |
+
async function fetchSchemes() {
|
| 632 |
+
setSchemesLoading(true);
|
| 633 |
+
setSchemesError(null);
|
| 634 |
+
try {
|
| 635 |
+
const data = await getSchemesByState(user.state);
|
| 636 |
+
setSchemes(data.schemes || []);
|
| 637 |
+
} catch (error) {
|
| 638 |
+
console.error("Failed to fetch schemes:", error);
|
| 639 |
+
setSchemesError("Unable to load schemes");
|
| 640 |
+
} finally {
|
| 641 |
+
setSchemesLoading(false);
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
fetchSchemes();
|
| 645 |
+
}, [user?.state]);
|
| 646 |
+
|
| 647 |
+
// Handlers
|
| 648 |
+
const handleAddCrop = useCallback(() => {
|
| 649 |
+
if (!selectedCrop) return;
|
| 650 |
+
if (formData.crops.length >= 2) {
|
| 651 |
+
toast.error("Maximum 2 crops allowed");
|
| 652 |
+
return;
|
| 653 |
+
}
|
| 654 |
+
if (formData.crops.includes(selectedCrop)) {
|
| 655 |
+
toast.error("Crop already added");
|
| 656 |
+
return;
|
| 657 |
+
}
|
| 658 |
+
setFormData((prev) => ({
|
| 659 |
+
...prev,
|
| 660 |
+
crops: [...prev.crops, selectedCrop],
|
| 661 |
+
}));
|
| 662 |
+
setSelectedCrop("");
|
| 663 |
+
}, [selectedCrop, formData.crops]);
|
| 664 |
+
|
| 665 |
+
const handleRemoveCrop = useCallback((cropToRemove) => {
|
| 666 |
+
setFormData((prev) => ({
|
| 667 |
+
...prev,
|
| 668 |
+
crops: prev.crops.filter((crop) => crop !== cropToRemove),
|
| 669 |
+
}));
|
| 670 |
+
}, []);
|
| 671 |
+
|
| 672 |
+
const handleSubmit = useCallback(
|
| 673 |
+
async (e) => {
|
| 674 |
+
e.preventDefault();
|
| 675 |
+
if (!formData.name || !formData.name.trim()) {
|
| 676 |
+
setErrors({ name: "Name is required" });
|
| 677 |
+
return;
|
| 678 |
+
}
|
| 679 |
+
if (formData.name.trim().length < 2) {
|
| 680 |
+
setErrors({ name: "Name must be at least 2 characters" });
|
| 681 |
+
return;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
setLoading(true);
|
| 685 |
+
try {
|
| 686 |
+
const updatedUser = await updateProfile(user.mobile_number, {
|
| 687 |
+
name: formData.name.trim(),
|
| 688 |
+
crops: formData.crops,
|
| 689 |
+
});
|
| 690 |
+
updateUser(updatedUser);
|
| 691 |
+
toast.success("Profile updated successfully");
|
| 692 |
+
} catch (error) {
|
| 693 |
+
toast.error(error.message || "Failed to update profile");
|
| 694 |
+
} finally {
|
| 695 |
+
setLoading(false);
|
| 696 |
+
}
|
| 697 |
+
},
|
| 698 |
+
[formData, user, updateUser]
|
| 699 |
+
);
|
| 700 |
+
|
| 701 |
+
if (!isAuthenticated || !user) {
|
| 702 |
+
return (
|
| 703 |
+
<div className="min-h-[80vh] flex items-center justify-center">
|
| 704 |
+
<LoadingSpinner size="lg" message="Loading..." />
|
| 705 |
+
</div>
|
| 706 |
+
);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
return (
|
| 710 |
+
<motion.div
|
| 711 |
+
variants={containerVariants}
|
| 712 |
+
initial="hidden"
|
| 713 |
+
animate="show"
|
| 714 |
+
className="max-w-6xl mx-auto px-4 py-8"
|
| 715 |
+
>
|
| 716 |
+
{/* Header */}
|
| 717 |
+
<motion.div variants={itemVariants} className="mb-8">
|
| 718 |
+
<div className="flex items-center gap-4">
|
| 719 |
+
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
| 720 |
+
<UserIcon className="h-8 w-8 text-primary-600" />
|
| 721 |
+
</div>
|
| 722 |
+
<div>
|
| 723 |
+
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
| 724 |
+
Welcome, {user.name?.split(" ")[0]}
|
| 725 |
+
</h1>
|
| 726 |
+
<div className="flex items-center gap-4 mt-1">
|
| 727 |
+
<div className="flex items-center gap-1 text-neutral-500 text-sm">
|
| 728 |
+
<PhoneIcon className="h-4 w-4" />
|
| 729 |
+
<span>{user.mobile_number}</span>
|
| 730 |
+
</div>
|
| 731 |
+
<div className="flex items-center gap-1 text-neutral-500 text-sm">
|
| 732 |
+
<MapPinIcon className="h-4 w-4" />
|
| 733 |
+
<span>
|
| 734 |
+
{user.taluka}, {user.district}, {user.state}
|
| 735 |
+
</span>
|
| 736 |
+
</div>
|
| 737 |
+
</div>
|
| 738 |
+
</div>
|
| 739 |
+
</div>
|
| 740 |
+
</motion.div>
|
| 741 |
+
|
| 742 |
+
{/* Dashboard Grid */}
|
| 743 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 744 |
+
{/* Left Column - Weather & Prices */}
|
| 745 |
+
<div className="lg:col-span-2 space-y-6">
|
| 746 |
+
<motion.div variants={itemVariants}>
|
| 747 |
+
<WeatherCard
|
| 748 |
+
weather={weather}
|
| 749 |
+
alerts={weatherAlerts}
|
| 750 |
+
loading={weatherLoading}
|
| 751 |
+
error={weatherError}
|
| 752 |
+
location={{
|
| 753 |
+
taluka: user.taluka,
|
| 754 |
+
district: user.district,
|
| 755 |
+
state: user.state,
|
| 756 |
+
}}
|
| 757 |
+
/>
|
| 758 |
+
</motion.div>
|
| 759 |
+
|
| 760 |
+
<motion.div variants={itemVariants}>
|
| 761 |
+
<APMCPricesCard
|
| 762 |
+
crops={user.crops || []}
|
| 763 |
+
pricesData={pricesData}
|
| 764 |
+
trendsData={trendsData}
|
| 765 |
+
loading={pricesLoading}
|
| 766 |
+
/>
|
| 767 |
+
</motion.div>
|
| 768 |
+
|
| 769 |
+
<motion.div variants={itemVariants}>
|
| 770 |
+
<SchemesCard
|
| 771 |
+
schemes={schemes}
|
| 772 |
+
loading={schemesLoading}
|
| 773 |
+
error={schemesError}
|
| 774 |
+
state={user.state}
|
| 775 |
+
/>
|
| 776 |
+
</motion.div>
|
| 777 |
+
</div>
|
| 778 |
+
|
| 779 |
+
{/* Right Column - Profile Edit */}
|
| 780 |
+
<div className="space-y-6">
|
| 781 |
+
<motion.div variants={itemVariants}>
|
| 782 |
+
<ProfileEditSection
|
| 783 |
+
user={user}
|
| 784 |
+
formData={formData}
|
| 785 |
+
setFormData={setFormData}
|
| 786 |
+
errors={errors}
|
| 787 |
+
setErrors={setErrors}
|
| 788 |
+
loading={loading}
|
| 789 |
+
commodities={commodities}
|
| 790 |
+
commoditiesLoading={commoditiesLoading}
|
| 791 |
+
selectedCrop={selectedCrop}
|
| 792 |
+
setSelectedCrop={setSelectedCrop}
|
| 793 |
+
onSubmit={handleSubmit}
|
| 794 |
+
onAddCrop={handleAddCrop}
|
| 795 |
+
onRemoveCrop={handleRemoveCrop}
|
| 796 |
+
/>
|
| 797 |
+
</motion.div>
|
| 798 |
+
|
| 799 |
+
{/* Account Info */}
|
| 800 |
+
<motion.div variants={itemVariants}>
|
| 801 |
+
<Card className="p-4">
|
| 802 |
+
<p className="text-xs text-neutral-500 text-center">
|
| 803 |
+
Account created on{" "}
|
| 804 |
+
{new Date(user.created_at).toLocaleDateString("en-IN", {
|
| 805 |
+
year: "numeric",
|
| 806 |
+
month: "long",
|
| 807 |
+
day: "numeric",
|
| 808 |
+
})}
|
| 809 |
+
</p>
|
| 810 |
+
</Card>
|
| 811 |
+
</motion.div>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
</motion.div>
|
| 815 |
+
);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
export default FarmerProfilePage;
|
frontend/src/pages/HomePage.jsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
*/
|
| 9 |
|
| 10 |
import { useState, useCallback, useMemo } from "react";
|
| 11 |
-
import { useNavigate } from "react-router-dom";
|
| 12 |
import { motion } from "framer-motion";
|
| 13 |
import {
|
| 14 |
CameraIcon,
|
|
@@ -21,12 +21,14 @@ import {
|
|
| 21 |
SignalIcon,
|
| 22 |
SignalSlashIcon,
|
| 23 |
DocumentTextIcon,
|
|
|
|
| 24 |
} from "@heroicons/react/24/outline";
|
| 25 |
import { Card } from "@components/common";
|
| 26 |
import { ROUTES } from "@utils/constants";
|
| 27 |
import { getRecentActivities, ACTIVITY_TYPES } from "@utils/activityTracker";
|
| 28 |
import { timeAgo } from "@utils/helpers";
|
| 29 |
import useNetworkStatus from "@hooks/useNetworkStatus";
|
|
|
|
| 30 |
import PropTypes from "prop-types";
|
| 31 |
|
| 32 |
// ---------------------------------------------------------------------------
|
|
@@ -128,15 +130,18 @@ const itemVariants = {
|
|
| 128 |
// ---------------------------------------------------------------------------
|
| 129 |
|
| 130 |
function GreetingBanner() {
|
|
|
|
| 131 |
const hour = new Date().getHours();
|
| 132 |
let greeting = "Good morning";
|
| 133 |
if (hour >= 12 && hour < 17) greeting = "Good afternoon";
|
| 134 |
else if (hour >= 17) greeting = "Good evening";
|
| 135 |
|
|
|
|
|
|
|
| 136 |
return (
|
| 137 |
<motion.div variants={itemVariants}>
|
| 138 |
<h1 className="text-3xl sm:text-4xl font-display font-bold text-neutral-900 mb-2">
|
| 139 |
-
{greeting}
|
| 140 |
</h1>
|
| 141 |
<p className="text-neutral-600 text-lg max-w-xl">
|
| 142 |
AI-powered crop disease detection, weather forecasting, and market price
|
|
@@ -249,6 +254,40 @@ function NetworkBanner({ isOnline }) {
|
|
| 249 |
);
|
| 250 |
}
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
NetworkBanner.propTypes = {
|
| 253 |
isOnline: PropTypes.bool.isRequired,
|
| 254 |
};
|
|
@@ -360,6 +399,7 @@ function HomePage() {
|
|
| 360 |
className="space-y-8 pb-8"
|
| 361 |
>
|
| 362 |
<NetworkBanner isOnline={isOnline} />
|
|
|
|
| 363 |
<GreetingBanner />
|
| 364 |
<QuickSearch onSearch={handleSearch} />
|
| 365 |
<QuickStats />
|
|
|
|
| 8 |
*/
|
| 9 |
|
| 10 |
import { useState, useCallback, useMemo } from "react";
|
| 11 |
+
import { useNavigate, Link } from "react-router-dom";
|
| 12 |
import { motion } from "framer-motion";
|
| 13 |
import {
|
| 14 |
CameraIcon,
|
|
|
|
| 21 |
SignalIcon,
|
| 22 |
SignalSlashIcon,
|
| 23 |
DocumentTextIcon,
|
| 24 |
+
ExclamationTriangleIcon,
|
| 25 |
} from "@heroicons/react/24/outline";
|
| 26 |
import { Card } from "@components/common";
|
| 27 |
import { ROUTES } from "@utils/constants";
|
| 28 |
import { getRecentActivities, ACTIVITY_TYPES } from "@utils/activityTracker";
|
| 29 |
import { timeAgo } from "@utils/helpers";
|
| 30 |
import useNetworkStatus from "@hooks/useNetworkStatus";
|
| 31 |
+
import { useAuth } from "@context/AuthContext";
|
| 32 |
import PropTypes from "prop-types";
|
| 33 |
|
| 34 |
// ---------------------------------------------------------------------------
|
|
|
|
| 130 |
// ---------------------------------------------------------------------------
|
| 131 |
|
| 132 |
function GreetingBanner() {
|
| 133 |
+
const { isAuthenticated, user } = useAuth();
|
| 134 |
const hour = new Date().getHours();
|
| 135 |
let greeting = "Good morning";
|
| 136 |
if (hour >= 12 && hour < 17) greeting = "Good afternoon";
|
| 137 |
else if (hour >= 17) greeting = "Good evening";
|
| 138 |
|
| 139 |
+
const firstName = user?.name?.split(" ")[0];
|
| 140 |
+
|
| 141 |
return (
|
| 142 |
<motion.div variants={itemVariants}>
|
| 143 |
<h1 className="text-3xl sm:text-4xl font-display font-bold text-neutral-900 mb-2">
|
| 144 |
+
{greeting}{isAuthenticated && firstName ? `, ${firstName}` : ""}
|
| 145 |
</h1>
|
| 146 |
<p className="text-neutral-600 text-lg max-w-xl">
|
| 147 |
AI-powered crop disease detection, weather forecasting, and market price
|
|
|
|
| 254 |
);
|
| 255 |
}
|
| 256 |
|
| 257 |
+
function CropsAlertBanner() {
|
| 258 |
+
const { isAuthenticated, user } = useAuth();
|
| 259 |
+
|
| 260 |
+
// Show alert only for logged-in users without crops
|
| 261 |
+
if (!isAuthenticated || !user) return null;
|
| 262 |
+
if (user.crops && user.crops.length > 0) return null;
|
| 263 |
+
|
| 264 |
+
return (
|
| 265 |
+
<motion.div
|
| 266 |
+
initial={{ opacity: 0, height: 0 }}
|
| 267 |
+
animate={{ opacity: 1, height: "auto" }}
|
| 268 |
+
exit={{ opacity: 0, height: 0 }}
|
| 269 |
+
className="rounded-lg bg-amber-50 border border-amber-200 p-4 flex items-start gap-3"
|
| 270 |
+
>
|
| 271 |
+
<ExclamationTriangleIcon className="h-6 w-6 text-amber-600 shrink-0 mt-0.5" />
|
| 272 |
+
<div className="flex-1">
|
| 273 |
+
<h3 className="text-sm font-semibold text-amber-800">
|
| 274 |
+
Complete Your Profile
|
| 275 |
+
</h3>
|
| 276 |
+
<p className="text-sm text-amber-700 mt-1">
|
| 277 |
+
Add your crops to get personalized APMC price alerts and farming recommendations.
|
| 278 |
+
</p>
|
| 279 |
+
<Link
|
| 280 |
+
to={ROUTES.PROFILE}
|
| 281 |
+
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-700 hover:text-amber-900"
|
| 282 |
+
>
|
| 283 |
+
Add Crops Now
|
| 284 |
+
<ArrowRightIcon className="h-4 w-4" />
|
| 285 |
+
</Link>
|
| 286 |
+
</div>
|
| 287 |
+
</motion.div>
|
| 288 |
+
);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
NetworkBanner.propTypes = {
|
| 292 |
isOnline: PropTypes.bool.isRequired,
|
| 293 |
};
|
|
|
|
| 399 |
className="space-y-8 pb-8"
|
| 400 |
>
|
| 401 |
<NetworkBanner isOnline={isOnline} />
|
| 402 |
+
<CropsAlertBanner />
|
| 403 |
<GreetingBanner />
|
| 404 |
<QuickSearch onSearch={handleSearch} />
|
| 405 |
<QuickStats />
|
frontend/src/pages/LoginPage.jsx
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoginPage - User login with mobile number and OTP.
|
| 3 |
+
*
|
| 4 |
+
* Flow:
|
| 5 |
+
* 1. User enters mobile number
|
| 6 |
+
* 2. User clicks "Request OTP" (dummy - always succeeds)
|
| 7 |
+
* 3. User enters OTP (any 4-6 digit code works)
|
| 8 |
+
* 4. User clicks "Login" to authenticate
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useState, useCallback } from "react";
|
| 12 |
+
import { useNavigate, Link } from "react-router-dom";
|
| 13 |
+
import { motion } from "framer-motion";
|
| 14 |
+
import toast from "react-hot-toast";
|
| 15 |
+
import { PhoneIcon, KeyIcon } from "@heroicons/react/24/outline";
|
| 16 |
+
|
| 17 |
+
import { Input, Button, Card } from "@components/common";
|
| 18 |
+
import { useAuth } from "@context/AuthContext";
|
| 19 |
+
import { login, requestOtp } from "@services/authApi";
|
| 20 |
+
import { ROUTES } from "@utils/constants";
|
| 21 |
+
|
| 22 |
+
function LoginPage() {
|
| 23 |
+
const navigate = useNavigate();
|
| 24 |
+
const { loginSuccess, setError, clearError } = useAuth();
|
| 25 |
+
|
| 26 |
+
const [formData, setFormData] = useState({
|
| 27 |
+
mobileNumber: "",
|
| 28 |
+
otp: "",
|
| 29 |
+
});
|
| 30 |
+
const [errors, setErrors] = useState({});
|
| 31 |
+
const [otpSent, setOtpSent] = useState(false);
|
| 32 |
+
const [loading, setLoading] = useState(false);
|
| 33 |
+
const [otpLoading, setOtpLoading] = useState(false);
|
| 34 |
+
|
| 35 |
+
const validateMobileNumber = useCallback((value) => {
|
| 36 |
+
if (!value) return "Mobile number is required";
|
| 37 |
+
if (!/^\d{10,15}$/.test(value)) return "Enter a valid 10-15 digit mobile number";
|
| 38 |
+
return null;
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
const validateOtp = useCallback((value) => {
|
| 42 |
+
if (!value) return "OTP is required";
|
| 43 |
+
if (!/^\d{4,6}$/.test(value)) return "OTP must be 4-6 digits";
|
| 44 |
+
return null;
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
const handleChange = useCallback((e) => {
|
| 48 |
+
const { name, value } = e.target;
|
| 49 |
+
|
| 50 |
+
// Only allow digits for mobile number and OTP
|
| 51 |
+
if ((name === "mobileNumber" || name === "otp") && value && !/^\d*$/.test(value)) {
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
setFormData((prev) => ({ ...prev, [name]: value }));
|
| 56 |
+
setErrors((prev) => ({ ...prev, [name]: null }));
|
| 57 |
+
clearError();
|
| 58 |
+
}, [clearError]);
|
| 59 |
+
|
| 60 |
+
const handleRequestOtp = useCallback(async () => {
|
| 61 |
+
const mobileError = validateMobileNumber(formData.mobileNumber);
|
| 62 |
+
if (mobileError) {
|
| 63 |
+
setErrors({ mobileNumber: mobileError });
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
setOtpLoading(true);
|
| 68 |
+
try {
|
| 69 |
+
const response = await requestOtp(formData.mobileNumber);
|
| 70 |
+
if (response.success) {
|
| 71 |
+
setOtpSent(true);
|
| 72 |
+
toast.success("OTP sent successfully (use any 4-6 digit code)");
|
| 73 |
+
}
|
| 74 |
+
} catch (error) {
|
| 75 |
+
const message = error.message || "Failed to send OTP";
|
| 76 |
+
toast.error(message);
|
| 77 |
+
setError(message);
|
| 78 |
+
} finally {
|
| 79 |
+
setOtpLoading(false);
|
| 80 |
+
}
|
| 81 |
+
}, [formData.mobileNumber, validateMobileNumber, setError]);
|
| 82 |
+
|
| 83 |
+
const handleSubmit = useCallback(async (e) => {
|
| 84 |
+
e.preventDefault();
|
| 85 |
+
|
| 86 |
+
const newErrors = {
|
| 87 |
+
mobileNumber: validateMobileNumber(formData.mobileNumber),
|
| 88 |
+
otp: validateOtp(formData.otp),
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const hasErrors = Object.values(newErrors).some(Boolean);
|
| 92 |
+
if (hasErrors) {
|
| 93 |
+
setErrors(newErrors);
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
setLoading(true);
|
| 98 |
+
try {
|
| 99 |
+
const response = await login(formData.mobileNumber, formData.otp);
|
| 100 |
+
if (response.success) {
|
| 101 |
+
loginSuccess(response.user, response.token);
|
| 102 |
+
toast.success(`Welcome back, ${response.user.name}!`);
|
| 103 |
+
navigate(ROUTES.HOME);
|
| 104 |
+
}
|
| 105 |
+
} catch (error) {
|
| 106 |
+
const message = error.message || "Login failed";
|
| 107 |
+
toast.error(message);
|
| 108 |
+
setError(message);
|
| 109 |
+
} finally {
|
| 110 |
+
setLoading(false);
|
| 111 |
+
}
|
| 112 |
+
}, [formData, validateMobileNumber, validateOtp, loginSuccess, navigate, setError]);
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="min-h-[80vh] flex items-center justify-center px-4 py-8">
|
| 116 |
+
<motion.div
|
| 117 |
+
initial={{ opacity: 0, y: 20 }}
|
| 118 |
+
animate={{ opacity: 1, y: 0 }}
|
| 119 |
+
transition={{ duration: 0.4 }}
|
| 120 |
+
className="w-full max-w-md"
|
| 121 |
+
>
|
| 122 |
+
<Card className="p-6 sm:p-8">
|
| 123 |
+
<div className="text-center mb-6">
|
| 124 |
+
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
| 125 |
+
Welcome Back
|
| 126 |
+
</h1>
|
| 127 |
+
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
| 128 |
+
Login with your mobile number
|
| 129 |
+
</p>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<form onSubmit={handleSubmit} className="space-y-5">
|
| 133 |
+
<Input
|
| 134 |
+
name="mobileNumber"
|
| 135 |
+
label="Mobile Number"
|
| 136 |
+
type="tel"
|
| 137 |
+
placeholder="Enter your 10-digit mobile number"
|
| 138 |
+
value={formData.mobileNumber}
|
| 139 |
+
onChange={handleChange}
|
| 140 |
+
error={errors.mobileNumber}
|
| 141 |
+
required
|
| 142 |
+
maxLength={15}
|
| 143 |
+
leadingIcon={<PhoneIcon className="h-5 w-5" />}
|
| 144 |
+
disabled={loading}
|
| 145 |
+
/>
|
| 146 |
+
|
| 147 |
+
{!otpSent ? (
|
| 148 |
+
<Button
|
| 149 |
+
type="button"
|
| 150 |
+
variant="secondary"
|
| 151 |
+
fullWidth
|
| 152 |
+
loading={otpLoading}
|
| 153 |
+
onClick={handleRequestOtp}
|
| 154 |
+
>
|
| 155 |
+
Request OTP
|
| 156 |
+
</Button>
|
| 157 |
+
) : (
|
| 158 |
+
<>
|
| 159 |
+
<Input
|
| 160 |
+
name="otp"
|
| 161 |
+
label="OTP"
|
| 162 |
+
type="text"
|
| 163 |
+
placeholder="Enter OTP (any 4-6 digits)"
|
| 164 |
+
value={formData.otp}
|
| 165 |
+
onChange={handleChange}
|
| 166 |
+
error={errors.otp}
|
| 167 |
+
required
|
| 168 |
+
maxLength={6}
|
| 169 |
+
leadingIcon={<KeyIcon className="h-5 w-5" />}
|
| 170 |
+
helperText="For demo: any 4-6 digit code works"
|
| 171 |
+
disabled={loading}
|
| 172 |
+
/>
|
| 173 |
+
|
| 174 |
+
<Button
|
| 175 |
+
type="submit"
|
| 176 |
+
variant="primary"
|
| 177 |
+
fullWidth
|
| 178 |
+
loading={loading}
|
| 179 |
+
>
|
| 180 |
+
Login
|
| 181 |
+
</Button>
|
| 182 |
+
|
| 183 |
+
<button
|
| 184 |
+
type="button"
|
| 185 |
+
onClick={handleRequestOtp}
|
| 186 |
+
disabled={otpLoading}
|
| 187 |
+
className="w-full text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
| 188 |
+
>
|
| 189 |
+
Resend OTP
|
| 190 |
+
</button>
|
| 191 |
+
</>
|
| 192 |
+
)}
|
| 193 |
+
</form>
|
| 194 |
+
|
| 195 |
+
<div className="mt-6 text-center">
|
| 196 |
+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
| 197 |
+
{"Don't have an account? "}
|
| 198 |
+
<Link
|
| 199 |
+
to={ROUTES.SIGNUP}
|
| 200 |
+
className="font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
| 201 |
+
>
|
| 202 |
+
Sign up
|
| 203 |
+
</Link>
|
| 204 |
+
</p>
|
| 205 |
+
</div>
|
| 206 |
+
</Card>
|
| 207 |
+
</motion.div>
|
| 208 |
+
</div>
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
export default LoginPage;
|
frontend/src/pages/SchemesPage.jsx
CHANGED
|
@@ -161,7 +161,7 @@ function SchemesPage() {
|
|
| 161 |
{/* Empty State */}
|
| 162 |
{!loading && !error && filteredSchemes.length === 0 && (
|
| 163 |
<EmptyState
|
| 164 |
-
icon={ExclamationTriangleIcon}
|
| 165 |
title="No schemes found"
|
| 166 |
description="Try adjusting your filters or search query."
|
| 167 |
/>
|
|
|
|
| 161 |
{/* Empty State */}
|
| 162 |
{!loading && !error && filteredSchemes.length === 0 && (
|
| 163 |
<EmptyState
|
| 164 |
+
icon={<ExclamationTriangleIcon className="h-10 w-10" />}
|
| 165 |
title="No schemes found"
|
| 166 |
description="Try adjusting your filters or search query."
|
| 167 |
/>
|
frontend/src/pages/SignupPage.jsx
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SignupPage - User registration with profile details.
|
| 3 |
+
*
|
| 4 |
+
* Collects:
|
| 5 |
+
* - Mobile number (unique)
|
| 6 |
+
* - Name
|
| 7 |
+
* - State (dropdown)
|
| 8 |
+
* - District (dropdown, filtered by state)
|
| 9 |
+
* - Taluka (dropdown, filtered by district)
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { useState, useCallback, useEffect } from "react";
|
| 13 |
+
import { useNavigate, Link } from "react-router-dom";
|
| 14 |
+
import { motion } from "framer-motion";
|
| 15 |
+
import toast from "react-hot-toast";
|
| 16 |
+
import { PhoneIcon, UserIcon } from "@heroicons/react/24/outline";
|
| 17 |
+
|
| 18 |
+
import { Input, Button, Card, Select, LoadingSpinner } from "@components/common";
|
| 19 |
+
import { useAuth } from "@context/AuthContext";
|
| 20 |
+
import { signup, getLocationData } from "@services/authApi";
|
| 21 |
+
import { ROUTES } from "@utils/constants";
|
| 22 |
+
|
| 23 |
+
function SignupPage() {
|
| 24 |
+
const navigate = useNavigate();
|
| 25 |
+
const { loginSuccess, setError, clearError } = useAuth();
|
| 26 |
+
|
| 27 |
+
const [formData, setFormData] = useState({
|
| 28 |
+
mobileNumber: "",
|
| 29 |
+
name: "",
|
| 30 |
+
state: "",
|
| 31 |
+
district: "",
|
| 32 |
+
taluka: "",
|
| 33 |
+
});
|
| 34 |
+
const [errors, setErrors] = useState({});
|
| 35 |
+
const [loading, setLoading] = useState(false);
|
| 36 |
+
const [locationLoading, setLocationLoading] = useState(true);
|
| 37 |
+
const [locationData, setLocationData] = useState({
|
| 38 |
+
states: [],
|
| 39 |
+
districts: {},
|
| 40 |
+
talukas: {},
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
// Fetch location data on mount
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
async function fetchLocationData() {
|
| 46 |
+
try {
|
| 47 |
+
const data = await getLocationData();
|
| 48 |
+
setLocationData(data);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.error("Failed to fetch location data:", error);
|
| 51 |
+
toast.error("Failed to load location data");
|
| 52 |
+
} finally {
|
| 53 |
+
setLocationLoading(false);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
fetchLocationData();
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
// Get available districts based on selected state
|
| 60 |
+
const availableDistricts = formData.state
|
| 61 |
+
? locationData.districts[formData.state] || []
|
| 62 |
+
: [];
|
| 63 |
+
|
| 64 |
+
// Get available talukas based on selected district
|
| 65 |
+
const availableTalukas = formData.district
|
| 66 |
+
? locationData.talukas[formData.district] || []
|
| 67 |
+
: [];
|
| 68 |
+
|
| 69 |
+
const validateField = useCallback((name, value) => {
|
| 70 |
+
switch (name) {
|
| 71 |
+
case "mobileNumber":
|
| 72 |
+
if (!value) return "Mobile number is required";
|
| 73 |
+
if (!/^\d{10,15}$/.test(value)) return "Enter a valid 10-15 digit mobile number";
|
| 74 |
+
return null;
|
| 75 |
+
case "name":
|
| 76 |
+
if (!value || !value.trim()) return "Name is required";
|
| 77 |
+
if (value.trim().length < 2) return "Name must be at least 2 characters";
|
| 78 |
+
return null;
|
| 79 |
+
case "state":
|
| 80 |
+
if (!value) return "State is required";
|
| 81 |
+
return null;
|
| 82 |
+
case "district":
|
| 83 |
+
if (!value) return "District is required";
|
| 84 |
+
return null;
|
| 85 |
+
case "taluka":
|
| 86 |
+
if (!value) return "Taluka is required";
|
| 87 |
+
return null;
|
| 88 |
+
default:
|
| 89 |
+
return null;
|
| 90 |
+
}
|
| 91 |
+
}, []);
|
| 92 |
+
|
| 93 |
+
const handleChange = useCallback((e) => {
|
| 94 |
+
const { name, value } = e.target;
|
| 95 |
+
|
| 96 |
+
// Only allow digits for mobile number
|
| 97 |
+
if (name === "mobileNumber" && value && !/^\d*$/.test(value)) {
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
setFormData((prev) => {
|
| 102 |
+
const updated = { ...prev, [name]: value };
|
| 103 |
+
|
| 104 |
+
// Reset dependent fields when parent changes
|
| 105 |
+
if (name === "state") {
|
| 106 |
+
updated.district = "";
|
| 107 |
+
updated.taluka = "";
|
| 108 |
+
} else if (name === "district") {
|
| 109 |
+
updated.taluka = "";
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return updated;
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
setErrors((prev) => ({ ...prev, [name]: null }));
|
| 116 |
+
clearError();
|
| 117 |
+
}, [clearError]);
|
| 118 |
+
|
| 119 |
+
const handleSubmit = useCallback(async (e) => {
|
| 120 |
+
e.preventDefault();
|
| 121 |
+
|
| 122 |
+
// Validate all fields
|
| 123 |
+
const newErrors = {
|
| 124 |
+
mobileNumber: validateField("mobileNumber", formData.mobileNumber),
|
| 125 |
+
name: validateField("name", formData.name),
|
| 126 |
+
state: validateField("state", formData.state),
|
| 127 |
+
district: validateField("district", formData.district),
|
| 128 |
+
taluka: validateField("taluka", formData.taluka),
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const hasErrors = Object.values(newErrors).some(Boolean);
|
| 132 |
+
if (hasErrors) {
|
| 133 |
+
setErrors(newErrors);
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
setLoading(true);
|
| 138 |
+
try {
|
| 139 |
+
const response = await signup({
|
| 140 |
+
mobileNumber: formData.mobileNumber,
|
| 141 |
+
name: formData.name.trim(),
|
| 142 |
+
state: formData.state,
|
| 143 |
+
district: formData.district,
|
| 144 |
+
taluka: formData.taluka,
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
if (response.success) {
|
| 148 |
+
loginSuccess(response.user, response.token);
|
| 149 |
+
toast.success(`Welcome, ${response.user.name}! Account created successfully.`);
|
| 150 |
+
navigate(ROUTES.HOME);
|
| 151 |
+
}
|
| 152 |
+
} catch (error) {
|
| 153 |
+
const message = error.message || "Signup failed";
|
| 154 |
+
toast.error(message);
|
| 155 |
+
setError(message);
|
| 156 |
+
} finally {
|
| 157 |
+
setLoading(false);
|
| 158 |
+
}
|
| 159 |
+
}, [formData, validateField, loginSuccess, navigate, setError]);
|
| 160 |
+
|
| 161 |
+
// Convert arrays to options format for Select component
|
| 162 |
+
const stateOptions = locationData.states.map((state) => ({
|
| 163 |
+
value: state,
|
| 164 |
+
label: state,
|
| 165 |
+
}));
|
| 166 |
+
|
| 167 |
+
const districtOptions = availableDistricts.map((district) => ({
|
| 168 |
+
value: district,
|
| 169 |
+
label: district,
|
| 170 |
+
}));
|
| 171 |
+
|
| 172 |
+
const talukaOptions = availableTalukas.map((taluka) => ({
|
| 173 |
+
value: taluka,
|
| 174 |
+
label: taluka,
|
| 175 |
+
}));
|
| 176 |
+
|
| 177 |
+
if (locationLoading) {
|
| 178 |
+
return (
|
| 179 |
+
<div className="min-h-[80vh] flex items-center justify-center">
|
| 180 |
+
<LoadingSpinner size="lg" message="Loading..." />
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<div className="min-h-[80vh] flex items-center justify-center px-4 py-8">
|
| 187 |
+
<motion.div
|
| 188 |
+
initial={{ opacity: 0, y: 20 }}
|
| 189 |
+
animate={{ opacity: 1, y: 0 }}
|
| 190 |
+
transition={{ duration: 0.4 }}
|
| 191 |
+
className="w-full max-w-md"
|
| 192 |
+
>
|
| 193 |
+
<Card className="p-6 sm:p-8">
|
| 194 |
+
<div className="text-center mb-6">
|
| 195 |
+
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
| 196 |
+
Create Account
|
| 197 |
+
</h1>
|
| 198 |
+
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
| 199 |
+
Register to access all features
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<form onSubmit={handleSubmit} className="space-y-5">
|
| 204 |
+
<Input
|
| 205 |
+
name="mobileNumber"
|
| 206 |
+
label="Mobile Number"
|
| 207 |
+
type="tel"
|
| 208 |
+
placeholder="Enter your 10-digit mobile number"
|
| 209 |
+
value={formData.mobileNumber}
|
| 210 |
+
onChange={handleChange}
|
| 211 |
+
error={errors.mobileNumber}
|
| 212 |
+
required
|
| 213 |
+
maxLength={15}
|
| 214 |
+
leadingIcon={<PhoneIcon className="h-5 w-5" />}
|
| 215 |
+
disabled={loading}
|
| 216 |
+
/>
|
| 217 |
+
|
| 218 |
+
<Input
|
| 219 |
+
name="name"
|
| 220 |
+
label="Full Name"
|
| 221 |
+
type="text"
|
| 222 |
+
placeholder="Enter your full name"
|
| 223 |
+
value={formData.name}
|
| 224 |
+
onChange={handleChange}
|
| 225 |
+
error={errors.name}
|
| 226 |
+
required
|
| 227 |
+
maxLength={100}
|
| 228 |
+
leadingIcon={<UserIcon className="h-5 w-5" />}
|
| 229 |
+
disabled={loading}
|
| 230 |
+
/>
|
| 231 |
+
|
| 232 |
+
<Select
|
| 233 |
+
name="state"
|
| 234 |
+
label="State"
|
| 235 |
+
placeholder="Select your state"
|
| 236 |
+
options={stateOptions}
|
| 237 |
+
value={formData.state}
|
| 238 |
+
onChange={handleChange}
|
| 239 |
+
error={errors.state}
|
| 240 |
+
required
|
| 241 |
+
disabled={loading}
|
| 242 |
+
/>
|
| 243 |
+
|
| 244 |
+
<Select
|
| 245 |
+
name="district"
|
| 246 |
+
label="District"
|
| 247 |
+
placeholder={formData.state ? "Select your district" : "Select state first"}
|
| 248 |
+
options={districtOptions}
|
| 249 |
+
value={formData.district}
|
| 250 |
+
onChange={handleChange}
|
| 251 |
+
error={errors.district}
|
| 252 |
+
required
|
| 253 |
+
disabled={loading || !formData.state}
|
| 254 |
+
/>
|
| 255 |
+
|
| 256 |
+
<Select
|
| 257 |
+
name="taluka"
|
| 258 |
+
label="Taluka"
|
| 259 |
+
placeholder={formData.district ? "Select your taluka" : "Select district first"}
|
| 260 |
+
options={talukaOptions}
|
| 261 |
+
value={formData.taluka}
|
| 262 |
+
onChange={handleChange}
|
| 263 |
+
error={errors.taluka}
|
| 264 |
+
required
|
| 265 |
+
disabled={loading || !formData.district}
|
| 266 |
+
/>
|
| 267 |
+
|
| 268 |
+
<Button
|
| 269 |
+
type="submit"
|
| 270 |
+
variant="primary"
|
| 271 |
+
fullWidth
|
| 272 |
+
loading={loading}
|
| 273 |
+
>
|
| 274 |
+
Create Account
|
| 275 |
+
</Button>
|
| 276 |
+
</form>
|
| 277 |
+
|
| 278 |
+
<div className="mt-6 text-center">
|
| 279 |
+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
| 280 |
+
Already have an account?{" "}
|
| 281 |
+
<Link
|
| 282 |
+
to={ROUTES.LOGIN}
|
| 283 |
+
className="font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
| 284 |
+
>
|
| 285 |
+
Login
|
| 286 |
+
</Link>
|
| 287 |
+
</p>
|
| 288 |
+
</div>
|
| 289 |
+
</Card>
|
| 290 |
+
</motion.div>
|
| 291 |
+
</div>
|
| 292 |
+
);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
export default SignupPage;
|
frontend/src/services/authApi.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Authentication API Service
|
| 3 |
+
*
|
| 4 |
+
* Provides methods for user authentication operations:
|
| 5 |
+
* - Login with mobile number and OTP
|
| 6 |
+
* - Signup with user details
|
| 7 |
+
* - Request OTP
|
| 8 |
+
* - Get location master data
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import api, { API_V1 } from "./api";
|
| 12 |
+
|
| 13 |
+
const AUTH_ENDPOINTS = {
|
| 14 |
+
LOGIN: `${API_V1}/auth/login`,
|
| 15 |
+
SIGNUP: `${API_V1}/auth/signup`,
|
| 16 |
+
REQUEST_OTP: `${API_V1}/auth/request-otp`,
|
| 17 |
+
LOCATIONS: `${API_V1}/auth/locations`,
|
| 18 |
+
ME: `${API_V1}/auth/me`,
|
| 19 |
+
PROFILE: `${API_V1}/auth/profile`,
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Login with mobile number and OTP.
|
| 24 |
+
*
|
| 25 |
+
* @param {string} mobileNumber - User's mobile number (10-15 digits)
|
| 26 |
+
* @param {string} otp - OTP code (4-6 digits)
|
| 27 |
+
* @returns {Promise<{success: boolean, message: string, user: object, token: string}>}
|
| 28 |
+
*/
|
| 29 |
+
export async function login(mobileNumber, otp) {
|
| 30 |
+
const response = await api.post(AUTH_ENDPOINTS.LOGIN, {
|
| 31 |
+
mobile_number: mobileNumber,
|
| 32 |
+
otp: otp,
|
| 33 |
+
});
|
| 34 |
+
return response.data;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Signup a new user.
|
| 39 |
+
*
|
| 40 |
+
* @param {object} userData - User registration data
|
| 41 |
+
* @param {string} userData.mobileNumber - Mobile number (10-15 digits)
|
| 42 |
+
* @param {string} userData.name - User's full name
|
| 43 |
+
* @param {string} userData.state - State name
|
| 44 |
+
* @param {string} userData.district - District name
|
| 45 |
+
* @param {string} userData.taluka - Taluka name
|
| 46 |
+
* @returns {Promise<{success: boolean, message: string, user: object, token: string}>}
|
| 47 |
+
*/
|
| 48 |
+
export async function signup(userData) {
|
| 49 |
+
const response = await api.post(AUTH_ENDPOINTS.SIGNUP, {
|
| 50 |
+
mobile_number: userData.mobileNumber,
|
| 51 |
+
name: userData.name,
|
| 52 |
+
state: userData.state,
|
| 53 |
+
district: userData.district,
|
| 54 |
+
taluka: userData.taluka,
|
| 55 |
+
});
|
| 56 |
+
return response.data;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Request OTP for login.
|
| 61 |
+
*
|
| 62 |
+
* @param {string} mobileNumber - User's mobile number
|
| 63 |
+
* @returns {Promise<{success: boolean, message: string, mobile_number: string}>}
|
| 64 |
+
*/
|
| 65 |
+
export async function requestOtp(mobileNumber) {
|
| 66 |
+
const response = await api.post(AUTH_ENDPOINTS.REQUEST_OTP, {
|
| 67 |
+
mobile_number: mobileNumber,
|
| 68 |
+
});
|
| 69 |
+
return response.data;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Get location master data for signup dropdowns.
|
| 74 |
+
*
|
| 75 |
+
* @returns {Promise<{states: string[], districts: object, talukas: object}>}
|
| 76 |
+
*/
|
| 77 |
+
export async function getLocationData() {
|
| 78 |
+
const response = await api.get(AUTH_ENDPOINTS.LOCATIONS);
|
| 79 |
+
return response.data;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Get current user details.
|
| 84 |
+
*
|
| 85 |
+
* @param {string} mobileNumber - User's mobile number
|
| 86 |
+
* @returns {Promise<object>} User data
|
| 87 |
+
*/
|
| 88 |
+
export async function getCurrentUser(mobileNumber) {
|
| 89 |
+
const response = await api.get(AUTH_ENDPOINTS.ME, {
|
| 90 |
+
params: { mobile_number: mobileNumber },
|
| 91 |
+
});
|
| 92 |
+
return response.data;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Update user profile.
|
| 97 |
+
*
|
| 98 |
+
* @param {string} mobileNumber - User's mobile number
|
| 99 |
+
* @param {object} profileData - Profile data to update
|
| 100 |
+
* @param {string} [profileData.name] - New name
|
| 101 |
+
* @param {string[]} [profileData.crops] - Selected crops (max 2)
|
| 102 |
+
* @returns {Promise<object>} Updated user data
|
| 103 |
+
*/
|
| 104 |
+
export async function updateProfile(mobileNumber, profileData) {
|
| 105 |
+
const response = await api.put(AUTH_ENDPOINTS.PROFILE, profileData, {
|
| 106 |
+
params: { mobile_number: mobileNumber },
|
| 107 |
+
});
|
| 108 |
+
return response.data;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export default {
|
| 112 |
+
login,
|
| 113 |
+
signup,
|
| 114 |
+
requestOtp,
|
| 115 |
+
getLocationData,
|
| 116 |
+
getCurrentUser,
|
| 117 |
+
updateProfile,
|
| 118 |
+
};
|
frontend/src/services/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
export { default as api, API_V1, apiUrl } from "./api";
|
|
|
|
| 6 |
export * from "./diseaseApi";
|
| 7 |
export * from "./weatherApi";
|
| 8 |
export * from "./apmcApi";
|
|
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
export { default as api, API_V1, apiUrl } from "./api";
|
| 6 |
+
export * from "./authApi";
|
| 7 |
export * from "./diseaseApi";
|
| 8 |
export * from "./weatherApi";
|
| 9 |
export * from "./apmcApi";
|
frontend/src/services/schemesApi.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
* Government Schemes API Service
|
| 3 |
*
|
| 4 |
* Handles all API calls related to government schemes.
|
|
|
|
| 5 |
*/
|
| 6 |
|
| 7 |
-
import
|
| 8 |
|
| 9 |
-
const
|
| 10 |
|
| 11 |
/**
|
| 12 |
* Get all government schemes with optional filters
|
|
@@ -17,32 +18,20 @@ const SCHEMES_BASE_URL = `${API_BASE_URL}${API_PREFIX}`;
|
|
| 17 |
* @returns {Promise<Object>} - Schemes data
|
| 18 |
*/
|
| 19 |
export async function getAllSchemes(filters = {}) {
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
params.append("scheme_type", filters.scheme_type);
|
| 25 |
-
}
|
| 26 |
-
if (filters.state) {
|
| 27 |
-
params.append("state", filters.state);
|
| 28 |
-
}
|
| 29 |
-
if (filters.is_active !== undefined) {
|
| 30 |
-
params.append("is_active", filters.is_active);
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
const url = `${SCHEMES_BASE_URL}/schemes${params.toString() ? `?${params.toString()}` : ""}`;
|
| 34 |
-
const response = await fetch(url);
|
| 35 |
-
|
| 36 |
-
if (!response.ok) {
|
| 37 |
-
const errorData = await response.json().catch(() => ({}));
|
| 38 |
-
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
return await response.json();
|
| 42 |
-
} catch (error) {
|
| 43 |
-
console.error("Error fetching schemes:", error);
|
| 44 |
-
throw error;
|
| 45 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
/**
|
|
@@ -51,19 +40,8 @@ export async function getAllSchemes(filters = {}) {
|
|
| 51 |
* @returns {Promise<Object>} - Scheme data
|
| 52 |
*/
|
| 53 |
export async function getSchemeById(schemeId) {
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if (!response.ok) {
|
| 58 |
-
const errorData = await response.json().catch(() => ({}));
|
| 59 |
-
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
return await response.json();
|
| 63 |
-
} catch (error) {
|
| 64 |
-
console.error(`Error fetching scheme ${schemeId}:`, error);
|
| 65 |
-
throw error;
|
| 66 |
-
}
|
| 67 |
}
|
| 68 |
|
| 69 |
/**
|
|
@@ -72,19 +50,8 @@ export async function getSchemeById(schemeId) {
|
|
| 72 |
* @returns {Promise<Object>} - Scheme data
|
| 73 |
*/
|
| 74 |
export async function getSchemeByCode(schemeCode) {
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
if (!response.ok) {
|
| 79 |
-
const errorData = await response.json().catch(() => ({}));
|
| 80 |
-
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
return await response.json();
|
| 84 |
-
} catch (error) {
|
| 85 |
-
console.error(`Error fetching scheme by code ${schemeCode}:`, error);
|
| 86 |
-
throw error;
|
| 87 |
-
}
|
| 88 |
}
|
| 89 |
|
| 90 |
/**
|
|
@@ -92,19 +59,8 @@ export async function getSchemeByCode(schemeCode) {
|
|
| 92 |
* @returns {Promise<Object>} - List of scheme types
|
| 93 |
*/
|
| 94 |
export async function getSchemeTypes() {
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
if (!response.ok) {
|
| 99 |
-
const errorData = await response.json().catch(() => ({}));
|
| 100 |
-
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
return await response.json();
|
| 104 |
-
} catch (error) {
|
| 105 |
-
console.error("Error fetching scheme types:", error);
|
| 106 |
-
throw error;
|
| 107 |
-
}
|
| 108 |
}
|
| 109 |
|
| 110 |
/**
|
|
|
|
| 2 |
* Government Schemes API Service
|
| 3 |
*
|
| 4 |
* Handles all API calls related to government schemes.
|
| 5 |
+
* Uses axios-based api module for consistency with other services.
|
| 6 |
*/
|
| 7 |
|
| 8 |
+
import api, { API_V1 } from "./api";
|
| 9 |
|
| 10 |
+
const BASE = `${API_V1}/schemes`;
|
| 11 |
|
| 12 |
/**
|
| 13 |
* Get all government schemes with optional filters
|
|
|
|
| 18 |
* @returns {Promise<Object>} - Schemes data
|
| 19 |
*/
|
| 20 |
export async function getAllSchemes(filters = {}) {
|
| 21 |
+
const params = {};
|
| 22 |
+
|
| 23 |
+
if (filters.scheme_type) {
|
| 24 |
+
params.scheme_type = filters.scheme_type;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
+
if (filters.state) {
|
| 27 |
+
params.state = filters.state;
|
| 28 |
+
}
|
| 29 |
+
if (filters.is_active !== undefined) {
|
| 30 |
+
params.is_active = filters.is_active;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const { data } = await api.get(BASE, { params });
|
| 34 |
+
return data;
|
| 35 |
}
|
| 36 |
|
| 37 |
/**
|
|
|
|
| 40 |
* @returns {Promise<Object>} - Scheme data
|
| 41 |
*/
|
| 42 |
export async function getSchemeById(schemeId) {
|
| 43 |
+
const { data } = await api.get(`${BASE}/${schemeId}`);
|
| 44 |
+
return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
/**
|
|
|
|
| 50 |
* @returns {Promise<Object>} - Scheme data
|
| 51 |
*/
|
| 52 |
export async function getSchemeByCode(schemeCode) {
|
| 53 |
+
const { data } = await api.get(`${BASE}/code/${schemeCode}`);
|
| 54 |
+
return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
|
|
|
| 59 |
* @returns {Promise<Object>} - List of scheme types
|
| 60 |
*/
|
| 61 |
export async function getSchemeTypes() {
|
| 62 |
+
const { data } = await api.get(`${BASE}/types/list`);
|
| 63 |
+
return data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
/**
|
frontend/src/utils/constants.js
CHANGED
|
@@ -135,6 +135,8 @@ export const STORAGE_KEYS = {
|
|
| 135 |
VOICE_SETTINGS: "farmhelp_voice_settings",
|
| 136 |
VOICE_TUTORIAL_SHOWN: "farmhelp_voice_tutorial_shown",
|
| 137 |
VOICE_CHAT_HISTORY: "farmhelp_voice_chat_history",
|
|
|
|
|
|
|
| 138 |
};
|
| 139 |
|
| 140 |
// ---------------------------------------------------------------------------
|
|
@@ -143,6 +145,9 @@ export const STORAGE_KEYS = {
|
|
| 143 |
|
| 144 |
export const ROUTES = {
|
| 145 |
HOME: "/",
|
|
|
|
|
|
|
|
|
|
| 146 |
DISEASE_DETECTION: "/disease",
|
| 147 |
WEATHER: "/weather",
|
| 148 |
APMC: "/apmc",
|
|
|
|
| 135 |
VOICE_SETTINGS: "farmhelp_voice_settings",
|
| 136 |
VOICE_TUTORIAL_SHOWN: "farmhelp_voice_tutorial_shown",
|
| 137 |
VOICE_CHAT_HISTORY: "farmhelp_voice_chat_history",
|
| 138 |
+
AUTH_USER: "farmhelp_auth_user",
|
| 139 |
+
AUTH_TOKEN: "farmhelp_auth_token",
|
| 140 |
};
|
| 141 |
|
| 142 |
// ---------------------------------------------------------------------------
|
|
|
|
| 145 |
|
| 146 |
export const ROUTES = {
|
| 147 |
HOME: "/",
|
| 148 |
+
LOGIN: "/login",
|
| 149 |
+
SIGNUP: "/signup",
|
| 150 |
+
PROFILE: "/profile",
|
| 151 |
DISEASE_DETECTION: "/disease",
|
| 152 |
WEATHER: "/weather",
|
| 153 |
APMC: "/apmc",
|