From c3b80dd4358c48e00d8e865e9077962f5a37bc1b Mon Sep 17 00:00:00 2001 From: Saran Hiruthik M Date: Wed, 11 Mar 2026 02:58:38 +0530 Subject: [PATCH] deploy: final fix --- .github/workflows/ci-cd.yml | 94 +++++++++++++ backend/package-lock.json | 123 ++++++++++++++++-- backend/package.json | 4 +- backend/src/controllers/advisoryController.ts | 76 +++++++++++ backend/src/controllers/authController.ts | 51 +++++--- backend/src/controllers/demandController.ts | 6 +- .../src/controllers/instantBuyController.ts | 24 ++++ .../src/controllers/negotiationController.ts | 54 ++++++++ backend/src/controllers/orderController.ts | 87 ++++++++++++- backend/src/controllers/prices.controller.ts | 11 +- backend/src/routes/advisoryRoutes.ts | 3 +- backend/src/services/demandService.ts | 45 +++---- backend/src/services/notificationService.ts | 106 +++++++++++++++ backend/test_sms.ts | 36 +++++ frontend/src/App.jsx | 9 ++ .../src/components/common/DynamicText.jsx | 37 ++++++ .../src/components/marketplace/CropCard.jsx | 22 +++- .../src/components/schemes/AiCropDoctor.jsx | 2 +- frontend/src/hooks/useDynamicTranslation.js | 60 +++++++++ frontend/src/i18n/i18n.js | 112 +++++++++++++++- frontend/src/pages/Dashboard.jsx | 33 +++-- frontend/src/pages/Login.jsx | 4 +- frontend/src/pages/Marketplace.jsx | 17 ++- frontend/src/pages/Register.jsx | 6 +- frontend/src/pages/orders/OrderHistory.jsx | 22 +++- frontend/src/services/auth.service.js | 14 +- .../src/services/recommendation.service.js | 6 +- frontend/src/services/translation.service.js | 109 ++++++++++++++++ 28 files changed, 1066 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/ci-cd.yml create mode 100644 backend/src/services/notificationService.ts create mode 100644 backend/test_sms.ts create mode 100644 frontend/src/components/common/DynamicText.jsx create mode 100644 frontend/src/hooks/useDynamicTranslation.js create mode 100644 frontend/src/services/translation.service.js diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..c0acc64 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,94 @@ +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 + + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install Dependencies + run: npm ci + + - name: Run Tests (Jest) + run: npm test -- --passWithNoTests + + - name: Build / Type Check (TypeScript) + run: npm run build + + # ------------------------------------------------------------------ + # JOB 2: FRONTEND CI (Lint & Build) + # ------------------------------------------------------------------ + frontend-ci: + name: Frontend CI (Lint & Build) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Dependencies + run: npm ci + + - name: Lint Code (ESLint) + # Only run lint if the script exists + run: npm run lint --if-present + + - 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 + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + + steps: + - name: Trigger Render Deploy (Backend) + if: env.RENDER_DEPLOY_HOOK_BACKEND != '' + run: curl "${{ secrets.RENDER_DEPLOY_HOOK_BACKEND }}" + + - name: Trigger Render Deploy (Frontend) + if: env.RENDER_DEPLOY_HOOK_FRONTEND != '' + run: curl "${{ secrets.RENDER_DEPLOY_HOOK_FRONTEND }}" diff --git a/backend/package-lock.json b/backend/package-lock.json index ad85ad3..f1e66b2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@google/generative-ai": "^0.24.1", "@prisma/client": "^6.19.2", "@types/socket.io": "^3.0.1", + "@types/twilio": "^3.19.2", "bcrypt": "^6.0.0", "cloudinary": "^2.9.0", "cors": "^2.8.6", @@ -23,7 +24,8 @@ "mongoose": "^8.22.0", "multer": "^2.1.1", "redis": "^4.7.0", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "twilio": "^5.12.2" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -74,7 +76,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1287,7 +1288,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -1620,7 +1620,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1710,6 +1709,15 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/twilio": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@types/twilio/-/twilio-3.19.2.tgz", + "integrity": "sha512-yMEBc7xS1G4Dd4w5xvfDIJkSVVZmiGP/Lrpr4QqUus9rENPjt9BUag5NL198cO2EoJNI8Tqy8qMcKO9jd+9Ssg==", + "license": "MIT", + "dependencies": { + "twilio": "*" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -2069,6 +2077,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -2176,6 +2196,17 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -2396,7 +2427,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3016,6 +3046,12 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3642,6 +3678,26 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4096,6 +4152,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4421,7 +4490,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -6120,7 +6188,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.2", "@prisma/engines": "6.19.2" @@ -6153,6 +6220,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -6362,6 +6435,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7131,7 +7211,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7178,6 +7257,24 @@ "license": "0BSD", "optional": true }, + "node_modules/twilio": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz", + "integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7227,7 +7324,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7586,6 +7682,15 @@ } } }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index c83fe39..b3a87dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@google/generative-ai": "^0.24.1", "@prisma/client": "^6.19.2", "@types/socket.io": "^3.0.1", + "@types/twilio": "^3.19.2", "bcrypt": "^6.0.0", "cloudinary": "^2.9.0", "cors": "^2.8.6", @@ -49,6 +50,7 @@ "mongoose": "^8.22.0", "multer": "^2.1.1", "redis": "^4.7.0", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "twilio": "^5.12.2" } } diff --git a/backend/src/controllers/advisoryController.ts b/backend/src/controllers/advisoryController.ts index bb39a6c..4d65c30 100644 --- a/backend/src/controllers/advisoryController.ts +++ b/backend/src/controllers/advisoryController.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; import { Advisory } from "../models/Advisory"; +import User from "../models/User"; +import { sendAdvisoryAlert } from "../services/notificationService"; /** * GET /advisory @@ -44,9 +46,83 @@ export const getAllAdvisories = async (_: Request, res: Response): Promise export const createAdvisory = async (req: Request, res: Response): Promise => { try { const advisory = await Advisory.create(req.body); + + // NOTIFICATION: Send SMS to Farmers in corresponding State + try { + if (advisory.state) { + // Find all active farmers in that state + const farmers = await User.find({ + role: 'FARMER', + state: advisory.state, + isActive: true + }); + + console.log(`[Advisory] Found ${farmers.length} farmers in ${advisory.state} for alert.`); + + // Send SMS to each farmer (Promise.all might send too fast, so iteration is safer for rate limits but slower) + for (const farmer of farmers) { + if (farmer.phoneNumber) { + await sendAdvisoryAlert(farmer.phoneNumber, { + type: advisory.type, + title: advisory.title, + state: advisory.state + }); + } + } + console.log(`[Advisory] Alerts sent successfully.`); + } + } catch (smsErr) { + console.error("SMS Error (Advisory):", smsErr); + } + res.status(201).json({ message: "Advisory created", advisory }); } catch (error) { console.error("Create Advisory Error:", error); res.status(500).json({ message: "Server error" }); } }; + +/** + * PATCH /advisory/:id + * Update advisory (Admin) + */ +export const updateAdvisory = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const advisory = await Advisory.findByIdAndUpdate(id, req.body, { new: true }); + + if (!advisory) { + return res.status(404).json({ message: "Advisory not found" }); + } + + // NOTIFICATION: Send SMS if State is present (meaning targeted update) + try { + if (advisory.state) { + const farmers = await User.find({ + role: 'FARMER', + state: advisory.state, + isActive: true + }); + + console.log(`[Advisory Update] Found ${farmers.length} farmers in ${advisory.state} for alert.`); + + for (const farmer of farmers) { + if (farmer.phoneNumber) { + await sendAdvisoryAlert(farmer.phoneNumber, { + type: advisory.type, // e.g. "UPDATE: WEATHER" + title: `Update: ${advisory.title}`, + state: advisory.state + }); + } + } + } + } catch (smsErr) { + console.error("SMS Error (Advisory Update):", smsErr); + } + + res.json({ message: "Advisory updated", advisory }); + } catch (error) { + console.error("Update Advisory Error:", error); + res.status(500).json({ message: "Server error" }); + } +}; diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts index fcd648b..db7afce 100644 --- a/backend/src/controllers/authController.ts +++ b/backend/src/controllers/authController.ts @@ -5,6 +5,7 @@ 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'; const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key_change_me'; @@ -160,8 +161,8 @@ export const register = async (req: Request, res: Response): Promise => { const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); - // 4. Create User (Unverified initially) - await User.create({ + // 4. Create User (Verified initially) + const newUser = await User.create({ phoneNumber: phoneStr, passwordHash, role: userRole, @@ -171,16 +172,26 @@ export const register = async (req: Request, res: Response): Promise => { district, address, preferredLanguage: preferredLanguage || 'en', - isVerified: false // Explicitly unverified + isVerified: true // Verify by default }); - // 5. Generate and Save OTP - const otp = await saveOTP(phoneStr, VerificationType.PHONE); + // 5. Generate Token + const token = jwt.sign( + { userId: newUser._id, role: newUser.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); - sendResponse(res, 201, "User registered successfully. Please verify OTP.", { - requiresOtp: true, - phoneNumber: phoneStr, - debugOtp: otp + // Send Welcome SMS + await sendWelcomeMessage(phoneStr, fullName); + + const userObject = newUser.toObject(); + const { passwordHash: _, ...userWithoutPassword } = userObject; + + sendResponse(res, 201, "User registered successfully", { + user: userWithoutPassword, + token, + requiresOtp: false }); } catch (error) { @@ -217,13 +228,23 @@ export const login = async (req: Request, res: Response): Promise => { return; } - // 4. Generate and Save OTP - const otp = await saveOTP(phoneStr, VerificationType.PHONE); + // 4. Generate Token + const token = jwt.sign( + { userId: user._id, role: user.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); - sendResponse(res, 200, "Credentials valid. Please verify OTP.", { - requiresOtp: true, - phoneNumber: phoneStr, - debugOtp: otp + // Send Login Alert SMS + await sendLoginAlert(phoneStr, user.fullName || 'User'); + + const userObject = user.toObject(); + const { passwordHash: _, ...userWithoutPassword } = userObject; + + sendResponse(res, 200, "Login successful", { + user: userWithoutPassword, + token, + requiresOtp: false }); } catch (error) { diff --git a/backend/src/controllers/demandController.ts b/backend/src/controllers/demandController.ts index 5f65ace..3fd768a 100644 --- a/backend/src/controllers/demandController.ts +++ b/backend/src/controllers/demandController.ts @@ -5,8 +5,9 @@ export const getForecast = async (req: Request, res: Response): Promise => try { const crop = req.query.crop as string || 'Tomato'; const location = req.query.location as string || 'Coimbatore'; + const language = req.query.language as string || 'English'; - const data = await getDemandAnalysis(crop, location); + const data = await getDemandAnalysis(crop, location, language); // Match frontend expected shape res.json({ @@ -32,9 +33,10 @@ export const getForecast = async (req: Request, res: Response): Promise => export const getRecommendations = async (req: Request, res: Response): Promise => { try { const location = req.query.location as string || 'Coimbatore'; + const language = req.query.language as string || 'English'; // Pass the currently selected crop so AI can suggest complementary crops const currentCrop = req.query.crop as string | undefined; - const suggestions = await getCropRecommendations(location, currentCrop); + const suggestions = await getCropRecommendations(location, currentCrop, language); res.json({ location, diff --git a/backend/src/controllers/instantBuyController.ts b/backend/src/controllers/instantBuyController.ts index 906dfa6..cd53d25 100644 --- a/backend/src/controllers/instantBuyController.ts +++ b/backend/src/controllers/instantBuyController.ts @@ -2,7 +2,9 @@ import { Response } from "express"; import { Order } from "../models/Order"; import { Negotiation } from "../models/Negotiation"; import { Crop } from "../models/Crop"; +import User from "../models/User"; import { AuthRequest } from "../middleware/authMiddleware"; +import { sendNewOrderAlert } from "../services/notificationService"; /** * POST /orders/instant-buy @@ -102,6 +104,28 @@ export const instantBuy = async (req: AuthRequest, res: Response): Promise .populate("buyerId", "fullName role") .populate("farmerId", "fullName role"); + // NOTIFICATION: Send SMS to Farmer (New Order via Instant Buy) + try { + const farmer = await User.findById(crop.farmerId); + const buyer = await User.findById(req.user.id); + + console.log(`[InstantBuy] Attempting SMS to Farmer: ${farmer?.phoneNumber}`); + + if (farmer && farmer.phoneNumber) { + await sendNewOrderAlert(farmer.phoneNumber, { + buyerName: buyer?.fullName || 'Buyer (Instant)', + crop: crop.name || 'Crop', + quantity: quantity, + unit: crop.unit || 'kg' + }); + console.log(`[InstantBuy] SMS Sent Successfully`); + } else { + console.warn(`[InstantBuy] Farmer phone missing. Cannot send SMS.`); + } + } catch (smsErr) { + console.error("SMS Error in InstantBuy:", smsErr); + } + res.status(201).json(populatedOrder); } catch (error: any) { diff --git a/backend/src/controllers/negotiationController.ts b/backend/src/controllers/negotiationController.ts index 821fc2f..a58fdfa 100644 --- a/backend/src/controllers/negotiationController.ts +++ b/backend/src/controllers/negotiationController.ts @@ -2,6 +2,8 @@ import { Request, Response } from "express"; import { Negotiation } from "../models/Negotiation"; import { AuthRequest } from "../middleware/authMiddleware"; import { getIO } from "../socket"; +import User from "../models/User"; +import { sendNegotiationAlert } from "../services/notificationService"; import { Crop } from "../models/Crop"; @@ -61,6 +63,21 @@ export const startNegotiation = async ( ], }); + // NOTIFICATION: Send SMS to Farmer + try { + const farmer = await User.findById(farmerId); + const currentUser = await User.findById(req.user!.id); + if (farmer && farmer.phoneNumber) { + await sendNegotiationAlert( + farmer.phoneNumber, + 'NEW_OFFER', + { name: currentUser?.fullName || 'Buyer', crop: crop.name, price: pricePerUnit } + ); + } + } catch (smsError) { + console.error("SMS Error:", smsError); + } + try { const io = getIO(); io.to(`user_${farmerId}`).emit('negotiation:new', negotiation); @@ -145,6 +162,43 @@ export const respondToNegotiation = async ( .populate("buyerId", "fullName phoneNumber") .populate("farmerId", "fullName phoneNumber"); + // NOTIFICATION: Send SMS + try { + if (populatedNegotiation) { + // Determine Recipient (The other party) + const recipient = isFarmer ? populatedNegotiation.buyerId : populatedNegotiation.farmerId; + const senderName = isFarmer ? (populatedNegotiation.farmerId as any).fullName : (populatedNegotiation.buyerId as any).fullName; + const recipientPhone = (recipient as any).phoneNumber; + const cropName = (populatedNegotiation.cropId as any).name; + + if (recipientPhone) { + if (action === "COUNTER") { + await sendNegotiationAlert(recipientPhone, 'COUNTER_OFFER', { + name: senderName, + crop: cropName, + price: pricePerUnit + }); + } else if (action === "ACCEPT") { + // Notify the one who made the offer (recipent) that *I* accepted + await sendNegotiationAlert(recipientPhone, 'ACCEPTED', { + name: senderName, + crop: cropName, + price: negotiation.agreedPrice + }); + } else if (action === "REJECT") { + // Notify user their offer was rejected + await sendNegotiationAlert(recipientPhone, 'REJECTED', { + name: senderName, + crop: cropName, + price: 0 + }); + } + } + } + } catch (smsErr) { + console.error("SMS Error:", smsErr); + } + try { const io = getIO(); // Ensure populatedNegotiation exists and has _id diff --git a/backend/src/controllers/orderController.ts b/backend/src/controllers/orderController.ts index d0d48c8..c0dc157 100644 --- a/backend/src/controllers/orderController.ts +++ b/backend/src/controllers/orderController.ts @@ -3,7 +3,9 @@ import { Types } from "mongoose"; import { Order } from "../models/Order"; import { Negotiation } from "../models/Negotiation"; import { Crop } from "../models/Crop"; +import User from "../models/User"; import { AuthRequest } from "../middleware/authMiddleware"; +import { sendNewOrderAlert, sendOrderStatusAlert } from "../services/notificationService"; const ORDER_STATUSES = ["CREATED", "CONFIRMED", "SHIPPED", "DELIVERED", "COMPLETED"] as const; type OrderStatus = (typeof ORDER_STATUSES)[number]; @@ -37,7 +39,7 @@ export const createOrder = async (req: AuthRequest, res: Response): Promise // Prevent Duplicate Orders for the same negotiation const existingOrder = await Order.findOne({ negotiationId: negotiation._id }); if (existingOrder) { - // ... existing code ... + console.log(`[OrderController] Duplicate order found for negotiation ${negotiation._id}. Returning existing order without sending SMS.`); const populatedExisting = await Order.findById(existingOrder._id) .populate("cropId", "name") .populate("buyerId", "fullName role") @@ -78,6 +80,31 @@ export const createOrder = async (req: AuthRequest, res: Response): Promise .populate("buyerId", "fullName role phoneNumber") .populate("farmerId", "fullName role phoneNumber"); + // NOTIFICATION: Send SMS to Farmer (New Order) + try { + console.log(`[OrderController] Begin SMS logic for Order: ${order._id}`); + // Fetch fresh User documents to ensure we have phone numbers + const farmer = await User.findById(order.farmerId); + const buyer = await User.findById(order.buyerId); + + // Debug Logs + if (!farmer) console.warn(`[OrderController] Farmer not found for ID: ${order.farmerId}`); + if (!farmer?.phoneNumber) console.warn(`[OrderController] Farmer has no phone number: ${order.farmerId}`); + + if (farmer && farmer.phoneNumber) { + console.log(`[OrderController] Sending New Order SMS to Farmer: ${farmer.phoneNumber}`); + await sendNewOrderAlert(farmer.phoneNumber, { + buyerName: buyer?.fullName || 'Buyer', + crop: crop.name || 'Crop', + quantity: order.quantity, + unit: 'kg' // Assuming default kg, or can be fetched from Crop + }); + console.log(`[OrderController] SMS sent successfully.`); + } + } catch (smsErr) { + console.error("[OrderController] SMS Error:", smsErr); + } + return res.status(201).json(populatedOrder); } catch (error: any) { if (error.name === "ValidationError" || error.name === "CastError") { @@ -198,6 +225,33 @@ export const acceptOrder = async (req: AuthRequest, res: Response): Promise .populate("farmerId", "fullName role phoneNumber") .populate("logisticsProviderId", "fullName role phoneNumber"); + // NOTIFICATION: Send SMS to Farmer and Buyer (Order Accepted) + try { + const farmer = await User.findById(order.farmerId); + const buyer = await User.findById(order.buyerId); + const crop = await Crop.findById(order.cropId); + + const cropName = crop?.name || 'Crop'; + + if (farmer && farmer.phoneNumber) { + await sendOrderStatusAlert(farmer.phoneNumber, { + orderId: order._id.toString(), + status: "CONFIRMED", + crop: cropName + }); + } + + if (buyer && buyer.phoneNumber) { + await sendOrderStatusAlert(buyer.phoneNumber, { + orderId: order._id.toString(), + status: "CONFIRMED", + crop: cropName + }); + } + } catch (smsErr) { + console.error("SMS Error:", smsErr); + } + return res.json(populated); } catch (err) { return res.status(500).json({ message: "Server error" }); @@ -276,6 +330,37 @@ export const updateOrderStatus = async (req: AuthRequest, res: Response): Promis await order.save(); + // NOTIFICATION: Send SMS to Buyer and Farmer + try { + const buyer = await User.findById(order.buyerId); + const farmer = await User.findById(order.farmerId); + const crop = await Crop.findById(order.cropId); + + const cropName = crop?.name || 'Crop'; + const status = nextStatus; + + // Notify Buyer + if (buyer && buyer.phoneNumber) { + await sendOrderStatusAlert(buyer.phoneNumber, { + orderId: order._id.toString(), + status: status, + crop: cropName + }); + } + + // Notify Farmer + if (farmer && farmer.phoneNumber) { + await sendOrderStatusAlert(farmer.phoneNumber, { + orderId: order._id.toString(), + status: status, + crop: cropName + }); + } + + } catch (smsErr) { + console.error("SMS Error:", smsErr); + } + return res.json(order); } catch { return res.status(500).json({ message: "Server error" }); diff --git a/backend/src/controllers/prices.controller.ts b/backend/src/controllers/prices.controller.ts index 52e87bd..eb67e59 100644 --- a/backend/src/controllers/prices.controller.ts +++ b/backend/src/controllers/prices.controller.ts @@ -139,14 +139,9 @@ export async function getForecastAnalysis(req: Request, res: Response) { if (query) { forecast = await handleChatForecast(query, crop, district, currentPrice, language); } else { - // Logic for the original simple button if no specific query - const prediction = await getPricePrediction(crop, district, currentPrice); - const now = new Date(); - const targetMonth = now.getMonth() + 2; - const targetYear = now.getFullYear(); - const monthName = new Date(targetYear, targetMonth - 1).toLocaleString('default', { month: 'long' }); - - forecast = `Based on our Random Forest ML model, we predict the price for ${crop} in ${monthName} ${targetYear} in ${district} will be around ₹${prediction!.predicted_price}/kg.`; + // Use a default query to trigger the LLM translation capability + const defaultQuery = `What is the price prediction for ${crop} in ${district}?`; + forecast = await handleChatForecast(defaultQuery, crop, district, currentPrice, language); } return res.status(200).json({ forecast }); diff --git a/backend/src/routes/advisoryRoutes.ts b/backend/src/routes/advisoryRoutes.ts index 270cc0a..58d152e 100644 --- a/backend/src/routes/advisoryRoutes.ts +++ b/backend/src/routes/advisoryRoutes.ts @@ -1,11 +1,12 @@ import { Router } from "express"; -import { getAdvisories, createAdvisory, getAllAdvisories } from "../controllers/advisoryController"; +import { getAdvisories, createAdvisory, getAllAdvisories, updateAdvisory } from "../controllers/advisoryController"; import { authenticate, adminOnly } from "../middleware/authMiddleware"; const router = Router(); router.get("/", getAdvisories); router.post("/", authenticate, adminOnly, createAdvisory); +router.patch("/:id", authenticate, adminOnly, updateAdvisory); router.get("/admin/all", authenticate, adminOnly, getAllAdvisories); // AI Crop Doctor diff --git a/backend/src/services/demandService.ts b/backend/src/services/demandService.ts index 122a6fa..a845b29 100644 --- a/backend/src/services/demandService.ts +++ b/backend/src/services/demandService.ts @@ -11,7 +11,7 @@ const getGroq = () => { const MODEL_NAME = "llama-3.3-70b-versatile"; -export const getDemandAnalysis = async (cropName: string, location: string) => { +export const getDemandAnalysis = async (cropName: string, location: string, language: string = 'English') => { // 1. Search for crops with this name in the DB const cropsFilter: any = { name: new RegExp(cropName, "i") }; const crops = await Crop.find(cropsFilter); @@ -99,54 +99,50 @@ export const getDemandAnalysis = async (cropName: string, location: string) => { - If price is rising and predicted to go higher -> Wait. - If price is likely to fall or demand is high now -> Sell. - If no active buyers -> Wait (unless price is crashing). + 3. Ensure the explanation and reason are in "${language}" language. - Return strictly valid JSON: + Return ONLY strictly valid JSON (no markdown, no extra text): { "explanation": "...", "sellRecommendation": { - "action": "...", - "reason": "...", - "trend": "..." + "action": "Sell Now" | "Wait", + "reason": "..." } } `; - const completion = await groq.chat.completions.create({ + const chatCompletion = await groq.chat.completions.create({ messages: [{ role: 'user', content: prompt }], model: MODEL_NAME, - response_format: { type: "json_object" } - }, { timeout: 15000 }); // 15 second timeout + temperature: 0.1, + }); - console.log(`[DemandService] AI Demand response received for ${cropName}`); - const aiData = JSON.parse(completion.choices[0].message.content || '{}'); + const content = chatCompletion.choices[0]?.message?.content || '{}'; + const cleanContent = content.replace(/```json/g, '').replace(/```/g, '').trim(); + const jsonResponse = JSON.parse(cleanContent); + + if (jsonResponse.explanation) explanation = jsonResponse.explanation; + if (jsonResponse.sellRecommendation) sellRecommendation = jsonResponse.sellRecommendation; - if (aiData.explanation) explanation = aiData.explanation; - if (aiData.sellRecommendation) sellRecommendation = aiData.sellRecommendation; } catch (error) { - console.error('[DemandService] Groq API Error (Forecast):', error); + console.error('Error fetching Groq demand analysis:', error); + // Fallback to heuristic values defined above } } return { demandLevel, - activeBuyers: activeNegotiations, - totalSupply, explanation, sellRecommendation, + activeBuyers: activeNegotiations, + totalSupply, currentPrice, priceTrend: priceTrendLabel, predictedPrice: prediction ? prediction.predicted_price : null }; }; -// Smart fallback recommendations — crop-aware so they don't always say Tomato/Onion/Potato -const CROP_ROTATION_FALLBACK: Record> = { - tomato: [{ name: 'Onion', reason: 'Tomato and onion share compatible soil conditions and rotation cycle', suitability: '82%' }, { name: 'Beans', reason: 'Legumes fix nitrogen after tomato harvest, improving soil', suitability: '78%' }, { name: 'Cabbage', reason: 'Cole crops grow well in post-tomato soil with proper pH', suitability: '71%' }], - onion: [{ name: 'Tomato', reason: 'Tomatoes thrive after onions and repel pests naturally', suitability: '85%' }, { name: 'Carrot', reason: 'Carrots and onions are great companion crops', suitability: '79%' }, { name: 'Maize', reason: 'Maize provides good windbreak for next season onion fields', suitability: '70%' }], - potato: [{ name: 'Wheat', reason: 'Wheat is an ideal crop rotation partner after potato harvest', suitability: '83%' }, { name: 'Beans', reason: 'Beans fix nitrogen depleted by potatoes', suitability: '80%' }, { name: 'Cauliflower', reason: 'Cauliflower benefits from the loose soil left after potato digging', suitability: '74%' }], - rice: [{ name: 'Wheat', reason: 'Classic rice-wheat rotation maximizes yield per season in India', suitability: '90%' }, { name: 'Mustard', reason: 'Mustard thrives in post-rice fields with residual moisture', suitability: '82%' }, { name: 'Gram', reason: 'Chickpea/gram is ideal nitrogen-fixer after rice in Rabi season', suitability: '77%' }], - wheat: [{ name: 'Rice', reason: 'Rice-wheat rotation is the backbone of Indian agriculture', suitability: '88%' }, { name: 'Sugarcane', reason: 'Sugarcane benefits from wheat-prepared soils', suitability: '74%' }, { name: 'Soybean', reason: 'Soybean fixes nitrogen and prepares soil for next wheat crop', suitability: '80%' }], - maize: [{ name: 'Soybean', reason: 'Soybean restores nitrogen after maize drain', suitability: '85%' }, { name: 'Wheat', reason: 'Wheat follows maize well in Rabi season', suitability: '80%' }, { name: 'Sunflower', reason: 'Sunflower is a good break crop after maize', suitability: '73%' }], +const CROP_ROTATION_FALLBACK: Record = { sugarcane: [{ name: 'Onion', reason: 'Onion intercropping in sugarcane ratoon is highly profitable', suitability: '80%' }, { name: 'Potato', reason: 'Potato grows well in inter-rows during early sugarcane growth', suitability: '75%' }, { name: 'Garlic', reason: 'Garlic is an excellent intercrop with sugarcane in Tamil Nadu', suitability: '72%' }], cotton: [{ name: 'Wheat', reason: 'Wheat is ideal after cotton harvest in north and central India', suitability: '84%' }, { name: 'Gram', reason: 'Gram fixes nitrogen depleted heavily by cotton', suitability: '81%' }, { name: 'Sunflower', reason: 'Sunflower rotation breaks cotton bollworm cycle', suitability: '75%' }], groundnut: [{ name: 'Maize', reason: 'Maize works well after groundnut as a cereal break crop', suitability: '83%' }, { name: 'Sorghum', reason: 'Sorghum is highly suitable following groundnut harvest', suitability: '79%' }, { name: 'Wheat', reason: 'Wheat thrives in nitrogen-enriched post-groundnut soil', suitability: '77%' }], @@ -168,7 +164,7 @@ const DEFAULT_FALLBACK = [ { name: 'Potato', reason: 'Consistent demand and pricing year-round', suitability: '75%' } ]; -export const getCropRecommendations = async (location: string, currentCrop?: string) => { +export const getCropRecommendations = async (location: string, currentCrop?: string, language: string = 'English') => { const groq = getGroq(); if (!groq) { @@ -196,6 +192,7 @@ export const getCropRecommendations = async (location: string, currentCrop?: str Consider: local soil, climate, market demand in India, seasonal suitability, crop rotation benefits, and profitability. Give actionable, specific reasons. Suitability should be a realistic percentage (60%-95%). + Ensure the reasons and crop names (if appropriate) are in "${language}" language. Return ONLY strictly valid JSON (no markdown, no extra text): { diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts new file mode 100644 index 0000000..3e5700f --- /dev/null +++ b/backend/src/services/notificationService.ts @@ -0,0 +1,106 @@ +import twilio from 'twilio'; + +const accountSid = process.env.TWILIO_ACCOUNT_SID; +const authToken = process.env.TWILIO_AUTH_TOKEN; +const fromPhoneNumber = process.env.TWILIO_PHONE_NUMBER; + +const getClient = () => { + if (accountSid && authToken) { + return twilio(accountSid, authToken); + } + console.warn("Twilio Client Init Failed: Missing SID or Token", { + hasSid: !!accountSid, + hasToken: !!authToken, + sidFirst4: accountSid ? accountSid.substring(0,4) : 'null' + }); + return null; +}; + +export const sendSMS = async (to: string, body: string): Promise => { + const client = getClient(); + if (!client) { + console.warn('Twilio credentials not configured. SMS not sent.'); + console.log(`[SMS SIMULATION] To: ${to}, Body: ${body}`); + return false; + } + + // Basic formatting for Indian numbers if standard 10-digit + // 1. Remove all whitespace and non-numeric chars except '+' + let formattedTo = to.replace(/[\s-]/g, ''); + + // 2. If it's a simple 10-digit number (e.g. 9876543210), assume +91 + if (/^\d{10}$/.test(formattedTo)) { + formattedTo = `+91${formattedTo}`; + } + // 3. If it starts with 91 and is 12 digits long (e.g. 919876543210), ensure it has + + else if (/^91\d{10}$/.test(formattedTo)) { + formattedTo = `+${formattedTo}`; + } + // 4. Ensure it starts with + if it's missing (general case) + else if (!formattedTo.startsWith('+')) { + formattedTo = `+${formattedTo}`; + } + + try { + const message = await client.messages.create({ + body: body, + from: fromPhoneNumber, + to: formattedTo + }); + console.log(`SMS sent successfully. SID: ${message.sid} | To: ${formattedTo}`); + return true; + } catch (error: any) { + console.error('Error sending SMS:', error.message); + if (error.code === 21211) { + console.error(`[Twilio Error 21211] Invalid Phone Number: '${formattedTo}'. \nTip: If you are on a Twilio Trial account, you can ONLY send SMS to Verified Caller IDs. Verify your number here: https://console.twilio.com/us1/develop/phone-numbers/manage/verified`); + } + return false; + } +}; + +export const sendWelcomeMessage = async (to: string, name: string) => { + const message = `Welcome to FarmSmart, ${name}! Your account has been successfully created. We are excited to have you on board.`; + return sendSMS(to, message); +}; + +export const sendLoginAlert = async (to: string, name: string) => { + const time = new Date().toLocaleTimeString('en-IN', { timeZone: 'Asia/Kolkata' }); + const message = `Login Alert: Hello ${name}, your FarmSmart account was accessed at ${time}. If this wasn't you, please secure your account.`; + return sendSMS(to, message); +}; + +export const sendNegotiationAlert = async (to: string, type: 'NEW_OFFER' | 'COUNTER_OFFER' | 'ACCEPTED' | 'REJECTED', details: { name: string, crop: string, price: number }) => { + let message = ''; + switch (type) { + case 'NEW_OFFER': + message = `FarmSmart: New Offer! ${details.name} has placed a bid of ₹${details.price} on your ${details.crop}.`; + break; + case 'COUNTER_OFFER': + message = `FarmSmart: Counter Offer! ${details.name} has countered your offer for ${details.crop} to ₹${details.price}.`; + break; + case 'ACCEPTED': + message = `FarmSmart: Deal Accepted! Your negotiation for ${details.crop} with ${details.name} has been finalized @ ₹${details.price}.`; + break; + case 'REJECTED': + message = `FarmSmart: Deal Rejected. Your offer for ${details.crop} was declined by ${details.name}.`; + break; + } + return sendSMS(to, message); +}; + +export const sendNewOrderAlert = async (to: string, details: { buyerName: string, crop: string, quantity: number, unit: string }) => { + const message = `FarmSmart: New Order! ${details.buyerName} has placed an order for ${details.quantity} ${details.unit} of ${details.crop}. Check dashboard for details.`; + return sendSMS(to, message); +}; + +export const sendOrderStatusAlert = async (to: string, details: { orderId: string, status: string, crop: string }) => { + const message = `FarmSmart: Order Update! Your order for ${details.crop} (ID: ...${details.orderId.slice(-4)}) is now ${details.status}.`; + return sendSMS(to, message); +}; + +export const sendAdvisoryAlert = async (to: string, details: { type: string, title: string, state?: string }) => { + let message = `FarmSmart Advisory: ${details.type.toUpperCase()}`; + if (details.state) message += ` for ${details.state}`; + message += ` - ${details.title}. Check app for details.`; + return sendSMS(to, message); +}; diff --git a/backend/test_sms.ts b/backend/test_sms.ts new file mode 100644 index 0000000..fb2aca8 --- /dev/null +++ b/backend/test_sms.ts @@ -0,0 +1,36 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +// Load .env explicitly +dotenv.config({ path: path.join(__dirname, '.env') }); + +import { sendWelcomeMessage, sendSMS } from './src/services/notificationService'; + +const runTest = async () => { + console.log('Testing SMS functionality...'); + console.log('TWILIO_ACCOUNT_SID present:', !!process.env.TWILIO_ACCOUNT_SID); + console.log('TWILIO_AUTH_TOKEN present:', !!process.env.TWILIO_AUTH_TOKEN); + console.log('TWILIO_PHONE_NUMBER:', process.env.TWILIO_PHONE_NUMBER); + + + // Use a hardcoded number for testing if you want, or just log what would happen + const testPhone = process.argv[2]; + if (!testPhone) { + console.error('Please provide a phone number as an argument. e.g., npx ts-node test_sms.ts +1234567890'); + return; + } + + console.log(`Attempting to send SMS to: ${testPhone}`); + + // Test basic SMS + const result = await sendSMS(testPhone, "Test message from FarmSmart Backend Debugger"); + console.log('Direct SMS Result:', result); + + if (result) { + console.log('Direct SMS sent successfully!'); + } else { + console.error('Direct SMS failed.'); + } +}; + +runTest(); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2437273..d67700d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -56,6 +56,15 @@ function App() { useEffect(() => { const initLanguage = async () => { + // Prioritize local preference over user profile default to prevent resetting on refresh + const savedLang = localStorage.getItem('i18nextLng'); + if (savedLang) { + if (i18n.language !== savedLang) { + i18n.changeLanguage(savedLang); + } + return; + } + const user = authService.getCurrentUser(); if (user && user.preferredLanguage) { if (i18n.language !== user.preferredLanguage) { diff --git a/frontend/src/components/common/DynamicText.jsx b/frontend/src/components/common/DynamicText.jsx new file mode 100644 index 0000000..551b858 --- /dev/null +++ b/frontend/src/components/common/DynamicText.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDynamicTranslation } from '../../hooks/useDynamicTranslation'; + +/** + * Component to render text that is automatically translated + * @param {string} text - The text to translate + * @param {string} className - CSS classes + * @param {string} as - Element type (p, span, div, h1, etc.) + * @param {string} contextPrefix - Optional i18n key prefix to check first (e.g. "dynamic.crops") + */ +const DynamicText = ({ text, className = "", as: Component = "span", contextPrefix }) => { + const { t, i18n } = useTranslation(); + + // 1. Check static dictionary first if prefix provided + let staticTranslation = null; + if (contextPrefix && text) { + const key = `${contextPrefix}.${text.toLowerCase()}`; + if (i18n.exists(key)) { + staticTranslation = t(key); + } + } + + // 2. Use hook for dynamic translation (only if static missing) + const translated = useDynamicTranslation(staticTranslation ? "" : text, 'en'); + + // Final text to display + const displayText = staticTranslation || translated || text; + + return ( + + {displayText} + + ); +}; + +export default DynamicText; diff --git a/frontend/src/components/marketplace/CropCard.jsx b/frontend/src/components/marketplace/CropCard.jsx index ca8437e..c6e2f13 100644 --- a/frontend/src/components/marketplace/CropCard.jsx +++ b/frontend/src/components/marketplace/CropCard.jsx @@ -1,10 +1,15 @@ import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import authService from "../../services/auth.service"; +import DynamicText from "../common/DynamicText"; import { MapPin, Scale, ChevronRight, Edit, Trash2, Award, Gem, Sprout } from "lucide-react"; const CropCard = ({ crop, onDelete }) => { + const { t } = useTranslation(); const navigate = useNavigate(); + // Removed getTranslatedCropName helper as we use DynamicText now + // Check ownership const currentUser = authService.getCurrentUser(); const currentUserId = currentUser?._id || currentUser?.id; @@ -54,7 +59,7 @@ const CropCard = ({ crop, onDelete }) => { {/* Price Tag */}
- Price + {t('common.price')}
₹{crop.price || crop.finalPrice || crop.basePrice} / {crop.quantityUnit || crop.unit || 'kg'} @@ -65,7 +70,7 @@ const CropCard = ({ crop, onDelete }) => {
- Grade {crop.qualityGrade || crop.quality || 'B'} + {t('common.grade')} {crop.qualityGrade || crop.quality || 'B'}
@@ -74,7 +79,12 @@ const CropCard = ({ crop, onDelete }) => {
-

{crop.name || crop.cropName}

+ {/* Variety Tag */} {crop.variety || 'Organic'} @@ -83,20 +93,20 @@ const CropCard = ({ crop, onDelete }) => {
- {crop.location?.district || 'Unknown'}, {crop.location?.state || 'Location'} + {crop.location?.district || t('dynamic.unknown')} , {crop.location?.state || t('common.location')}
- Quantity + {t('common.quantity')}
{crop.quantity} {crop.quantityUnit || crop.unit || 'kg'}
- Listed On + {t('common.listedOn')}
{new Date(crop.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
diff --git a/frontend/src/components/schemes/AiCropDoctor.jsx b/frontend/src/components/schemes/AiCropDoctor.jsx index 816bfdc..5554108 100644 --- a/frontend/src/components/schemes/AiCropDoctor.jsx +++ b/frontend/src/components/schemes/AiCropDoctor.jsx @@ -37,7 +37,7 @@ const AiCropDoctor = () => { }; return ( -
+
diff --git a/frontend/src/hooks/useDynamicTranslation.js b/frontend/src/hooks/useDynamicTranslation.js new file mode 100644 index 0000000..b5366fc --- /dev/null +++ b/frontend/src/hooks/useDynamicTranslation.js @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react'; +import TranslationService from '../services/translation.service'; +import { useTranslation } from 'react-i18next'; + +/** + * Hook to translate dynamic content on the fly + * @param {string} text - The text to translate + * @param {string} sourceLang - The source language (default 'en') + * @returns {string} - The translated text (or original while loading) + */ +export const useDynamicTranslation = (text, sourceLang = 'en') => { + const { i18n } = useTranslation(); + + // Try to get cached value immediately for initial state + const getInitialState = () => { + const cached = TranslationService.getCached(text, i18n.language, sourceLang); + return cached || text; // Return cached if available, else original text + }; + + const [translatedText, setTranslatedText] = useState(getInitialState); + const [currentLang, setCurrentLang] = useState(i18n.language); + + // Update effect to handle language changes or text changes + useEffect(() => { + let isMounted = true; + + const translate = async () => { + // 1. Check if language matches or text empty + if (!text || i18n.language === sourceLang) { + if (isMounted) setTranslatedText(text); + return; + } + + // 2. Check cache first (synchronous check again to be sure) + const cached = TranslationService.getCached(text, i18n.language, sourceLang); + if (cached) { + if (isMounted) setTranslatedText(cached); + return; + } + + // 3. Perform async translation + try { + const result = await TranslationService.translate(text, i18n.language, sourceLang); + if (isMounted) setTranslatedText(result); + } catch (err) { + if (isMounted) setTranslatedText(text); + } + }; + + translate(); + + return () => { + isMounted = false; + }; + }, [text, i18n.language, sourceLang]); + + return translatedText; +}; + +export default useDynamicTranslation; diff --git a/frontend/src/i18n/i18n.js b/frontend/src/i18n/i18n.js index d7b2a20..914fd73 100644 --- a/frontend/src/i18n/i18n.js +++ b/frontend/src/i18n/i18n.js @@ -1,7 +1,15 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +// Get language from localStorage or default to English +const savedLanguage = localStorage.getItem('i18nextLng') || "en"; + i18n.use(initReactI18next).init({ + lng: savedLanguage, + fallbackLng: "en", + interpolation: { + escapeValue: false + }, resources: { en: { translation: { @@ -21,7 +29,47 @@ i18n.use(initReactI18next).init({ viewAll: "View all", newMessage: "New Message", auctionUpdate: "Auction Update", - negotiationAlert: "Negotiation Alert" + negotiationAlert: "Negotiation Alert", + orders: { + manageSales: "Manage and track sales of your crops.", + viewPurchases: "View and track your previous crop purchases.", + searchPlaceholder: "Search orders..." + }, + price: "Price", + quantity: "Quantity", + listedOn: "Listed On", + grade: "Grade", + location: "Location" + }, + dynamic: { + crops: { + tomato: "Tomato", + potato: "Potato", + onion: "Onion", + wheat: "Wheat", + rice: "Rice", + "basmati rice": "Basmati Rice", + "sona masuri rice": "Sona Masuri Rice", + chilli: "Chilli", + okra: "Okra", + "bitter gourd": "Bitter Gourd", + "unknown crop": "Unknown Crop", + unknown: "Unknown" + }, + status: { + created: "Created", + confirmed: "Confirmed", + delivered: "Delivered", + resolved: "Resolved", + rejected: "Rejected", + cancelled: "Cancelled", + pending: "Pending" + }, + roles: { + farmer: "Farmer", + buyer: "Buyer", + logistics: "Logistics" + } }, nav: { dashboard: "Dashboard", @@ -80,7 +128,8 @@ i18n.use(initReactI18next).init({ }, dashboard: { loading: "Loading your dashboard...", - welcome: "Welcome back, {{name}}! Here's what's happening today.", + welcome: "Welcome back, {{name}}!", + welcomeSub: "Here's a summary of your agricultural activities and market insights.", totalCrops: "Total Crops", totalRevenue: "Total Revenue", activeBids: "Active Bids", @@ -103,6 +152,12 @@ i18n.use(initReactI18next).init({ noMatching: "No matching", revenueComingSoon: "Revenue Analytics (Coming Soon)", activityComingSoon: "Recent Activity (Coming Soon)", + revenueAnalytics: "Revenue Analytics", + recentActivity: "Recent Activity", + analyticsLoading: "Analytics Module Loading...", + detailedCharts: "Detailed charts coming soon to this view.", + noRecentActivity: "No Recent Activity", + latestActions: "Your latest actions will appear here.", enterQuantity: "Enter quantity (kg) to contribute:", joinedPool: "Successfully joined the institutional batch!", failedToJoin: "Failed to join pool" @@ -348,7 +403,47 @@ i18n.use(initReactI18next).init({ viewAll: "அனைத்தையும் காண்க", newMessage: "புதிய செய்தி", auctionUpdate: "ஏல அறிவிப்பு", - negotiationAlert: "பேச்சுவார்த்தை எச்சரிக்கை" + negotiationAlert: "பேச்சுவார்த்தை எச்சரிக்கை", + orders: { + manageSales: "உங்கள் பயிர் விற்பனையை நிர்வகிக்கவும் கண்காணிக்கவும்.", + viewPurchases: "உங்கள் முந்தைய பயிர் கொள்முதல்களைப் பார்க்கவும் கண்காணிக்கவும்.", + searchPlaceholder: "ஆர்டர்களை தேடு..." + }, + price: "விலை", + quantity: "அளவு", + listedOn: "பட்டியலிடப்பட்ட தேதி", + grade: "தரம்", + location: "இடம்" + }, + dynamic: { + crops: { + tomato: "தக்காளி", + potato: "உருளைக்கிழங்கு", + onion: "வெங்காயம்", + wheat: "கோதுமை", + rice: "அரிசி", + "basmati rice": "பாசுமதி அரிசி", + "sona masuri rice": "சோனா மசூரி அரிசி", + chilli: "மிளகாய்", + okra: "வெண்டைக்காய்", + "bitter gourd": "பாகற்காய்", + unknown: "தெரியாத", + "unknown crop": "தெரியாத பயிர்" + }, + status: { + created: "உருவாக்கப்பட்டது", + confirmed: "உறுதிப்படுத்தப்பட்டது", + delivered: "வழங்கப்பட்டது", + resolved: "தீர்க்கப்பட்டது", + rejected: "நிராகரிக்கப்பட்டது", + cancelled: "ரத்து செய்யப்பட்டது", + pending: "நிலுவையில் உள்ளது" + }, + roles: { + farmer: "விவசாயி", + buyer: "வாங்குபவர்", + logistics: "தளவாடங்கள்" + } }, nav: { dashboard: "முகப்பு", @@ -403,7 +498,8 @@ i18n.use(initReactI18next).init({ }, dashboard: { loading: "உங்கள் டாஷ்போர்டு ஏற்றப்படுகிறது...", - welcome: "மீண்டும் வருக, {{name}}! இன்று என்ன நடக்கிறது.", + welcome: "மீண்டும் வருக, {{name}}!", + welcomeSub: "உங்கள் விவசாய நடவடிக்கைகள் மற்றும் சந்தை நுண்ணறிவுகளின் சுருக்கம் இங்கே.", totalCrops: "மொத்த பயிர்கள்", totalRevenue: "மொத்த வருவாய்", activeBids: "செயல் ஏலங்கள்", @@ -426,6 +522,12 @@ i18n.use(initReactI18next).init({ noMatching: "பொருந்தும் இல்லை", revenueComingSoon: "வருவாய் பகுப்பாய்வு (விரைவில்)", activityComingSoon: "சமீபத்திய செயல்பாடு (விரைவில்)", + revenueAnalytics: "வருவாய் பகுப்பாய்வு", + recentActivity: "சமீபத்திய செயல்பாடு", + analyticsLoading: "பகுப்பாய்வு தொகுதி ஏற்றப்படுகிறது...", + detailedCharts: "விரிவான வரைபடங்கள் விரைவில் பயன்பாட்டிற்கு வரும்.", + noRecentActivity: "சமீபத்திய செயல்பாடு இல்லை", + latestActions: "உங்கள் சமீபத்திய நடவடிக்கைகள் இங்கே தோன்றும்.", enterQuantity: "பங்களிக்க அளவை (கிலோ) உள்ளிடவும்:", joinedPool: "நிறுவன தொகுப்பில் வெற்றிகரமாக இணைந்தது!", failedToJoin: "குளத்தில் சேர முடியவில்லை" @@ -1528,7 +1630,7 @@ i18n.use(initReactI18next).init({ } }, }, - lng: "en", + lng: savedLanguage, fallbackLng: "en", interpolation: { diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 4c850a5..d58892b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { TrendingUp, Users, DollarSign, ShoppingBag, Loader2, Truck, CheckCircle2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import DynamicText from "../components/common/DynamicText"; // Import DynamicText import authService from "../services/auth.service"; import negotiationService from "../services/negotiation.service"; import salesService from "../services/sales.service"; @@ -140,8 +141,8 @@ const Dashboard = () => {

{t('nav.dashboard')}

- {t('dashboard.welcome', { name: firstName })}! - Here's a summary of your agricultural activities and market insights. + {t('dashboard.welcome', { name: firstName })} + {t('dashboard.welcomeSub')}

@@ -218,7 +219,7 @@ const Dashboard = () => {
-

Revenue Analytics

+

{t('dashboard.revenueAnalytics')}

@@ -230,8 +231,16 @@ const Dashboard = () => {
-

Analytics Module Loading...

-

Detailed charts coming soon to this view.

+ +
{/* Fake chart lines for visual fill */} @@ -243,14 +252,22 @@ const Dashboard = () => {
-

Recent Activity

+

{t('dashboard.recentActivity')}

-

No Recent Activity

-

Your latest actions will appear here.

+ +
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index a424957..c40ff0c 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -33,8 +33,8 @@ const Login = () => { password: password }); - // Navigate to OTP page instead of Dashboard, passing phone number - navigate("/otp", { state: { phoneNumber: phone } }); + // Navigate to Dashboard + navigate("/dashboard"); } catch (err) { console.error(err); setError(err.response?.data?.message || t('auth.invalidCredentials')); diff --git a/frontend/src/pages/Marketplace.jsx b/frontend/src/pages/Marketplace.jsx index c43be5c..aa66057 100644 --- a/frontend/src/pages/Marketplace.jsx +++ b/frontend/src/pages/Marketplace.jsx @@ -74,11 +74,10 @@ const Marketplace = () => {

- Marketplace + {t('nav.marketplace')}

- Discover fresh, locally sourced crops directly from verified farmers. - Fair prices, transparent quality. + {t('marketplace.subtitle')}

@@ -109,7 +108,7 @@ const Marketplace = () => {
- +
{ name="name" value={filters.name} onChange={handleFilterChange} - placeholder="Search e.g. Rice" + placeholder={t('marketplace.searchCrop')} className="w-full pl-12 pr-4 py-3 bg-white/50 border border-nature-200 rounded-xl focus:ring-2 focus:ring-nature-400 focus:border-nature-400 outline-none transition-all placeholder:text-nature-300 text-nature-800 font-medium" />
- +
- +
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index e4e44b0..f81c596 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -4,7 +4,9 @@ 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"; import AuthCard from "../components/common/AuthCard"; // Keeping for reference if needed elsewhere, but not using here. +import { useState } from "react"; const Register = () => { + const navigate = useNavigate() const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const [password, setPassword] = useState(""); @@ -49,8 +51,8 @@ const Register = () => { preferredLanguage }); - // Navigate to OTP page - navigate("/otp", { state: { phoneNumber: phone } }); + // Navigate to Dashboard + navigate("/dashboard"); } catch (err) { console.error(err); setError(err.response?.data?.message || t('common.error')); diff --git a/frontend/src/pages/orders/OrderHistory.jsx b/frontend/src/pages/orders/OrderHistory.jsx index 55f2cbb..b5a7f96 100644 --- a/frontend/src/pages/orders/OrderHistory.jsx +++ b/frontend/src/pages/orders/OrderHistory.jsx @@ -1,10 +1,13 @@ -import React, { useState, useEffect } from "react"; // Added hooks +import React, { useState, useEffect } from "react"; import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import DynamicText from "../../components/common/DynamicText"; import { Receipt, Search, Filter, ChevronRight, Package, Calendar, Tag } from "lucide-react"; import authService from "../../services/auth.service"; import orderService from "../../services/order.service"; // Changed to real service const OrderHistory = () => { + const { t } = useTranslation(); const user = authService.getCurrentUser(); const isFarmer = user?.role?.toLowerCase() === "farmer"; @@ -66,10 +69,10 @@ const OrderHistory = () => {
-

Order History

+

{t('nav.orders')}

- {isFarmer ? "Manage and track sales of your crops." : "View and track your previous crop purchases."} + {isFarmer ? t('common.orders.manageSales') : t('common.orders.viewPurchases')}

@@ -78,7 +81,7 @@ const OrderHistory = () => { setSearchQuery(e.target.value)} className="pl-11 pr-4 py-3 bg-white border-2 border-neutral-light rounded-2xl focus:border-primary/30 outline-none w-full md:w-64 transition-all font-medium text-sm" @@ -154,7 +157,12 @@ const OrderHistory = () => { 🌾
-

{order.crop}

+
{order.id.slice(0, 8)} @@ -169,7 +177,7 @@ const OrderHistory = () => { {/* Details Grid */}
-

Quantity

+

{t('common.quantity')}

{order.quantity} @@ -186,7 +194,7 @@ const OrderHistory = () => {

Status

- {order.status} + {t(`dynamic.status.${order.status.toLowerCase()}`, order.status)}
diff --git a/frontend/src/services/auth.service.js b/frontend/src/services/auth.service.js index 2e27e67..701a1b9 100644 --- a/frontend/src/services/auth.service.js +++ b/frontend/src/services/auth.service.js @@ -1,20 +1,26 @@ import api from "./api"; const authService = { - // Register user (now returns need for OTP) + // Register user register: async (userData) => { // userData matches backend expectation: // { phoneNumber, password, role, fullName, email (optional), preferredLanguage (optional) } const response = await api.post("/auth/register", userData); - // Note: No token here anymore. Just status. + 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; }, - // Login user (now returns need for OTP) + // Login user login: async (credentials) => { // credentials: { phoneNumber, password } const response = await api.post("/auth/login", credentials); - // Note: No token here anymore. Just status. + 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; }, diff --git a/frontend/src/services/recommendation.service.js b/frontend/src/services/recommendation.service.js index f142d6a..1140809 100644 --- a/frontend/src/services/recommendation.service.js +++ b/frontend/src/services/recommendation.service.js @@ -3,15 +3,17 @@ import api from "./api"; const recommendationService = { // Get demand forecasting for a specific crop getDemandForecast: async (cropId, location = 'Coimbatore') => { + const language = localStorage.getItem('i18nextLng') || "en"; const response = await api.get('/demand/forecast', { - params: { crop: cropId, location } + params: { crop: cropId, location, language } }); return response.data; }, // Get crop recommendations based on location and currently selected crop getCropRecommendations: async (location, currentCrop) => { - const params = { location }; + const language = localStorage.getItem('i18nextLng') || "en"; + const params = { location, language }; if (currentCrop) params.crop = currentCrop; const response = await api.get('/demand/recommendations', { params }); return response.data.suggestions || response.data; diff --git a/frontend/src/services/translation.service.js b/frontend/src/services/translation.service.js new file mode 100644 index 0000000..178a1c9 --- /dev/null +++ b/frontend/src/services/translation.service.js @@ -0,0 +1,109 @@ +const TranslationService = { + // Cache to store translations and minimize API calls + cache: new Map(), + + /** + * Synchronously get cached translation if available + */ + getCached(text, targetLang, sourceLang = 'en') { + if (!text) return ""; + if (targetLang === sourceLang) return text; + + const cacheKey = `${sourceLang}_${targetLang}_${text}`; + + // Check memory cache + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + + // Check localStorage cache + const localCache = localStorage.getItem('translation_cache'); + const localMap = localCache ? JSON.parse(localCache) : {}; + if (localMap[cacheKey]) { + this.cache.set(cacheKey, localMap[cacheKey]); + return localMap[cacheKey]; + } + + return null; + }, + + /** + * Translates text using a free API (MyMemory or Google Translate Unofficial) + * @param {string} text - Text to translate + * @param {string} targetLang - Target language code (e.g., 'ta', 'hi') + * @param {string} sourceLang - Source language code (default 'en') + * @returns {Promise} - Translated text + */ + async translate(text, targetLang, sourceLang = 'en') { + if (!text) return ""; + if (targetLang === sourceLang) return text; + + // specific cache key + const cacheKey = `${sourceLang}_${targetLang}_${text}`; + + // Check cache first + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + + // Check localStorage cache + const localCache = localStorage.getItem('translation_cache'); + const localMap = localCache ? JSON.parse(localCache) : {}; + if (localMap[cacheKey]) { + this.cache.set(cacheKey, localMap[cacheKey]); + return localMap[cacheKey]; + } + + try { + // Simplified MyMemory API Call - no pair splitting + const response = await fetch( + `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${sourceLang}|${targetLang}` + ); + + const data = await response.json(); + + if (data.responseStatus === 200 || data.responseStatus === '200') { + // MyMemory sometimes returns matches + const translatedText = data.responseData.translatedText; + + // Update caches + this.cache.set(cacheKey, translatedText); + localMap[cacheKey] = translatedText; + localStorage.setItem('translation_cache', JSON.stringify(localMap)); + + return translatedText; + } else { + console.warn("Translation API warning:", data.responseDetails); + // Retry logic or fallback could go here + return text; + } + } catch (error) { + console.error("Primary translation failed, trying backup...", error); + return this.translateBackup(text, targetLang, sourceLang); + } + }, + + /** + * Backup translation using Google Translate unofficial API + */ + async translateBackup(text, targetLang, sourceLang) { + try { + const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`; + const res = await fetch(url); + const data = await res.json(); + return data[0][0][0]; + } catch (err) { + console.error("Backup translation failed:", err); + return text; + } + }, + + /** + * Batch translate an array of texts + */ + async translateBatch(texts, targetLang, sourceLang = 'en') { + return Promise.all(texts.map(text => this.translate(text, targetLang, sourceLang))); + } +}; + +export default TranslationService;