diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b1e3502..22cee50 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,20 +1,17 @@ name: FarmSmart CI/CD Pipeline -# Trigger the workflow on pushes and pull requests to the main branch on: push: branches: [ "main" ] pull_request: branches: [ "main" ] -# Define environment variables if needed env: NODE_VERSION: '18.x' jobs: - # ------------------------------------------------------------------ + # JOB 1: BACKEND CI (Test & Build) - # ------------------------------------------------------------------ backend-ci: name: Backend CI (Test & Build) runs-on: ubuntu-latest @@ -40,15 +37,13 @@ jobs: # - name: Run Tests (Jest) # run: npm test -- --passWithNoTests - # - name: Build / Type Check (TypeScript) - # run: npm run build + - name: Build / Type Check (TypeScript) + run: npm run build - name: Build Project (Simulated) run: echo "Build successful" - # ------------------------------------------------------------------ # JOB 2: FRONTEND CI (Lint & Build) - # ------------------------------------------------------------------ frontend-ci: name: Frontend CI (Lint & Build) runs-on: ubuntu-latest @@ -78,9 +73,7 @@ jobs: - name: Build Project (Vite) run: npm run build - # ------------------------------------------------------------------ # JOB 3: DEPLOY TO RENDER (CD) - # ------------------------------------------------------------------ deploy: name: Deploy to Render needs: [backend-ci, frontend-ci] # Only deploy if CI passes diff --git a/backend/__tests__/crops/crop.test.ts b/backend/__tests__/crops/crop.test.ts index e105e30..3d1d4b3 100644 --- a/backend/__tests__/crops/crop.test.ts +++ b/backend/__tests__/crops/crop.test.ts @@ -35,6 +35,8 @@ const validCrop = { quantity: 100, unit: 'kg', basePrice: 25.50, + finalPrice: 25.50, + imageUrl: 'https://example.com/tomato.jpg', qualityGrade: 'A', location: { state: 'Maharashtra', @@ -48,6 +50,8 @@ const invalidCrop = { quantity: -50, // INVALID: Negative quantity unit: 'kg', basePrice: -100, // INVALID: Negative price + finalPrice: 100, + imageUrl: 'img', qualityGrade: 'A', location: { state: 'Punjab', @@ -69,6 +73,8 @@ const cropEmptyName = { quantity: 50, unit: 'kg', basePrice: 20, + finalPrice: 20, + imageUrl: 'img', qualityGrade: 'B', location: { state: 'Madhya Pradesh', @@ -81,6 +87,8 @@ const cropInvalidUnit = { quantity: 75, unit: 'bags', // INVALID: Only kg, quintal, ton allowed basePrice: 30, + finalPrice: 30, + imageUrl: 'img', qualityGrade: 'B', location: { state: 'Rajasthan', @@ -93,6 +101,8 @@ const cropInvalidGrade = { quantity: 60, unit: 'quintal', basePrice: 28, + finalPrice: 28, + imageUrl: 'img', qualityGrade: 'D', // INVALID: Only A, B, C allowed location: { state: 'Telangana', @@ -105,6 +115,8 @@ const zeroQuantityCrop = { quantity: 0, unit: 'ton', basePrice: 15, + finalPrice: 15, + imageUrl: 'http://img.com/barley.jpg', qualityGrade: 'C', location: { state: 'Uttarakhand', @@ -117,6 +129,8 @@ const zeroPrice = { quantity: 200, unit: 'kg', basePrice: 0, // EDGE CASE: Zero price + finalPrice: 0, + imageUrl: 'http://img.com/soy.jpg', qualityGrade: 'B', location: { state: 'Madhya Pradesh', @@ -129,6 +143,8 @@ const largeQuantity = { quantity: 999999999, // EDGE CASE: Very large number unit: 'ton', basePrice: 3.50, + finalPrice: 3.50, + imageUrl: 'http://img.com/cane.jpg', qualityGrade: 'A', location: { state: 'Uttar Pradesh', @@ -141,6 +157,8 @@ const duplicateCrop = { quantity: 150, unit: 'quintal', basePrice: 35, + finalPrice: 35, + imageUrl: 'http://img.com/potatoes.jpg', qualityGrade: 'B', location: { state: 'Karnataka', @@ -212,6 +230,8 @@ describe('CROP CONTROLLER - COMPREHENSIVE TEST SUITE', () => { quantity: 500, unit: 'kg', basePrice: 40, + finalPrice: 40, + imageUrl: 'http://img.com/rice.jpg', qualityGrade: 'A', location: { state: 'Punjab', diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index db7afce..7fe6932 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -2,7 +2,6 @@ import { Request, Response } from 'express'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import User from '../models/User'; -import VerificationCode, { VerificationType } from '../models/VerificationCode'; import { sendResponse } from '../utils/response'; import { AuthRequest } from '../middleware/authMiddleware'; import { sendWelcomeMessage, sendLoginAlert } from '../services/notificationService'; @@ -104,30 +103,7 @@ export const updateProfile = async (req: AuthRequest, res: Response): Promise { - return Math.floor(100000 + Math.random() * 900000).toString(); -}; -const saveOTP = async (contact: string, type: VerificationType) => { - const code = generateOTP(); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes - - // Delete existing codes for this user/type - await VerificationCode.deleteMany({ contact, type }); - - await VerificationCode.create({ - contact, - code, - type, - expiresAt - }); - - // In a real app, send SMS here - console.log(`\n----------------------------------------`); - console.log(`[OTP] GENERATED FOR ${contact}: ${code}`); - console.log(`----------------------------------------\n`); - return code; -}; export const register = async (req: Request, res: Response): Promise => { try { @@ -253,89 +229,3 @@ export const login = async (req: Request, res: Response): Promise => { } }; -export const verify = async (req: Request, res: Response): Promise => { - try { - const { contact, code } = req.body; - - if (!contact || !code) { - sendResponse(res, 400, "Contact and code are required"); - return; - } - - // 1. Find the OTP record - const record = await VerificationCode.findOne({ - contact: contact, - code: code, - type: VerificationType.PHONE, - expiresAt: { $gt: new Date() } // check if not expired - }); - - if (!record) { - sendResponse(res, 400, "Invalid or expired OTP"); - return; - } - - // 2. Find the user - const user = await User.findOne({ phoneNumber: contact }); - if (!user) { - sendResponse(res, 404, "User not found"); - return; - } - - // 3. Mark user as verified - user.isVerified = true; - await user.save(); - - // 4. Delete the OTP record (prevent reuse) - await VerificationCode.deleteOne({ _id: record._id }); - - // 5. Generate Token - const token = jwt.sign( - { userId: user._id, role: user.role }, - JWT_SECRET, - { expiresIn: '7d' } - ); - - // Exclude password from response - const userObject = user.toObject(); - const { passwordHash: _, ...userWithoutPassword } = userObject; - - sendResponse(res, 200, "Verification successful", { - user: userWithoutPassword, - token, - }); - - } catch (error) { - console.error("Verify Error:", error); - sendResponse(res, 500, "Internal Server Error"); - } -}; - -export const resendOtp = async (req: Request, res: Response): Promise => { - try { - const { contact } = req.body; - - if (!contact) { - sendResponse(res, 400, "Contact is required"); - return; - } - - // 1. Check if user exists - const user = await User.findOne({ phoneNumber: contact }); - if (!user) { - sendResponse(res, 404, "User not found"); - return; - } - - // 2. Generate and Save OTP - const otp = await saveOTP(contact, VerificationType.PHONE); - - sendResponse(res, 200, "OTP resent successfully", { - debugOtp: otp - }); - - } catch (error) { - console.error("Resend OTP Error:", error); - sendResponse(res, 500, "Internal Server Error"); - } -}; \ No newline at end of file diff --git a/backend/src/controllers/orderController.ts b/backend/src/controllers/orderController.ts index c0dc157..fad5af4 100644 --- a/backend/src/controllers/orderController.ts +++ b/backend/src/controllers/orderController.ts @@ -77,8 +77,8 @@ export const createOrder = async (req: AuthRequest, res: Response): Promise // Populate Response Data immediatly so UI doesn't flicker "Unknown Crop" const populatedOrder = await Order.findById(order._id) .populate("cropId", "name") - .populate("buyerId", "fullName role phoneNumber") - .populate("farmerId", "fullName role phoneNumber"); + .populate("buyerId", "fullName role phoneNumber address district state") + .populate("farmerId", "fullName role phoneNumber address district state"); // NOTIFICATION: Send SMS to Farmer (New Order) try { @@ -124,8 +124,8 @@ export const getOrderById = async (req: AuthRequest, res: Response): Promise const orders = await Order.find(filter) .populate("cropId", "name") - .populate("buyerId", "fullName role phoneNumber") - .populate("farmerId", "fullName role phoneNumber") + .populate("buyerId", "fullName role phoneNumber address district state") + .populate("farmerId", "fullName role phoneNumber address district state") .populate("logisticsProviderId", "fullName role phoneNumber") .sort({ createdAt: -1 }); @@ -184,8 +184,8 @@ export const getAvailableOrdersForLogistics = async (req: AuthRequest, res: Resp ] }) .populate("cropId", "name") - .populate("buyerId", "fullName role phoneNumber") - .populate("farmerId", "fullName role phoneNumber") + .populate("buyerId", "fullName role phoneNumber address district state") + .populate("farmerId", "fullName role phoneNumber address district state") .sort({ createdAt: -1 }); return res.json(orders); @@ -221,8 +221,8 @@ export const acceptOrder = async (req: AuthRequest, res: Response): Promise // Return populated order const populated = await Order.findById(order._id) .populate("cropId", "name") - .populate("buyerId", "fullName role phoneNumber") - .populate("farmerId", "fullName role phoneNumber") + .populate("buyerId", "fullName role phoneNumber address district state") + .populate("farmerId", "fullName role phoneNumber address district state") .populate("logisticsProviderId", "fullName role phoneNumber"); // NOTIFICATION: Send SMS to Farmer and Buyer (Order Accepted) @@ -310,24 +310,35 @@ export const updateOrderStatus = async (req: AuthRequest, res: Response): Promis return res.status(404).json({ message: "Order not found" }); } - // Logic: Only Logistics provider (who accepted the order) can update status. - // ALSO: Admin can probably update status? I'll stick to user requirement: "Logistics provideder... They should be the ones be able to change the order status" - // User requested removal of farmer capability. const isLogistics = req.user?.role === "LOGISTICS"; + const isBuyer = req.user?.role === "BUYER"; const isAdmin = req.user?.role === "ADMIN"; - if (!isLogistics && !isAdmin) { - return res.status(403).json({ message: "Only Logistics Provider can update status" }); - } - - if (isLogistics && order.logisticsProviderId?.toString() !== req.user?.id) { - return res.status(403).json({ message: "You are not the designated logistics provider" }); + // Permission Logic + if (nextStatus === "DELIVERED") { + // ONLY Buyer can mark as DELIVERED + if (!isBuyer || order.buyerId?.toString() !== req.user?.id) { + return res.status(403).json({ message: "Only the Buyer can confirm delivery." }); + } + } else if (nextStatus === "SHIPPED") { + // Logistics marks as SHIPPED + if (!isLogistics || order.logisticsProviderId?.toString() !== req.user?.id) { + return res.status(403).json({ message: "Only Logistics Provider can update shipment status." }); + } + } else { + // Default Fallback + if (!isLogistics && !isAdmin) { + return res.status(403).json({ message: "Unauthorized status update." }); + } } + // Previous Logic was: order.status.push... + // Now just execute update order.currentStatus = nextStatus as OrderStatus; order.status.push({ status: nextStatus, timestamp: new Date() }); + await order.save(); // NOTIFICATION: Send SMS to Buyer and Farmer @@ -366,3 +377,38 @@ export const updateOrderStatus = async (req: AuthRequest, res: Response): Promis return res.status(500).json({ message: "Server error" }); } }; + +export const claimPayment = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + const order = await Order.findById(id); + + if (!order) return res.status(404).json({ message: "Order not found" }); + + // Ensure user is the Farmer + if (order.farmerId?.toString() !== req.user?.id) { + return res.status(403).json({ message: "Only the Farmer can claim payment." }); + } + + // Must be DELIVERED to claim + if (order.currentStatus !== "DELIVERED") { + return res.status(400).json({ message: "Order must be DELIVERED by Buyer to claim payment." }); + } + + // Add to Farmer Wallet + const farmer = await User.findById(req.user.id); + if (farmer) { + farmer.walletBalance = (farmer.walletBalance || 0) + (order.totalAmount || 0); + await farmer.save(); + } + + // Update Order to COMPLETED + order.currentStatus = "COMPLETED"; + order.status.push({ status: "COMPLETED", timestamp: new Date() }); + await order.save(); + + return res.json({ message: "Payment Claimed Successfully", newBalance: farmer?.walletBalance, order }); + } catch (err) { + return res.status(500).json({ message: "Server error" }); + } +}; diff --git a/backend/src/controllers/reviewController.ts b/backend/src/controllers/reviewController.ts index e2e5daf..2441299 100644 --- a/backend/src/controllers/reviewController.ts +++ b/backend/src/controllers/reviewController.ts @@ -16,6 +16,12 @@ export const createReview = async (req: AuthRequest, res: Response): Promise('VerificationCode', VerificationCodeSchema); diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 58895fa..cb00f5d 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { register, login, verify, resendOtp, getMe, updateProfile, uploadKYC } from '../controllers/authController'; +import { register, login, getMe, updateProfile, uploadKYC } from '../controllers/authController'; import { authenticate } from '../middleware/authMiddleware'; import { upload } from "../middleware/uploadMiddleware"; @@ -7,8 +7,6 @@ const router = Router(); router.post('/register', register); router.post('/login', login); -router.post('/verify', verify); -router.post('/resend', resendOtp); // Profile routes router.get('/me', authenticate, getMe); diff --git a/backend/src/routes/orderRoutes.ts b/backend/src/routes/orderRoutes.ts index 4d08874..53af508 100644 --- a/backend/src/routes/orderRoutes.ts +++ b/backend/src/routes/orderRoutes.ts @@ -7,6 +7,7 @@ import { getAvailableOrdersForLogistics, acceptOrder, updateLogisticsDetails, + claimPayment } from "../controllers/orderController"; import { instantBuy } from "../controllers/instantBuyController"; import { authenticate } from "../middleware/authMiddleware"; @@ -20,6 +21,7 @@ router.get("/available", authenticate, getAvailableOrdersForLogistics); // Logis router.get("/:id", authenticate, getOrderById); router.patch("/:id/status", authenticate, updateOrderStatus); +router.post("/:id/claim", authenticate, claimPayment); // Farmer claims payment router.put("/:id/accept", authenticate, acceptOrder); router.patch("/:id/logistics", authenticate, updateLogisticsDetails); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f481f8b..db7d79c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -24,7 +24,7 @@ export default defineConfig([ }, rules: { 'no-unused-vars': ['warn', { - varsIgnorePattern: '^[A-Z_]', + varsIgnorePattern: '^[A-Z_]|motion', argsIgnorePattern: '^[A-Z_]' }], 'react-hooks/exhaustive-deps': 'off', diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d67700d..6a7baf9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,7 @@ import i18n from "./i18n/i18n"; import authService from "./services/auth.service"; import Login from "./pages/Login"; import Register from "./pages/Register"; -import Otp from "./pages/Otp"; + import DashboardLayout from "./components/layout/DashboardLayout"; import Dashboard from "./pages/Dashboard"; import Marketplace from "./pages/Marketplace"; @@ -28,6 +28,7 @@ import AdminDisputes from "./pages/admin/AdminDisputes"; import SalesRevenue from "./pages/SalesRevenue"; import Profile from "./pages/Profile"; import CropPlanning from "./pages/CropPlanning"; +import LogisticsDashboard from "./pages/logistics/LogisticsDashboard"; import GlobalVoiceButton from "./components/common/GlobalVoiceButton"; @@ -52,7 +53,7 @@ const PublicRoute = ({ children }) => { function App() { const location = useLocation(); - const isPublicRoute = (location.pathname === '/' || location.pathname === '/login' || location.pathname === '/register' || location.pathname === '/otp'); + const isPublicRoute = (location.pathname === '/' || location.pathname === '/login' || location.pathname === '/register'); useEffect(() => { const initLanguage = async () => { @@ -82,7 +83,7 @@ function App() { } /> } /> } /> - } /> + {/* Dashboard Routes */} }> @@ -114,6 +115,7 @@ function App() { {/* Notifications & Settings */} } /> } /> + } /> } /> } /> {/* Orders */} diff --git a/frontend/src/components/common/GlobalVoiceButton.jsx b/frontend/src/components/common/GlobalVoiceButton.jsx index 8fdb1ad..eec4bd5 100644 --- a/frontend/src/components/common/GlobalVoiceButton.jsx +++ b/frontend/src/components/common/GlobalVoiceButton.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Mic, AlertCircle } from 'lucide-react'; -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import useVoiceNavigation from '../../hooks/useVoiceNavigation'; diff --git a/frontend/src/components/common/LanguageSelector.jsx b/frontend/src/components/common/LanguageSelector.jsx index 9abf073..218bfc8 100644 --- a/frontend/src/components/common/LanguageSelector.jsx +++ b/frontend/src/components/common/LanguageSelector.jsx @@ -1,8 +1,7 @@ import { useTranslation } from "react-i18next"; import { Globe, Check } from "lucide-react"; import { useState, useRef, useEffect } from "react"; -// import { motion, AnimatePresence } from "framer-motion"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; function LanguageSelector() { const { i18n } = useTranslation(); diff --git a/frontend/src/components/common/OTPInput.jsx b/frontend/src/components/common/OTPInput.jsx deleted file mode 100644 index 69ffcad..0000000 --- a/frontend/src/components/common/OTPInput.jsx +++ /dev/null @@ -1,18 +0,0 @@ -function OTPInput({ value, onChange, inputStyle = {} }) { - return ( -
- -
-
- ); -} - -export default OTPInput; diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 7ccf045..6647ac6 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; import { Search, Bell, User, ChevronRight, LogOut, MessageSquare, Gavel, AlertCircle } from "lucide-react"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import authService from "../../services/auth.service"; diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index 89534dd..9e83102 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -37,8 +37,8 @@ const Sidebar = () => { { label: t('nav.negotiations'), icon: Handshake, path: "/dashboard/negotiations", roles: ["farmer", "buyer"] }, { label: t('nav.orders'), icon: Receipt, path: "/dashboard/orders", roles: ["farmer", "buyer", "logistics"] }, { label: t('nav.logistics'), icon: Truck, path: "/dashboard/logistics", roles: ["logistics"] }, - { label: t('nav.reviews'), icon: Star, path: "/dashboard/reviews", roles: ["farmer", "buyer", "logistics"] }, - { label: t('nav.disputes'), icon: ShieldAlert, path: "/dashboard/disputes", roles: ["farmer", "buyer", "logistics"] }, + { label: t('nav.reviews'), icon: Star, path: "/dashboard/reviews", roles: ["farmer", "buyer"] }, + { label: t('nav.disputes'), icon: ShieldAlert, path: "/dashboard/disputes", roles: ["farmer", "buyer"] }, { label: t('nav.adminDisputes'), icon: ShieldAlert, path: "/dashboard/admin/disputes", roles: ["admin"] }, { label: t('nav.sales'), icon: TrendingUp, path: "/dashboard/sales", roles: ["farmer"] }, { label: t('nav.planning'), icon: Sprout, path: "/dashboard/planning", roles: ["farmer"] }, diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index b8397a1..83f9842 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { TrendingUp, Users, DollarSign, ShoppingBag, Loader2, Truck, CheckCircle2 } from "lucide-react"; +import { TrendingUp, Users, DollarSign, ShoppingBag, Loader2, Truck, CheckCircle2, Package, Clock, Activity } from "lucide-react"; import { useTranslation } from "react-i18next"; import DynamicText from "../components/common/DynamicText"; // Import DynamicText import authService from "../services/auth.service"; @@ -8,6 +8,31 @@ import salesService from "../services/sales.service"; import cropService from "../services/crop.service"; import orderService from "../services/order.service"; import poolingService from "../services/pooling.service"; +import { Line, Bar } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip, + Legend, + Filler +); // import { Globe, Users2, ChevronRight, LayoutDashboard, Search, HandCoins } from "lucide-react"; // import RotationAdvisoryCard from "../components/dashboard/RotationAdvisoryCard"; @@ -24,7 +49,12 @@ const Dashboard = () => { activeBids: 0, marketTrends: "+15%" }); + const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); + const [chartData, setChartData] = useState({ + labels: [], + datasets: [] + }); // const [activePools, setActivePools] = useState([]); // const [myCrops, setMyCrops] = useState([]); @@ -47,6 +77,95 @@ const Dashboard = () => { const activeCount = myOrders.filter(o => ["CONFIRMED", "SHIPPED"].includes(o.status)).length; const completedCount = myOrders.filter(o => ["DELIVERED", "COMPLETED"].includes(o.status)).length; + + // Logistics Activity Logs + let newActivities = []; + + // 1. New Available Requests + if (availableOrders.length > 0) { + newActivities = [...newActivities, ...availableOrders.map(order => ({ + id: `new-${order.id}`, + type: 'new_request', + title: 'New Service Request', + message: `${order.crop} (${order.quantity}kg) from ${order.farmerName}`, + date: new Date(order.date), + icon: Package, + color: 'text-blue-500 bg-blue-50' + }))]; + } + + // 2. My Order Status Updates + if (myOrders.length > 0) { + myOrders.forEach(order => { + // If timeline exists, use it for granular updates + if (order.timeline && Array.isArray(order.timeline) && order.timeline.length > 0) { + order.timeline.forEach(event => { + newActivities.push({ + id: `status-${order.id}-${event.status}`, + type: 'status_change', + title: `Order Status Updated`, + message: `Order #${order.id.slice(-4)} is now ${event.status}`, + date: new Date(event.timestamp || order.date), // Fallback to order date if timestamp missing + icon: event.status === 'DELIVERED' ? CheckCircle2 : Truck, + color: event.status === 'DELIVERED' ? 'text-green-500 bg-green-50' : 'text-orange-500 bg-orange-50' + }); + }); + } else { + // Fallback: Just show current status as "Latest Update" + newActivities.push({ + id: `update-${order.id}`, + type: 'status_change', + title: `Order Update`, + message: `Order #${order.id.slice(-4)}: ${order.status}`, + date: new Date(order.date), + icon: order.status === 'DELIVERED' ? CheckCircle2 : Truck, + color: 'text-nature-600 bg-nature-50' + }); + } + }); + } + + // Sort by date (newest first) and take top 5 + newActivities.sort((a, b) => b.date - a.date); + setActivities(newActivities.slice(0, 5)); + + // Calculate Revenue for Chart + const revenueByDate = {}; + myOrders.forEach(order => { + if(["DELIVERED", "COMPLETED"].includes(order.status)){ + const dateObj = new Date(order.date); + const dateKey = dateObj.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const revenue = (Number(order.totalPrice) || 0) * 0.10; + revenueByDate[dateKey] = (revenueByDate[dateKey] || 0) + revenue; + } + }); + + // Ensure at least some dummy data if empty to show the chart structure + const labels = Object.keys(revenueByDate).length > 0 ? Object.keys(revenueByDate) : ['Week 1', 'Week 2', 'Week 3', 'Week 4']; + const data = Object.keys(revenueByDate).length > 0 ? Object.values(revenueByDate) : [0, 0, 0, 0]; + + setChartData({ + labels: labels, + datasets: [{ + label: 'Revenue (₹)', + data: data, + backgroundColor: (context) => { + const ctx = context.chart.ctx; + const gradient = ctx.createLinearGradient(0, 0, 0, 400); + gradient.addColorStop(0, 'rgba(16, 185, 129, 0.4)'); + gradient.addColorStop(1, 'rgba(16, 185, 129, 0.0)'); + return gradient; + }, + borderColor: '#10b981', + borderWidth: 3, + pointBackgroundColor: '#fff', + pointBorderColor: '#10b981', + pointRadius: 4, + pointHoverRadius: 6, + fill: true, + tension: 0.4 + }] + }); setStats({ activeDeliveries: activeCount, @@ -92,6 +211,36 @@ const Dashboard = () => { await poolingService.getActivePools(district); } } + + // Farmer Chart Data + const revenueByDate = {}; + const revenueSales = sales.filter(order => order.currentStatus !== 'CANCELLED'); + revenueSales.forEach(order => { + const dateObj = new Date(order.createdAt); // user order.createdAt from backend + const dateKey = dateObj.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + revenueByDate[dateKey] = (revenueByDate[dateKey] || 0) + (order.totalAmount || 0); + }); + + const labels = Object.keys(revenueByDate); + const data = Object.values(revenueByDate); + + setChartData({ + labels: labels.length > 0 ? labels : ['Week 1', 'Week 2', 'Week 3', 'Week 4'], + datasets: [{ + label: 'Revenue (₹)', + data: data.length > 0 ? data : [0, 0, 0, 0], + // Simple color fallback if context unavailable during init + backgroundColor: 'rgba(16, 185, 129, 0.2)', + borderColor: '#10b981', + borderWidth: 3, + pointBackgroundColor: '#fff', + pointBorderColor: '#10b981', + pointRadius: 4, + pointHoverRadius: 6, + fill: true, + tension: 0.4 + }] + }); } setStats(prev => ({ @@ -226,49 +375,90 @@ const Dashboard = () => {
- {/* Future: Chart Placeholder with improved look */} -
-
- -
- - -
- - {/* Fake chart lines for visual fill */} -
- {[40, 70, 45, 90, 60, 80, 50, 75, 60].map((h, i) => ( -
- ))} +
+ {chartData && chartData.datasets && chartData.datasets.length > 0 ? ( + '₹' + value } + } + } + }} + /> + ) : ( +
+
+ +
+

{t('dashboard.analyticsLoading')}

+
+ )}

{t('dashboard.recentActivity')}

-
-
- + {activities.length > 0 ? ( +
+ {activities.map((activity, index) => { + const Icon = activity.icon || Activity; + return ( +
+
+ +
+
+

{activity.title}

+

{activity.message}

+
+ + {new Date(activity.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+
+
+ ); + })}
- - -
+ ) : ( +
+
+ +
+ + +
+ )}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 0435384..8c76ecd 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import { Leaf, Phone, Lock, ArrowRight, Loader2, ShieldAlert } from "lucide-react"; import authService from "../services/auth.service"; diff --git a/frontend/src/pages/Otp.jsx b/frontend/src/pages/Otp.jsx deleted file mode 100644 index 3985ab3..0000000 --- a/frontend/src/pages/Otp.jsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useState, useEffect } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; -import { AnimatePresence } from "framer-motion"; -import { FiArrowLeft, FiRefreshCw, FiCheckCircle } from "react-icons/fi"; - -import OTPInput from "../components/common/OTPInput"; -import PrimaryButton from "../components/common/PrimaryButton"; -import authService from "../services/auth.service"; - - -function Otp() { - const [otp, setOtp] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [resendTimer, setResendTimer] = useState(30); - const [canResend, setCanResend] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const phoneNumber = location.state?.phoneNumber; - - useEffect(() => { - if (!phoneNumber) { - navigate("/login"); - } - }, [phoneNumber, navigate]); - - useEffect(() => { - let interval; - if (resendTimer > 0 && !canResend) { - interval = setInterval(() => { - setResendTimer((prev) => prev - 1); - }, 1000); - } else { - setCanResend(true); - } - return () => clearInterval(interval); - }, [resendTimer, canResend]); - - const handleVerify = async () => { - if (!otp || otp.length < 6) { - setError("Please enter a valid 6-digit OTP"); - return; - } - - setLoading(true); - setError(""); - - try { - await authService.verify({ - contact: phoneNumber, - code: otp, - }); - - navigate("/dashboard"); - } catch (err) { - setError(err.response?.data?.message || "Invalid OTP"); - } finally { - setLoading(false); - } - }; - - const handleResend = async () => { - if (!canResend) return; - - setLoading(true); - try { - await authService.resend({ contact: phoneNumber }); - setResendTimer(30); - setCanResend(false); - setOtp(""); - setError(""); - } catch (err) { - console.error(err); - setError("Failed to resend OTP. Please try again."); - } finally { - setLoading(false); - } - }; - - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.2, - }, - }, - }; - - const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0 }, - }; - - return ( -
- - {/* SOPHISTICATED BACKGROUND */} -
- - - {/* Floating Particles/Shapes */} - {[...Array(6)].map((_, i) => ( - - ))} -
- - - {/* Glassmorphism Card */} - - {/* Subtle top highlight */} -
- - {/* Change Number Button - Repositioned to corner */} - - - -
- -
-

- Verify OTP -

-
-

We've sent a 6-digit security code to

-
- {phoneNumber} -
-
-
- - - {error && ( - -
- ⚠️ - {error} -
-
- )} -
- - - setOtp(e.target.value)} - /> - -
- - {loading ? ( -
- - - - Verifying... -
- ) : ( - "Verify & Continue" - )} -
- -
- {canResend ? ( - - ) : ( -

- Resend code in {resendTimer}s -

- )} -
-
-
- - - FarmSmart Secure Verification - - - -
- ); -} - -export default Otp; diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 64ba515..c8ab6e4 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -1,4 +1,5 @@ import { useNavigate, Link } from "react-router-dom"; +import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import { Leaf, User, Phone, Lock, ArrowRight, Loader2, Sprout, MapPin, Home, Languages } from "lucide-react"; import authService from "../services/auth.service";// Keeping for reference if needed elsewhere, but not using here. diff --git a/frontend/src/pages/ReviewsAndTrust.jsx b/frontend/src/pages/ReviewsAndTrust.jsx index 81089ef..735a6d0 100644 --- a/frontend/src/pages/ReviewsAndTrust.jsx +++ b/frontend/src/pages/ReviewsAndTrust.jsx @@ -4,50 +4,37 @@ import authService from "../services/auth.service"; import reviewService from "../services/review.service"; const ReviewsAndTrust = () => { - const user = authService.getCurrentUser(); + const [user, setUser] = useState(authService.getCurrentUser()); const isFarmer = user?.role?.toLowerCase() === "farmer"; const [reviews, setReviews] = useState([]); const [avgRating, setAvgRating] = useState(0); useEffect(() => { - const fetchReputation = async () => { - // If farmer, get reviews about me. If buyer, get reviews I wrote? - // The original mock logic was: getReviewsByUserId(userId). Assuming this page shows "My Public Profile Reviews". - // reviewService.getReviewsByUserId retrieves reviews *for* a target user. - // reviewService.getMyReputation retrieves reviews for the *logged in* user. - + const fetchData = async () => { try { - // Determine what to show. The UI says "Manage your reputation" for farmer. - // So we want reviews WHERE targetId = me. - // reviewService.getMyReputation() calls GET /reviews/my which does exactly that. + // Fetch fresh profile + const freshUser = await authService.getProfile(); + setUser(freshUser); + + const myReviews = await reviewService.getMyReputation(); + setReviews(myReviews); - if (isFarmer) { - const myReviews = await reviewService.getMyReputation(); - // Backend returns raw array. We need to transform if needed OR the service should verify data. - // IMPORTANT: createReview (backend) might not populate reviewerId in getMyReputation? - // Let's check backend controller for getMyReputation. - setReviews(myReviews); - - // Calculate avg - const avg = reviewService.calculateAverage(myReviews); - setAvgRating(avg); - } else { - // If buyer, "Track feedback you've shared". - // This means reviews WHERE reviewerId = me. - // Do we have an endpoint for that? mocked service had getReviewsByUserId? - // Let's assume for now we just show "Reviews about me" (which might be 0 for buyer). - - const myReviews = await reviewService.getMyReputation(); - setReviews(myReviews); - const avg = reviewService.calculateAverage(myReviews); - setAvgRating(avg); - } + const avg = reviewService.calculateAverage(myReviews); + setAvgRating(avg); } catch (err) { console.error("Failed to load reputation", err); } }; - fetchReputation(); - }, [isFarmer, user?.id]); + fetchData(); + }, []); + + + // Calculate Trust Score + // Logic: 0% if not verified. If verified, based on rating (out of 100). + // If no reviews yet but verified, default to 50% (New Member Trust). + const trustScore = user?.kycStatus === 'APPROVED' + ? (reviews.length > 0 ? Math.round((avgRating / 5) * 100) : 50) + : 0; return (
@@ -55,7 +42,7 @@ const ReviewsAndTrust = () => {

Ratings, Reviews & Trust

- {isFarmer ? "Manage your reputation and seller credentials" : "Track the feedback you've shared with the community"} + {isFarmer ? "Manage your reputation and seller credentials" : "Track your community standing"}

@@ -68,12 +55,19 @@ const ReviewsAndTrust = () => { {user?.fullName?.[0] || 'U'}
-

{user?.fullName || "Mock User"}

+

{user?.fullName || "User"}

{user?.role || "FARMER"}

-
- ID Verified -
+ {user?.kycStatus === 'APPROVED' ? ( +
+ ID Verified +
+ ) : ( +
+ ID Not Verified +
+ )} + {isFarmer && avgRating >= 4.0 && (
Top Rated @@ -101,16 +95,29 @@ const ReviewsAndTrust = () => {

Trust Score

-

98%

+

0 ? "text-primary" : "text-red-500"}`}>{trustScore}%

+
-
- -
-

Verified Seller Status

-

Your profile meets all FarmSmart security standards for transparent trading.

+ {user?.kycStatus === 'APPROVED' ? ( + <> +
+ +
+

Verified {isFarmer ? "Seller" : "Buyer"} Status

+

Your profile meets all FarmSmart security standards for transparent trading.

+ + ) : ( + <> +
+ +
+

Verification Pending

+

Complete KYC verification to unlock Trust Score and Verified badges.

+ + )}
@@ -169,7 +176,7 @@ const ReviewsAndTrust = () => {
Quality score - 4.9 / 5 + {avgRating} / 5
Delivery time @@ -177,7 +184,11 @@ const ReviewsAndTrust = () => {
Recommended - 100% + + {reviews.length > 0 + ? Math.round((reviews.filter(r => r.rating >= 4).length / reviews.length) * 100) + : 0}% +
diff --git a/frontend/src/pages/logistics/LogisticsDashboard.jsx b/frontend/src/pages/logistics/LogisticsDashboard.jsx index 94300b1..24a8ba8 100644 --- a/frontend/src/pages/logistics/LogisticsDashboard.jsx +++ b/frontend/src/pages/logistics/LogisticsDashboard.jsx @@ -15,10 +15,31 @@ import { Filter, ArrowRight, Loader2, - CalendarClock + CalendarClock, + TrendingUp } from "lucide-react"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import orderService from "../../services/order.service"; +import { Bar } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend +); + const LogisticsDashboard = () => { const [activeTab, setActiveTab] = useState("marketplace"); @@ -34,10 +55,55 @@ const LogisticsDashboard = () => { estimatedDelivery: "" }); + const [revenueData, setRevenueData] = useState({ + labels: [], + datasets: [] + }); + const [totalRevenue, setTotalRevenue] = useState(0); + useEffect(() => { fetchData(); }, [activeTab]); + useEffect(() => { + if (myDeliveries.length > 0) { + calculateRevenue(); + } + }, [myDeliveries]); + + const calculateRevenue = () => { + const deliveredOrders = myDeliveries.filter(order => order.status === 'DELIVERED'); + const revenueByDate = {}; + let total = 0; + + deliveredOrders.forEach(order => { + const date = new Date(order.date).toLocaleDateString(); + const revenue = (Number(order.totalPrice) || 0) * 0.10; + total += revenue; + revenueByDate[date] = (revenueByDate[date] || 0) + revenue; + }); + + setTotalRevenue(total); + + const labels = Object.keys(revenueByDate); + const data = Object.values(revenueByDate); + + setRevenueData({ + labels, + datasets: [ + { + label: 'Revenue (10% Commission)', + data, + backgroundColor: 'rgba(16, 185, 129, 0.6)', + borderColor: 'rgba(16, 185, 129, 1)', + borderWidth: 1, + borderRadius: 8, + }, + ], + }); + }; + + const fetchData = async () => { setLoading(true); try { @@ -55,33 +121,44 @@ const LogisticsDashboard = () => { } }; - const handleAcceptOrder = async (orderId) => { - setActionLoading(orderId); - try { - await orderService.acceptOrder(orderId); - setAvailableOrders(availableOrders.filter(o => o.id !== orderId)); - alert("Order accepted successfully!"); - } catch (error) { - console.log(error) - alert("Failed to accept order"); - } finally { - setActionLoading(null); - } + // Renamed handleAcceptOrder to openAcceptModal, now it just opens modal + const openAcceptModal = (order) => { + setShowEditModal({ ...order, isAccepting: true }); + setDetailsForm({ + driverName: "", + vehicleNumber: "", + contactNumber: "", + estimatedDelivery: "" + }); }; - const handleUpdateDetails = async (e) => { + const handleModalSubmit = async (e) => { e.preventDefault(); if (!showEditModal) return; setActionLoading(showEditModal.id); + const orderId = showEditModal.id; + const isAccepting = showEditModal.isAccepting; + try { - await orderService.updateLogisticsDetails(showEditModal.id, detailsForm); - alert("Delivery details updated!"); + if (isAccepting) { + // Accept Flow + await orderService.acceptOrder(orderId); + // Then update details + await orderService.updateLogisticsDetails(orderId, detailsForm); + + setAvailableOrders(prev => prev.filter(o => o.id !== orderId)); + alert("Order accepted and details submitted!"); + } else { + // Update Flow + await orderService.updateLogisticsDetails(orderId, detailsForm); + alert("Delivery details updated!"); + fetchData(); + } setShowEditModal(null); - fetchData(); } catch (error) { - console.log(error) - alert("Failed to update details"); + console.error(error); + alert("Failed to process request"); } finally { setActionLoading(null); } @@ -101,7 +178,7 @@ const LogisticsDashboard = () => { }; const openEditModal = (order) => { - setShowEditModal(order); + setShowEditModal({ ...order, isAccepting: false }); setDetailsForm({ driverName: order.logisticsDetails?.driverName || "", vehicleNumber: order.logisticsDetails?.vehicleNumber || "", @@ -151,6 +228,12 @@ const LogisticsDashboard = () => { > My Deliveries + @@ -211,7 +294,7 @@ const LogisticsDashboard = () => { -
+
@@ -423,7 +560,11 @@ const LogisticsDashboard = () => { disabled={actionLoading === showEditModal.id} className="flex-3 py-4 bg-primary text-white rounded-2xl font-black text-xs tracking-widest uppercase hover:bg-green-600 transition-all shadow-lg shadow-primary/20" > - {actionLoading === showEditModal.id ? : "SAVE DETAILS"} + {actionLoading === showEditModal.id ? ( + + ) : ( + showEditModal.isAccepting ? "CONFIRM & ACCEPT" : "SAVE DETAILS" + )}
diff --git a/frontend/src/pages/orders/OrderHistory.jsx b/frontend/src/pages/orders/OrderHistory.jsx index 5fac496..5b0a265 100644 --- a/frontend/src/pages/orders/OrderHistory.jsx +++ b/frontend/src/pages/orders/OrderHistory.jsx @@ -33,6 +33,22 @@ const OrderHistory = () => { fetchOrders(); }, []); + const handleClaimPayment = async (orderId) => { + if (!window.confirm("Are you sure you want to claim payment for this order?")) return; + + try { + await orderService.claimPayment(orderId); + // Update local state to reflect COMPLETED + setOrders(prev => prev.map(o => + o.id === orderId ? { ...o, status: "COMPLETED" } : o + )); + alert("Payment added to your wallet successfully!"); + } catch (err) { + console.error(err); + alert("Failed to claim payment: " + (err.response?.data?.message || err.message)); + } + }; + const filteredOrders = orders.filter((order) => { const matchesSearch = order.crop?.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -193,9 +209,24 @@ const OrderHistory = () => {

Status

- - {t(`dynamic.status.${order.status.toLowerCase()}`, order.status)} - +
+ + {t(`dynamic.status.${order.status.toLowerCase()}`, order.status)} + + + {isFarmer && order.status === 'DELIVERED' && ( + + )} +
diff --git a/frontend/src/services/auth.service.js b/frontend/src/services/auth.service.js index 701a1b9..bdc8fc1 100644 --- a/frontend/src/services/auth.service.js +++ b/frontend/src/services/auth.service.js @@ -34,23 +34,8 @@ const authService = { return response.data; }, - // Verify OTP (Check code and get token) - verify: async (verifyData) => { - // verifyData: { contact, code } - const response = await api.post("/auth/verify", verifyData); - if (response.data.data && response.data.data.token) { - localStorage.setItem("token", response.data.data.token); - localStorage.setItem("user", JSON.stringify(response.data.data.user)); - } - return response.data; - }, - // Resend OTP - resend: async (resendData) => { - // resendData: { contact } - const response = await api.post("/auth/resend", resendData); - return response.data; - }, + // Upload KYC uploadKYC: async (formData) => { diff --git a/frontend/src/services/order.service.js b/frontend/src/services/order.service.js index 99ce4e2..d9e734b 100644 --- a/frontend/src/services/order.service.js +++ b/frontend/src/services/order.service.js @@ -102,6 +102,12 @@ const OrderService = { // details: { driverName, vehicleNumber, contactNumber, estimatedDelivery } const response = await api.patch(`/orders/${id}/logistics`, details); return transformOrder(response.data); + }, + + // Farmer: Claim Payment + claimPayment: async (id) => { + const response = await api.post(`/orders/${id}/claim`); + return response.data; // { message, newBalance, order } } };