Gankit12 commited on
Commit
34af792
·
1 Parent(s): fb6ccf7
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
- <LocationProvider>
77
- <VoiceProvider>
78
- <AppShell />
79
- </VoiceProvider>
80
- </LocationProvider>
 
 
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, and dark mode 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="large">
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 { API_BASE_URL, API_PREFIX } from "@utils/constants";
8
 
9
- const SCHEMES_BASE_URL = `${API_BASE_URL}${API_PREFIX}`;
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
- try {
21
- const params = new URLSearchParams();
22
-
23
- if (filters.scheme_type) {
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
- try {
55
- const response = await fetch(`${SCHEMES_BASE_URL}/schemes/${schemeId}`);
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
- try {
76
- const response = await fetch(`${SCHEMES_BASE_URL}/schemes/code/${schemeCode}`);
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
- try {
96
- const response = await fetch(`${SCHEMES_BASE_URL}/schemes/types/list`);
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",