datamk commited on
Commit
57da3ff
·
verified ·
1 Parent(s): 9db6644

Upload 65 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +13 -0
  2. .env.example +13 -0
  3. .gitattributes +1 -0
  4. .gitignore +29 -0
  5. Dockerfile +53 -0
  6. eslint.config.js +30 -0
  7. index.html +13 -0
  8. package-lock.json +0 -0
  9. package.json +54 -0
  10. public/favicon.svg +1 -0
  11. public/icons.svg +24 -0
  12. public/upiqr.jpeg +0 -0
  13. scripts/migrate.js +23 -0
  14. server/db.js +95 -0
  15. server/index.js +722 -0
  16. server/mandi.db +0 -0
  17. server/models/group1-shard1of1 +3 -0
  18. server/models/model.json +0 -0
  19. server/test-nsfw.js +10 -0
  20. server/uploads/24c331bc-c580-4b26-8b66-bd952c84ddab.jpg +0 -0
  21. server/uploads/2eaf1e25-d636-4498-80a5-952b6f597a96.jpg +0 -0
  22. server/uploads/2fc68f29-0051-43b5-8267-8ec386ce5a0e.jpg +0 -0
  23. server/uploads/4104a2e9-4716-4945-8cb2-9d82314d5f46.jpg +0 -0
  24. server/uploads/445a0c5c-f518-4603-b56a-40947daf9514.jpg +0 -0
  25. server/uploads/4df08cb1-3942-4a11-a212-729ab615e238.jpg +0 -0
  26. server/uploads/5275088a-59e6-49bb-b8b5-be64df4b7ed0.jpg +0 -0
  27. server/uploads/61696cec-0adb-4f3d-9063-cd61412a235e.jpg +0 -0
  28. server/uploads/68ecf409-8fbe-4513-9d83-a256923cc58c.jpg +0 -0
  29. server/uploads/6a01ff8b-b925-49ed-97f7-e98e2c13faac.jpg +0 -0
  30. server/uploads/6c68841c-5d0a-4033-84e6-66f792ea94fe.jpg +0 -0
  31. server/uploads/8a05b98d-72db-400b-b506-e091c8d7d6a8.jpg +0 -0
  32. server/uploads/8bed15fe-ee0a-45f0-a649-3bc3d15e618f.jpg +0 -0
  33. server/uploads/8d906b44-c7f7-401d-aecf-57015bba7031.jpg +0 -0
  34. server/uploads/8e39ceb8-aa0a-4c2b-985e-c05aeaee9a47.jpg +0 -0
  35. server/uploads/9706a044-d954-4ea7-92bc-3ee9acb04f54.jpg +0 -0
  36. server/uploads/aea7aa8f-f4ee-4e32-94c4-32bb9ecfc7eb.jpg +0 -0
  37. server/uploads/b2f36863-9274-4c31-9879-7bcd7541ac5c.jpg +0 -0
  38. server/uploads/b539c7b1-e419-457b-9873-72d417966488.jpg +0 -0
  39. server/uploads/c558d211-042a-4e1e-8e8d-d9e1cd41b1d6.jpg +0 -0
  40. server/uploads/c56cdcbf-e2ed-4be0-8d79-88965c7664ab.jpg +0 -0
  41. server/uploads/eb4ae4ff-89d3-44ea-a622-ff714b6780de.jpg +0 -0
  42. server/uploads/ed8399fd-041c-4594-b650-bb29ee5805aa.jpg +0 -0
  43. src/App.css +184 -0
  44. src/App.jsx +189 -0
  45. src/assets/hero.png +0 -0
  46. src/assets/react.svg +1 -0
  47. src/assets/vite.svg +1 -0
  48. src/components/AddListing.jsx +438 -0
  49. src/components/Auth.jsx +554 -0
  50. src/components/BuyerDashboard.jsx +149 -0
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ dist-ssr
4
+ server/uploads
5
+ *.log
6
+ .env
7
+ .DS_Store
8
+ mandi.db
9
+ server/mandi.db
10
+ .gitignore
11
+ README.md
12
+ scripts/
13
+ out.txt
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this file to .env for local development.
2
+ # On Hugging Face Spaces, set these in:
3
+ # Settings → Variables and secrets
4
+
5
+ # Required: Neon PostgreSQL connection string
6
+ DATABASE_URL="postgresql://USER:PASSWORD@HOST/DBNAME?sslmode=require&channel_binding=require"
7
+
8
+ # Optional: Custom admin password (SHA-256 hash will be computed from this)
9
+ # If not set, a default hash is used (change it in production!)
10
+ ADMIN_SECRET="YourSecureAdminPasswordHere"
11
+
12
+ # Optional: Override the server port (HF Spaces requires 7860, which is the default)
13
+ # PORT=7860
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ server/models/group1-shard1of1 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Environment secrets — never commit these
27
+ .env
28
+ .env.*
29
+ !.env.example
Dockerfile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Build Stage ─────────────────────────────────────────────────────────────
2
+ FROM node:20-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Install build tools for native modules (sharp, tfjs-node)
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ python3 \
9
+ make \
10
+ g++ \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Install all dependencies (including optional tfjs-node)
14
+ COPY package*.json ./
15
+ RUN npm ci
16
+
17
+ # Copy source and build static frontend
18
+ COPY . .
19
+ RUN npm run build
20
+
21
+ # Prune devDeps (keeps production node_modules and native binaries)
22
+ RUN npm prune --omit=dev
23
+
24
+ # ── Runtime Stage ───────────────────────────────────────────────────────────
25
+ FROM node:20-slim
26
+
27
+ WORKDIR /app
28
+
29
+ # Runtime dependencies for sharp/tensorflow
30
+ RUN apt-get update && apt-get install -y --no-install-recommends \
31
+ libvips-dev \
32
+ && rm -rf /var/lib/apt/lists/*
33
+
34
+ # Copy only what's needed from builder
35
+ COPY --from=builder /app/dist ./dist
36
+ COPY --from=builder /app/package*.json ./
37
+ COPY --from=builder /app/node_modules ./node_modules
38
+ COPY --from=builder /app/server ./server
39
+
40
+ # Ensure upload directory remains writable
41
+ RUN mkdir -p server/uploads && chmod 777 server/uploads
42
+
43
+ # Env configuration for Hugging Face Spaces
44
+ ENV NODE_ENV=production
45
+ ENV PORT=7860
46
+ EXPOSE 7860
47
+
48
+ # Non-root security
49
+ RUN groupadd -r appgroup && useradd -r -g appgroup appuser
50
+ RUN chown -R appuser:appgroup /app
51
+ USER appuser
52
+
53
+ CMD ["node", "server/index.js"]
eslint.config.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: { ...globals.browser, ...globals.node },
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]|motion', caughtErrors: 'none' }],
27
+ 'no-empty': ['error', { allowEmptyCatch: true }],
28
+ },
29
+ },
30
+ ])
index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Meri Mandi</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mandi",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
8
+ "dev:server": "node --watch server/index.js",
9
+ "dev:client": "vite",
10
+ "build": "vite build",
11
+ "lint": "eslint .",
12
+ "preview": "vite preview"
13
+ },
14
+ "dependencies": {
15
+ "@tensorflow/tfjs": "^4.22.0",
16
+ "buffer": "^6.0.3",
17
+ "concurrently": "^9.2.1",
18
+ "cors": "^2.8.6",
19
+ "dotenv": "^17.3.1",
20
+ "events": "^3.3.0",
21
+ "express": "^5.2.1",
22
+ "framer-motion": "^12.38.0",
23
+ "lucide-react": "^1.7.0",
24
+ "morgan": "^1.10.1",
25
+ "multer": "^2.1.1",
26
+ "nsfwjs": "^4.3.0",
27
+ "pg": "^8.20.0",
28
+ "process": "^0.11.10",
29
+ "react": "^19.2.4",
30
+ "react-dom": "^19.2.4",
31
+ "react-router-dom": "^7.13.2",
32
+ "sharp": "^0.34.5",
33
+ "simple-peer": "^9.11.1",
34
+ "socket.io": "^4.8.3",
35
+ "socket.io-client": "^4.8.3",
36
+ "util": "^0.12.5",
37
+ "uuid": "^13.0.0",
38
+ "zustand": "^5.0.12"
39
+ },
40
+ "optionalDependencies": {
41
+ "@tensorflow/tfjs-node": "^4.22.0"
42
+ },
43
+ "devDependencies": {
44
+ "@eslint/js": "^9.39.4",
45
+ "@types/react": "^19.2.14",
46
+ "@types/react-dom": "^19.2.3",
47
+ "@vitejs/plugin-react": "^6.0.1",
48
+ "eslint": "^9.39.4",
49
+ "eslint-plugin-react-hooks": "^7.0.1",
50
+ "eslint-plugin-react-refresh": "^0.5.2",
51
+ "globals": "^17.4.0",
52
+ "vite": "^8.0.1"
53
+ }
54
+ }
public/favicon.svg ADDED
public/icons.svg ADDED
public/upiqr.jpeg ADDED
scripts/migrate.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import pg from 'pg';
3
+ const { Pool } = pg;
4
+
5
+ const db = new Pool({
6
+ connectionString: process.env.DATABASE_URL,
7
+ ssl: { rejectUnauthorized: false }
8
+ });
9
+
10
+ async function migrate() {
11
+ try {
12
+ console.log("Applying migrations...");
13
+ await db.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS "deviceId" TEXT;`);
14
+ await db.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS "deviceToken" TEXT;`);
15
+ console.log("✓ Database Migration Successful");
16
+ } catch (err) {
17
+ console.error("Migration failed:", err);
18
+ } finally {
19
+ await db.end();
20
+ }
21
+ }
22
+
23
+ migrate();
server/db.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import pg from 'pg';
3
+ // Postgres by default returns bigint (int8, type 20) as a string. Map it securely to Number for timestamps
4
+ pg.types.setTypeParser(20, parseInt);
5
+ const { Pool } = pg;
6
+
7
+ // Neon/PostgreSQL requires a connection string
8
+ const connectionString = process.env.DATABASE_URL;
9
+
10
+ if (!connectionString) {
11
+ console.warn("⚠️ WARNING: DATABASE_URL environment variable is missing.");
12
+ console.warn("Make sure to provide your Neon connection string.");
13
+ }
14
+
15
+ const db = new Pool({
16
+ connectionString,
17
+ ssl: connectionString && connectionString.includes('localhost') ? false : { rejectUnauthorized: false }
18
+ });
19
+
20
+ export const initDB = async () => {
21
+ if (!connectionString) return;
22
+ console.log("Initializing PostgreSQL Tables...");
23
+ const client = await db.connect();
24
+ try {
25
+ await client.query(`
26
+ CREATE TABLE IF NOT EXISTS users (
27
+ "id" TEXT PRIMARY KEY,
28
+ "name" TEXT NOT NULL,
29
+ "role" TEXT NOT NULL,
30
+ "selfiePath" TEXT NOT NULL,
31
+ "district" TEXT DEFAULT '',
32
+ "state" TEXT DEFAULT '',
33
+ "pincode" TEXT DEFAULT '',
34
+ "contact" TEXT DEFAULT '',
35
+ "location" TEXT DEFAULT '',
36
+ "nearestCity" TEXT DEFAULT '',
37
+ "deviceId" TEXT,
38
+ "deviceToken" TEXT,
39
+ "isBlocked" INTEGER DEFAULT 0
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS listings (
43
+ "id" TEXT PRIMARY KEY,
44
+ "sellerId" TEXT NOT NULL,
45
+ "sellerName" TEXT NOT NULL,
46
+ "cropName" TEXT DEFAULT '',
47
+ "quantity" TEXT DEFAULT '',
48
+ "price" TEXT DEFAULT '',
49
+ "location" TEXT NOT NULL,
50
+ "nearestCity" TEXT DEFAULT '',
51
+ "district" TEXT DEFAULT '',
52
+ "state" TEXT DEFAULT '',
53
+ "pincode" TEXT DEFAULT '',
54
+ "timestamp" BIGINT NOT NULL,
55
+ "status" TEXT DEFAULT 'active',
56
+ FOREIGN KEY ("sellerId") REFERENCES users("id") ON DELETE CASCADE
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS listing_images (
60
+ "id" SERIAL PRIMARY KEY,
61
+ "listingId" TEXT NOT NULL,
62
+ "imagePath" TEXT NOT NULL,
63
+ FOREIGN KEY ("listingId") REFERENCES listings("id") ON DELETE CASCADE
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS support_messages (
67
+ "id" TEXT PRIMARY KEY,
68
+ "senderId" TEXT NOT NULL,
69
+ "message" TEXT NOT NULL,
70
+ "adminReply" TEXT DEFAULT NULL,
71
+ "unreadAdminReply" INTEGER DEFAULT 0,
72
+ "isResolved" INTEGER DEFAULT 0,
73
+ "timestamp" BIGINT NOT NULL
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS messages (
77
+ "id" TEXT PRIMARY KEY,
78
+ "senderId" TEXT NOT NULL,
79
+ "receiverId" TEXT NOT NULL,
80
+ "message" TEXT NOT NULL,
81
+ "timestamp" BIGINT NOT NULL,
82
+ "isRead" INTEGER DEFAULT 0,
83
+ FOREIGN KEY ("senderId") REFERENCES users("id") ON DELETE CASCADE,
84
+ FOREIGN KEY ("receiverId") REFERENCES users("id") ON DELETE CASCADE
85
+ );
86
+ `);
87
+ console.log("✓ PostgreSQL Database Ready");
88
+ } catch (err) {
89
+ console.error("Database initialization failed:", err);
90
+ } finally {
91
+ client.release();
92
+ }
93
+ };
94
+
95
+ export default db;
server/index.js ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import express from 'express';
3
+ import cors from 'cors';
4
+ import multer from 'multer';
5
+ import path from 'path';
6
+ import fs from 'fs';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+ import crypto from 'crypto';
9
+ import db, { initDB } from './db.js';
10
+
11
+ // Import Http & Socket.io for Signaling
12
+ import { createServer } from 'http';
13
+ import { Server } from 'socket.io';
14
+
15
+ const app = express();
16
+ const PORT = process.env.PORT || 7860;
17
+
18
+ // Create raw HTTP server for Express app (required for socket.io)
19
+ const httpServer = createServer(app);
20
+
21
+ import * as nsfwjs from 'nsfwjs';
22
+ import sharp from 'sharp';
23
+
24
+ let tf;
25
+ try {
26
+ tf = await import('@tensorflow/tfjs-node');
27
+ } catch (e) {
28
+ tf = await import('@tensorflow/tfjs');
29
+ console.warn("⚠ Running TensorFlow.js in pure JS mode. Install @tensorflow/tfjs-node for faster performance.");
30
+ }
31
+
32
+ // Middlewares
33
+ import morgan from 'morgan';
34
+ app.use(cors());
35
+ app.use(express.json());
36
+ app.use(morgan('dev'));
37
+
38
+ const uploadDir = path.resolve('./server/uploads');
39
+ const modelsDir = path.resolve('./server/models');
40
+
41
+ // Graceful port handling – if the default port is busy, try the next one
42
+ // Ensure upload and model directories exist
43
+ if (!fs.existsSync(uploadDir)) {
44
+ fs.mkdirSync(uploadDir, { recursive: true });
45
+ }
46
+ if (!fs.existsSync(modelsDir)) {
47
+ fs.mkdirSync(modelsDir, { recursive: true });
48
+ }
49
+
50
+ // Graceful port handling – after httpServer is defined
51
+ httpServer.on('error', (err) => {
52
+ if (err.code === 'EADDRINUSE') {
53
+ console.warn(`Port ${PORT} already in use, trying ${PORT + 1}`);
54
+ httpServer.listen(PORT + 1);
55
+ } else {
56
+ console.error('Server error:', err);
57
+ }
58
+ });
59
+
60
+ app.use('/uploads', express.static(uploadDir));
61
+ app.use('/models', express.static(modelsDir));
62
+
63
+ // Serve the React production build
64
+ const distDir = path.resolve('./dist');
65
+ if (fs.existsSync(distDir)) {
66
+ app.use(express.static(distDir));
67
+ }
68
+
69
+ const storage = multer.diskStorage({
70
+ destination: function (req, file, cb) {
71
+ cb(null, uploadDir);
72
+ },
73
+ filename: function (req, file, cb) {
74
+ const ext = path.extname(file.originalname);
75
+ cb(null, uuidv4() + ext);
76
+ }
77
+ });
78
+ const upload = multer({ storage: storage });
79
+
80
+ // Load NSFW Model Locally
81
+ let nsfwModel = null;
82
+ const loadModel = async () => {
83
+ try {
84
+ // Wait for the server to be fully ready before internal fetch
85
+ await new Promise(resolve => setTimeout(resolve, 500));
86
+
87
+ // Attempt local filesystem URL first if path-aware, fallback to internal HTTP
88
+ // tfjs-node can handle file:// URLs directly and is MUCH faster.
89
+ const isNodeBackend = tf.engine().backendName === 'tensorflow';
90
+ let modelFileUrl;
91
+
92
+ if (isNodeBackend) {
93
+ // On Linux/HuggingFace, we need file:///app/server/...
94
+ // On Windows, absolute path needs to be converted carefully.
95
+ const absPath = path.resolve(modelsDir);
96
+ modelFileUrl = `file://${absPath.startsWith('/') ? '' : '/'}${absPath.replace(/\\/g, '/')}/`;
97
+ console.log("Loading NSFW Model via Native Engine (File IO)...");
98
+ } else {
99
+ modelFileUrl = `http://127.0.0.1:${PORT}/models/`;
100
+ console.log("Loading NSFW Model via Pure JS Mode (HTTP Loopback)...");
101
+ }
102
+
103
+ nsfwModel = await nsfwjs.load(modelFileUrl, { size: 224 });
104
+ console.log("✓ Offline NSFW Security Engine Loaded");
105
+ } catch (err) {
106
+ console.warn(`⚠ Local NSFW Model load failed (err: ${err.message}). Attempting CDN fallback...`);
107
+ try {
108
+ // Corrected CDN URL for MobileNetV2 fallback
109
+ nsfwModel = await nsfwjs.load('https://nsfwjs.com/model/', { size: 224 });
110
+ console.log("✓ Online NSFW Security Engine Loaded (CDN Fallback)");
111
+ } catch (cdnErr) {
112
+ console.warn("⚠ NSFW Model could not be loaded even from CDN. Content moderation will be disabled.", cdnErr.message);
113
+ }
114
+ }
115
+ };
116
+
117
+ // Helper to check if an image is inappropriate
118
+ const checkInappropriate = async (filePath) => {
119
+ if (!nsfwModel) return false;
120
+ try {
121
+ // Standardize image processing via sharp before passing to TFJS
122
+ // Model expects 224x224 (MobileNetV2 standard)
123
+ const { data, info } = await sharp(filePath)
124
+ .resize(224, 224)
125
+ .removeAlpha()
126
+ .raw()
127
+ .toBuffer({ resolveWithObject: true });
128
+
129
+ // Reconstruct into a tensor for classification
130
+ const image = tf.tensor3d(new Uint8Array(data), [info.height, info.width, 3]);
131
+ const predictions = await nsfwModel.classify(image);
132
+ image.dispose();
133
+
134
+ // Block if highly explicit content is detected
135
+ return predictions.some(p =>
136
+ ['Porn', 'Hentai', 'Sexy'].includes(p.className) && p.probability > 0.6
137
+ );
138
+ } catch (err) {
139
+ console.error("AI Analysis Error:", err);
140
+ return false;
141
+ }
142
+ };
143
+
144
+ // REST API Endpoints
145
+ app.post('/api/users', upload.single('selfie'), async (req, res) => {
146
+ try {
147
+ const { id: deviceId, deviceToken, name, role, contact, nearestCity, district, state, pincode, location } = req.body;
148
+ if (!deviceId || !name || !role || !contact || !nearestCity || !district || !state || !pincode || !location || !req.file) {
149
+ if (req.file) try { fs.unlinkSync(req.file.path); } catch(e){}
150
+ return res.status(400).json({ error: 'Missing required fields, location or selfie.' });
151
+ }
152
+
153
+ // AI Validation on Backend
154
+ const isInappropriate = await checkInappropriate(req.file.path);
155
+ if (isInappropriate) {
156
+ try { fs.unlinkSync(req.file.path); } catch(e){}
157
+ return res.status(400).json({ error: 'Snapshot rejected: Inappropriate content detected by security engine.' });
158
+ }
159
+
160
+ const { rows: matches } = await db.query(`
161
+ SELECT * FROM users
162
+ WHERE contact = $1 AND role = $2 AND name = $3 AND state = $4 AND district = $5 AND pincode = $6
163
+ `, [contact, role, name, state, district, pincode]);
164
+ const existingProfile = matches[0];
165
+
166
+ const compoundId = existingProfile ? existingProfile.id : `${deviceId}_${role}`;
167
+ const selfiePath = `/uploads/${req.file.filename}`;
168
+
169
+ if (!existingProfile) {
170
+ // New Profile - Extra Security Check: Prevent hijacking if only phone matches but others don't
171
+ const { rows: phoneCheck } = await db.query(`SELECT id FROM users WHERE contact = $1 AND role = $2`, [contact, role]);
172
+ if (phoneCheck.length > 0) {
173
+ return res.status(400).json({ error: 'A profile with this phone number exists with different details. Please verify your exact name and location details to restore it.' });
174
+ }
175
+
176
+ await db.query(`
177
+ INSERT INTO users (id, name, role, "selfiePath", contact, "nearestCity", district, state, pincode, location, "deviceId", "deviceToken")
178
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
179
+ `, [compoundId, name, role, selfiePath, contact, nearestCity, district, state, pincode, location, deviceId, deviceToken]);
180
+ } else {
181
+ // "Auto-Sync" - Re-link existing profile to this new device if it matches contact/role
182
+ if (existingProfile.isBlocked) {
183
+ return res.status(403).json({ error: 'Account blocked by admin.' });
184
+ }
185
+
186
+ // Cleanup old selfie
187
+ if (existingProfile.selfiePath) {
188
+ try { fs.unlinkSync(path.join(process.cwd(), 'server', existingProfile.selfiePath)); } catch(e){}
189
+ }
190
+
191
+ await db.query(`
192
+ UPDATE users SET name = $1, "selfiePath" = $2, "nearestCity" = $3, district = $4, state = $5, pincode = $6, location = $7, "deviceId" = $8, "deviceToken" = $9
193
+ WHERE id = $10
194
+ `, [name, selfiePath, nearestCity, district, state, pincode, location, deviceId, deviceToken, compoundId]);
195
+ }
196
+
197
+ res.status(200).json({
198
+ id: compoundId, deviceId, name, role, contact,
199
+ nearestCity, district, state, pincode,
200
+ location: JSON.parse(location),
201
+ selfiePath, isBlocked: existingProfile ? existingProfile.isBlocked : 0
202
+ });
203
+ } catch (err) {
204
+ console.error(err);
205
+ res.status(500).json({ error: 'Failed to register.' });
206
+ }
207
+ });
208
+
209
+ app.get('/api/users/device/:deviceId', async (req, res) => {
210
+ try {
211
+ const { deviceId } = req.params;
212
+ const token = req.headers['x-device-token']; // Secure verification
213
+
214
+ const { rows: profiles } = await db.query(`
215
+ SELECT * FROM users
216
+ WHERE "deviceId" = $1 OR id LIKE $2
217
+ `, [deviceId, `${deviceId}_%`]);
218
+
219
+ // Simple Token-based security check (if profile exists and has a token, it MUST match)
220
+ const validProfiles = profiles.filter(p => !p.deviceToken || p.deviceToken === token);
221
+
222
+ res.json(validProfiles);
223
+ } catch (err) {
224
+ console.error(err);
225
+ res.status(500).json({ error: 'Failed to fetch device profiles' });
226
+ }
227
+ });
228
+
229
+ app.get('/api/listings', async (req, res) => {
230
+ try {
231
+ const { rows } = await db.query(`
232
+ SELECT listings.*, users."selfiePath" as "sellerSelfie"
233
+ FROM listings
234
+ JOIN users ON listings."sellerId" = users.id
235
+ WHERE listings.status != 'sold'
236
+ ORDER BY timestamp DESC
237
+ `);
238
+
239
+ const result = await Promise.all(rows.map(async (listing) => {
240
+ const { rows: images } = await db.query('SELECT "imagePath" FROM listing_images WHERE "listingId" = $1', [listing.id]);
241
+ return {
242
+ ...listing,
243
+ location: JSON.parse(listing.location),
244
+ nearestCity: listing.nearestCity || '',
245
+ district: listing.district || '',
246
+ state: listing.state || '',
247
+ pincode: listing.pincode || '',
248
+ images: images.map(img => img.imagePath)
249
+ };
250
+ }));
251
+ res.json(result);
252
+ } catch (err) {
253
+ console.error(err);
254
+ res.status(500).json({ error: 'Failed to fetch' });
255
+ }
256
+ });
257
+
258
+ app.post('/api/listings', upload.array('images', 5), async (req, res) => {
259
+ try {
260
+ const { sellerId, sellerName, cropName, quantity, price, location, nearestCity, district, state, pincode } = req.body;
261
+
262
+ // Strict Location Validation
263
+ let parsedLocation;
264
+ try {
265
+ parsedLocation = JSON.parse(location);
266
+ if (!parsedLocation || typeof parsedLocation.lat !== 'number' || typeof parsedLocation.lng !== 'number') {
267
+ throw new Error('Invalid coordinates');
268
+ }
269
+ } catch (e) {
270
+ if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(ev){} });
271
+ return res.status(400).json({ error: 'Valid GPS Location is strictly mandatory for all listings.' });
272
+ }
273
+
274
+ if (!sellerId || !cropName || !quantity || !location || !req.files || req.files.length === 0) {
275
+ if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
276
+ return res.status(400).json({ error: 'Missing fields' });
277
+ }
278
+
279
+ // AI Validation for all uploaded images
280
+ for (const file of req.files) {
281
+ const isInappropriate = await checkInappropriate(file.path);
282
+ if (isInappropriate) {
283
+ // Delete all uploaded images for this request if any are NSFW
284
+ req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
285
+ return res.status(400).json({ error: 'Listing rejected: One or more images contain inappropriate content.' });
286
+ }
287
+ }
288
+
289
+ const listingId = uuidv4();
290
+ const timestamp = Date.now();
291
+
292
+ await db.query(`
293
+ INSERT INTO listings (id, "sellerId", "sellerName", "cropName", quantity, price, location, "nearestCity", district, state, pincode, timestamp)
294
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
295
+ `, [listingId, sellerId, sellerName, cropName, quantity, price, location, nearestCity, district, state, pincode, timestamp]);
296
+
297
+ const imagePaths = req.files.map(file => `/uploads/${file.filename}`);
298
+
299
+ await Promise.all(imagePaths.map(p =>
300
+ db.query(`INSERT INTO listing_images ("listingId", "imagePath") VALUES ($1, $2)`, [listingId, p])
301
+ ));
302
+
303
+ const { rows: userRows } = await db.query(`SELECT "selfiePath" FROM users WHERE id = $1`, [sellerId]);
304
+ const user = userRows[0];
305
+
306
+ res.status(201).json({
307
+ id: listingId, sellerId, sellerName,
308
+ sellerSelfie: user ? user.selfiePath : null,
309
+ cropName, quantity, price, location: JSON.parse(location),
310
+ timestamp, images: imagePaths
311
+ });
312
+ } catch (err) {
313
+ if (req.files) req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch(e){} });
314
+ res.status(500).json({ error: 'Failed to create' });
315
+ }
316
+ });
317
+
318
+ // Configure Socket.IO Server
319
+ const io = new Server(httpServer, {
320
+ cors: {
321
+ origin: '*',
322
+ methods: ['GET', 'POST']
323
+ }
324
+ });
325
+
326
+ // Global mapping of deviceId -> socketId
327
+ const activeUsers = new Map();
328
+
329
+ // Mark as sold endpoint (uses io)
330
+ app.patch('/api/listings/:id/sold', async (req, res) => {
331
+ try {
332
+ const { id } = req.params;
333
+ await db.query(`UPDATE listings SET status = 'sold' WHERE id = $1`, [id]);
334
+
335
+ // Broadcast everywhere immediately
336
+ io.emit('listing-updated', { id, status: 'sold' });
337
+
338
+ res.json({ success: true, id, status: 'sold' });
339
+ } catch (err) {
340
+ console.error(err);
341
+ res.status(500).json({ error: 'Failed to update listing' });
342
+ }
343
+ });
344
+
345
+ // Edit listing info endpoint
346
+ app.patch('/api/listings/:id/edit', async (req, res) => {
347
+ try {
348
+ const { id } = req.params;
349
+ const { cropName, quantity, price } = req.body;
350
+
351
+ await db.query(`UPDATE listings SET "cropName" = $1, quantity = $2, price = $3 WHERE id = $4`, [cropName, quantity, price, id]);
352
+
353
+ // Broadcast changes
354
+ io.emit('listing-edited', { id, cropName, quantity, price });
355
+
356
+ res.json({ success: true, id, cropName, quantity, price });
357
+ } catch (err) {
358
+ console.error(err);
359
+ res.status(500).json({ error: 'Failed to edit listing' });
360
+ }
361
+ });
362
+
363
+ // --- USER MESSAGING API ---
364
+ app.post('/api/messages', async (req, res) => {
365
+ try {
366
+ const { id, senderId, receiverId, message, timestamp } = req.body;
367
+ const socketId = req.headers['x-socket-id'];
368
+
369
+ // Zero-Trust IDOR Check
370
+ const activeSocket = activeUsers.get(senderId ? senderId.split('_')[0] : null);
371
+ if (!activeSocket || activeSocket !== socketId) {
372
+ return res.status(403).json({ error: 'Messaging Identity verification failed.' });
373
+ }
374
+
375
+ await db.query(`INSERT INTO messages (id, "senderId", "receiverId", message, timestamp) VALUES ($1, $2, $3, $4, $5)`, [id, senderId, receiverId, message, timestamp]);
376
+ res.json({ success: true, id });
377
+ } catch (err) {
378
+ console.error(err);
379
+ res.status(500).json({ error: 'Failed to send message' });
380
+ }
381
+ });
382
+
383
+ app.get('/api/messages/history/:user1/:user2', async (req, res) => {
384
+ try {
385
+ const { user1, user2 } = req.params;
386
+ const { rows: history } = await db.query(`
387
+ SELECT * FROM messages
388
+ WHERE ("senderId" = $1 AND "receiverId" = $2) OR ("senderId" = $3 AND "receiverId" = $4)
389
+ ORDER BY timestamp ASC
390
+ `, [user1, user2, user2, user1]);
391
+ res.json(history);
392
+ } catch (err) {
393
+ res.status(500).json({ error: 'Failed to fetch messages' });
394
+ }
395
+ });
396
+
397
+ app.get('/api/messages/inbox/:userId', async (req, res) => {
398
+ try {
399
+ const { userId } = req.params;
400
+ // Get the most recent message for each unique conversation strictly ordered by timestamp
401
+ const { rows: inbox } = await db.query(`
402
+ SELECT * FROM (
403
+ SELECT m.*,
404
+ CASE WHEN m."senderId" = $1 THEN r.name ELSE s.name END as "contactName",
405
+ CASE WHEN m."senderId" = $1 THEN r."selfiePath" ELSE s."selfiePath" END as "contactSelfie",
406
+ CASE WHEN m."senderId" = $1 THEN r.id ELSE s.id END as "contactId",
407
+ ROW_NUMBER() OVER(PARTITION BY CASE WHEN m."senderId" = $1 THEN m."receiverId" ELSE m."senderId" END ORDER BY m.timestamp DESC) as rn
408
+ FROM messages m
409
+ JOIN users s ON m."senderId" = s.id
410
+ JOIN users r ON m."receiverId" = r.id
411
+ WHERE m."senderId" = $1 OR m."receiverId" = $1
412
+ ) sub
413
+ WHERE rn = 1
414
+ ORDER BY timestamp DESC
415
+ `, [userId]);
416
+ res.json(inbox);
417
+ } catch (err) {
418
+ console.error("Inbox fetch err:", err);
419
+ res.status(500).json({ error: 'Failed to fetch inbox' });
420
+ }
421
+ });
422
+
423
+ app.get('/api/messages/unread-count/:userId', async (req, res) => {
424
+ try {
425
+ const { rows } = await db.query(`SELECT COUNT(*) as count FROM messages WHERE "receiverId" = $1 AND "isRead" = 0`, [req.params.userId]);
426
+ res.json({ count: parseInt(rows[0].count) });
427
+ } catch (err) {
428
+ res.status(500).json({ error: 'Failed to fetch unread count' });
429
+ }
430
+ });
431
+
432
+ app.patch('/api/messages/read/:senderId/:receiverId', async (req, res) => {
433
+ try {
434
+ await db.query(`UPDATE messages SET "isRead" = 1 WHERE "senderId" = $1 AND "receiverId" = $2`, [req.params.senderId, req.params.receiverId]);
435
+ res.json({ success: true });
436
+ } catch (err) {
437
+ res.status(500).json({ error: 'Failed' });
438
+ }
439
+ });
440
+
441
+ // --- SUPPORT API (CLIENT) ---
442
+ app.post('/api/support', async (req, res) => {
443
+ try {
444
+ const { senderId, message } = req.body;
445
+ if (!senderId || !message) return res.status(400).json({ error: 'Missing fields' });
446
+ const ticketId = uuidv4();
447
+ await db.query(`INSERT INTO support_messages (id, "senderId", message, timestamp) VALUES ($1, $2, $3, $4)`, [ticketId, senderId, message, Date.now()]);
448
+ // Notify admin clients
449
+ io.emit('support-ticket-updated');
450
+ res.status(201).json({ success: true, id: ticketId });
451
+ } catch (err) {
452
+ res.status(500).json({ error: 'Failed to create support ticket' });
453
+ }
454
+ });
455
+
456
+ app.get('/api/support/history/:deviceId', async (req, res) => {
457
+ try {
458
+ const { deviceId } = req.params;
459
+ const { rows: history } = await db.query(`SELECT * FROM support_messages WHERE ("senderId" = $1 OR "senderId" LIKE $2) ORDER BY timestamp DESC`, [deviceId, `${deviceId}_%`]);
460
+ res.json(history);
461
+ } catch (err) {
462
+ res.status(500).json({ error: 'Failed' });
463
+ }
464
+ });
465
+
466
+ app.get('/api/support/unread/:deviceId', async (req, res) => {
467
+ try {
468
+ const { deviceId } = req.params;
469
+ const { rows } = await db.query(`SELECT COUNT(*) as count FROM support_messages WHERE ("senderId" = $1 OR "senderId" LIKE $2) AND "unreadAdminReply" = 1`, [deviceId, `${deviceId}_%`]);
470
+ res.json({ count: parseInt(rows[0].count) });
471
+ } catch (err) {
472
+ res.status(500).json({ error: 'Failed' });
473
+ }
474
+ });
475
+
476
+ app.patch('/api/support/read/:deviceId', async (req, res) => {
477
+ try {
478
+ const { deviceId } = req.params;
479
+ await db.query(`UPDATE support_messages SET "unreadAdminReply" = 0 WHERE ("senderId" = $1 OR "senderId" LIKE $2)`, [deviceId, `${deviceId}_%`]);
480
+ res.json({ success: true });
481
+ } catch (err) {
482
+ res.status(500).json({ error: 'Failed' });
483
+ }
484
+ });
485
+
486
+ // --- ADMIN API ---
487
+ // WORKAROUND: Instead of storing secret in .env, we use the SHA-256 hash
488
+ // Hash of "MyNewSecureAdminPassword2026!" is provided as the default.
489
+ const DEFAULT_ADMIN_HASH = '922b11a4333a2f48c9cd3a55240b26b724d5273d28564e485582b5a375876e46';
490
+ const ADMIN_SECRET = process.env.ADMIN_SECRET;
491
+
492
+ const ADMIN_HASH_EXPECTED = ADMIN_SECRET
493
+ ? crypto.createHash('sha256').update(ADMIN_SECRET).digest('hex')
494
+ : DEFAULT_ADMIN_HASH;
495
+
496
+ const activeAdminTokens = new Set();
497
+
498
+ app.post('/api/admin/login', (req, res) => {
499
+ if (req.body.passwordHash === ADMIN_HASH_EXPECTED) {
500
+ const sessionToken = uuidv4();
501
+ activeAdminTokens.add(sessionToken);
502
+ res.json({ token: sessionToken });
503
+ } else {
504
+ res.status(401).json({ error: 'Invalid encrypted admin password' });
505
+ }
506
+ });
507
+
508
+ const adminAuth = (req, res, next) => {
509
+ if (activeAdminTokens.has(req.headers['x-admin-key'])) return next();
510
+ return res.status(403).json({ error: 'Unauthorized Admin Session.' });
511
+ };
512
+
513
+ app.get('/api/admin/stats', adminAuth, async (req, res) => {
514
+ try {
515
+ const { rows: users } = await db.query(`SELECT count(*) as count FROM users`);
516
+ const { rows: listings } = await db.query(`SELECT count(*) as count FROM listings`);
517
+ const { rows: pendingSupport } = await db.query(`SELECT count(*) as count FROM support_messages WHERE "isResolved" = 0`);
518
+ res.json({ users: parseInt(users[0].count), listings: parseInt(listings[0].count), pendingSupport: parseInt(pendingSupport[0].count) });
519
+ } catch (e) { res.status(500).json({ error: 'Failed' }); }
520
+ });
521
+
522
+ app.get('/api/admin/users', adminAuth, async (req, res) => {
523
+ try {
524
+ const { rows: users } = await db.query(`SELECT * FROM users ORDER BY id ASC`);
525
+ res.json(users);
526
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
527
+ });
528
+
529
+ app.patch('/api/admin/users/:id/block', adminAuth, async (req, res) => {
530
+ const { isBlocked } = req.body;
531
+ try {
532
+ await db.query(`UPDATE users SET "isBlocked" = $1 WHERE id = $2`, [isBlocked ? 1 : 0, req.params.id]);
533
+ io.emit('user-blocked-status-changed', { id: req.params.id, isBlocked: isBlocked ? 1 : 0 }); // Global notification
534
+ res.json({ success: true });
535
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
536
+ });
537
+
538
+ app.delete('/api/admin/users/:id', adminAuth, async (req, res) => {
539
+ try {
540
+ const { rows: users } = await db.query(`SELECT * FROM users WHERE id = $1`, [req.params.id]);
541
+ const user = users[0];
542
+ if (!user) return res.status(404).json({ error: 'Not found' });
543
+
544
+ // Delete their selfie image file physically
545
+ try { fs.unlinkSync(path.join(uploadDir, path.basename(user.selfiePath))); } catch(e){}
546
+
547
+ // Also delete listing images as SQLite Pragma foreign key relies on delete cascade for db rows not files
548
+ const { rows: userListings } = await db.query(`SELECT id FROM listings WHERE "sellerId" = $1`, [req.params.id]);
549
+ for (const l of userListings) {
550
+ const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [l.id]);
551
+ for (const i of imgs) {
552
+ try { fs.unlinkSync(path.join(uploadDir, path.basename(i.imagePath))); } catch(e){}
553
+ }
554
+ }
555
+
556
+ // Let's manually cascade for safety as PRAGMA might not be active on all connections
557
+ for (const l of userListings) {
558
+ await db.query(`DELETE FROM listing_images WHERE "listingId" = $1`, [l.id]);
559
+ }
560
+ await db.query(`DELETE FROM listings WHERE "sellerId" = $1`, [req.params.id]);
561
+
562
+ // Cleanup the user's selfie to prevent storage leak
563
+ if (user && user.selfiePath) {
564
+ try { fs.unlinkSync(path.join(process.cwd(), 'server', user.selfiePath)); } catch (e) {}
565
+ }
566
+ await db.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
567
+
568
+ // Blast it out to clients
569
+ io.emit('listing-updated', { forceRefresh: true }); // trigger refresh for others
570
+
571
+ res.json({ success: true });
572
+ } catch(e) { console.error(e); res.status(500).json({ error: 'Failed to delete' }); }
573
+ });
574
+
575
+ app.get('/api/admin/listings', adminAuth, async (req, res) => {
576
+ try {
577
+ const { rows: listings } = await db.query(`SELECT listings.*, users.name as "sellerNameDisplay", users.contact as "sellerContact" FROM listings JOIN users ON users.id = listings."sellerId" ORDER BY timestamp DESC`);
578
+ const result = await Promise.all(listings.map(async l => {
579
+ const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [l.id]);
580
+ return { ...l, images: imgs.map(i => i.imagePath) };
581
+ }));
582
+ res.json(result);
583
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
584
+ });
585
+
586
+ app.delete('/api/admin/listings/:id', adminAuth, async (req, res) => {
587
+ try {
588
+ const { rows: imgs } = await db.query(`SELECT "imagePath" FROM listing_images WHERE "listingId" = $1`, [req.params.id]);
589
+ for (const i of imgs) {
590
+ try { fs.unlinkSync(path.join(uploadDir, path.basename(i.imagePath))); } catch(e){}
591
+ }
592
+ await db.query(`DELETE FROM listing_images WHERE "listingId" = $1`, [req.params.id]);
593
+ await db.query(`DELETE FROM listings WHERE id = $1`, [req.params.id]);
594
+
595
+ // Push delete command to all socket clients using the updated structure
596
+ io.emit('listing-updated', { id: req.params.id, status: 'deleted' });
597
+
598
+ res.json({ success: true });
599
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
600
+ });
601
+ app.get('/api/admin/support', adminAuth, async (req, res) => {
602
+ try {
603
+ const { rows: messages } = await db.query(`SELECT support_messages.*, users.name, users.role, users.contact, users."selfiePath" FROM support_messages LEFT JOIN users ON users.id = support_messages."senderId" ORDER BY support_messages.timestamp DESC`);
604
+ res.json(messages);
605
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
606
+ });
607
+
608
+ app.patch('/api/admin/support/:id/resolve', adminAuth, async (req, res) => {
609
+ try {
610
+ await db.query(`UPDATE support_messages SET "isResolved" = 1 WHERE id = $1`, [req.params.id]);
611
+ res.json({ success: true });
612
+ } catch(e) { res.status(500).json({ error: 'Failed' }); }
613
+ });
614
+
615
+ app.patch('/api/admin/support/:id/reply', adminAuth, async (req, res) => {
616
+ try {
617
+ const { reply } = req.body;
618
+ await db.query(`UPDATE support_messages SET "adminReply" = $1, "unreadAdminReply" = 1 WHERE id = $2`, [reply, req.params.id]);
619
+ io.emit('support-ticket-updated'); // Notify clients
620
+ res.json({ success: true, reply });
621
+ } catch(e) {
622
+ console.error("Support Reply Error:", e);
623
+ res.status(500).json({ error: 'Failed' });
624
+ }
625
+ });
626
+ // --- END ADMIN API ---
627
+
628
+ io.on('connection', (socket) => {
629
+
630
+ socket.on('join-network', (deviceId) => {
631
+ activeUsers.set(deviceId, socket.id);
632
+ console.log(`User connected: ${deviceId} via socket ${socket.id}`);
633
+ });
634
+
635
+ socket.on('disconnect', () => {
636
+ for (const [deviceId, sockId] of activeUsers.entries()) {
637
+ if (sockId === socket.id) {
638
+ activeUsers.delete(deviceId);
639
+ console.log(`User disconnected: ${deviceId}`);
640
+ break;
641
+ }
642
+ }
643
+ });
644
+
645
+ const getRawId = (id) => id ? id.split('_')[0] : null;
646
+
647
+ // Live Messaging Events
648
+ socket.on('send-message', (data) => {
649
+ const receiverSocket = activeUsers.get(getRawId(data.receiverId));
650
+ if (receiverSocket) {
651
+ io.to(receiverSocket).emit('receive-message', data);
652
+ }
653
+ });
654
+
655
+ // WebRTC Signaling Events
656
+ socket.on('call-user', (data) => {
657
+ const { userToCall, signalData, from, callerName, callerSelfie } = data;
658
+ const receiverSocket = activeUsers.get(getRawId(userToCall));
659
+ if (receiverSocket) {
660
+ io.to(receiverSocket).emit('incoming-call', {
661
+ signal: signalData,
662
+ from,
663
+ callerName,
664
+ callerSelfie
665
+ });
666
+ } else {
667
+ // Notify caller that receiver is offline
668
+ io.to(socket.id).emit('call-failed', { message: 'User is offline' });
669
+ }
670
+ });
671
+
672
+ socket.on('answer-call', (data) => {
673
+ const callerSocket = activeUsers.get(getRawId(data.to));
674
+ if (callerSocket) {
675
+ io.to(callerSocket).emit('call-accepted', data.signal);
676
+ }
677
+ });
678
+
679
+ socket.on('end-call', (data) => {
680
+ const peerSocket = activeUsers.get(getRawId(data.to));
681
+ if (peerSocket) {
682
+ io.to(peerSocket).emit('call-ended');
683
+ }
684
+ });
685
+
686
+ socket.on('missed-call', (data) => {
687
+ const peerSocket = activeUsers.get(getRawId(data.to));
688
+ if (peerSocket) io.to(peerSocket).emit('missed-call');
689
+ });
690
+
691
+ // Video Negotiation Signaling
692
+ socket.on('request-video', (data) => {
693
+ const peerSocket = activeUsers.get(getRawId(data.to));
694
+ if (peerSocket) io.to(peerSocket).emit('video-requested', { from: data.from });
695
+ });
696
+
697
+ socket.on('accept-video', (data) => {
698
+ const peerSocket = activeUsers.get(getRawId(data.to));
699
+ if (peerSocket) io.to(peerSocket).emit('video-accepted');
700
+ });
701
+
702
+ socket.on('reject-video', (data) => {
703
+ const peerSocket = activeUsers.get(getRawId(data.to));
704
+ if (peerSocket) io.to(peerSocket).emit('video-rejected');
705
+ });
706
+ });
707
+
708
+ // SPA fallback — must be registered AFTER all API routes but BEFORE listen
709
+ // Express 5 uses path-to-regexp v8 which requires '/{*splat}' instead of '*'
710
+ const spaDistDir = path.resolve('./dist');
711
+ const spaIndexHtml = path.join(spaDistDir, 'index.html');
712
+ if (fs.existsSync(spaIndexHtml)) {
713
+ app.get('/{*splat}', (req, res) => {
714
+ res.sendFile(spaIndexHtml);
715
+ });
716
+ }
717
+
718
+ httpServer.listen(PORT, '0.0.0.0', async () => {
719
+ await initDB();
720
+ console.log(`Backend API & WebSocket Server running at http://0.0.0.0:${PORT}`);
721
+ await loadModel();
722
+ });
server/mandi.db ADDED
File without changes
server/models/group1-shard1of1 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8e7dddbb16acacc1bf1601b1b8a761e730ff934b7f2d7771312b2f000e5f5f13
3
+ size 2619461
server/models/model.json ADDED
The diff for this file is too large to render. See raw diff
 
server/test-nsfw.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as nsfwjs from 'nsfwjs';
2
+ import fetch from 'node-fetch';
3
+ global.fetch = async (url, options) => {
4
+ console.log("FETCHED:", url);
5
+ return fetch(url, options);
6
+ };
7
+ nsfwjs.load().then(() => {
8
+ console.log("Done");
9
+ process.exit(0);
10
+ }).catch(console.error);
server/uploads/24c331bc-c580-4b26-8b66-bd952c84ddab.jpg ADDED
server/uploads/2eaf1e25-d636-4498-80a5-952b6f597a96.jpg ADDED
server/uploads/2fc68f29-0051-43b5-8267-8ec386ce5a0e.jpg ADDED
server/uploads/4104a2e9-4716-4945-8cb2-9d82314d5f46.jpg ADDED
server/uploads/445a0c5c-f518-4603-b56a-40947daf9514.jpg ADDED
server/uploads/4df08cb1-3942-4a11-a212-729ab615e238.jpg ADDED
server/uploads/5275088a-59e6-49bb-b8b5-be64df4b7ed0.jpg ADDED
server/uploads/61696cec-0adb-4f3d-9063-cd61412a235e.jpg ADDED
server/uploads/68ecf409-8fbe-4513-9d83-a256923cc58c.jpg ADDED
server/uploads/6a01ff8b-b925-49ed-97f7-e98e2c13faac.jpg ADDED
server/uploads/6c68841c-5d0a-4033-84e6-66f792ea94fe.jpg ADDED
server/uploads/8a05b98d-72db-400b-b506-e091c8d7d6a8.jpg ADDED
server/uploads/8bed15fe-ee0a-45f0-a649-3bc3d15e618f.jpg ADDED
server/uploads/8d906b44-c7f7-401d-aecf-57015bba7031.jpg ADDED
server/uploads/8e39ceb8-aa0a-4c2b-985e-c05aeaee9a47.jpg ADDED
server/uploads/9706a044-d954-4ea7-92bc-3ee9acb04f54.jpg ADDED
server/uploads/aea7aa8f-f4ee-4e32-94c4-32bb9ecfc7eb.jpg ADDED
server/uploads/b2f36863-9274-4c31-9879-7bcd7541ac5c.jpg ADDED
server/uploads/b539c7b1-e419-457b-9873-72d417966488.jpg ADDED
server/uploads/c558d211-042a-4e1e-8e8d-d9e1cd41b1d6.jpg ADDED
server/uploads/c56cdcbf-e2ed-4be0-8d79-88965c7664ab.jpg ADDED
server/uploads/eb4ae4ff-89d3-44ea-a622-ff714b6780de.jpg ADDED
server/uploads/ed8399fd-041c-4594-b650-bb29ee5805aa.jpg ADDED
src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
src/App.jsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Routes, Route, Navigate, useNavigate } from 'react-router-dom';
2
+ import { useStore } from './store';
3
+ import { Auth } from './components/Auth';
4
+ import { BuyerDashboard } from './components/BuyerDashboard';
5
+ import { SellerDashboard } from './components/SellerDashboard';
6
+ import { AddListing } from './components/AddListing';
7
+ import { ListingDetails } from './components/ListingDetails';
8
+ import { CallOverlay } from './components/CallOverlay';
9
+ import { HelpOverlay } from './components/HelpOverlay';
10
+ import { ChatOverlay } from './components/ChatOverlay';
11
+ import { InboxOverlay } from './components/InboxOverlay';
12
+ import { AdminLogin } from './components/admin/AdminLogin';
13
+ import { AdminDashboard } from './components/admin/AdminDashboard';
14
+ import { ProtectedRoute } from './components/ProtectedRoute';
15
+ import { LogOut, Home, KeyRound, Heart, X } from 'lucide-react';
16
+ import { useEffect, useState } from 'react';
17
+ import { socket } from './socket';
18
+ import { useLocation } from 'react-router-dom';
19
+
20
+ export default function App() {
21
+ const { currentUser, initDevice, logout } = useStore();
22
+ const [showQR, setShowQR] = useState(false);
23
+ const navigate = useNavigate();
24
+ const location = useLocation();
25
+ const isAdminRoute = location.pathname.startsWith('/admin');
26
+
27
+ useEffect(() => {
28
+ // Request native OS notification permissions
29
+ if ("Notification" in window && Notification.permission !== "granted" && Notification.permission !== "denied") {
30
+ Notification.requestPermission();
31
+ }
32
+
33
+ const id = initDevice();
34
+ if (id) {
35
+ socket.connect();
36
+ socket.emit('join-network', id);
37
+ }
38
+
39
+ // Listen for global listing updates (e.g., marked sold)
40
+ socket.on('listing-updated', (data) => {
41
+ useStore.getState().updateListingStatus(data.id, data.status);
42
+ });
43
+
44
+ socket.on('listing-edited', (data) => {
45
+ useStore.getState().editListingDetails(data.id, { cropName: data.cropName, quantity: data.quantity, price: data.price });
46
+ });
47
+
48
+ socket.on('receive-message', (data) => {
49
+ const state = useStore.getState();
50
+ if (!state.activeChatUser || state.activeChatUser.id !== data.senderId) {
51
+ state.fetchUnreadCount();
52
+ if ("Notification" in window && Notification.permission === "granted") {
53
+ new Notification("New Message - Meri Mandi", {
54
+ body: data.message,
55
+ icon: '/upiqr.jpeg' // Generic icon for app push
56
+ });
57
+ }
58
+ }
59
+ });
60
+
61
+ return () => {
62
+ socket.off('listing-updated');
63
+ socket.off('listing-edited');
64
+ socket.off('receive-message');
65
+ socket.disconnect();
66
+ }
67
+ }, [initDevice]);
68
+
69
+ useEffect(() => {
70
+ if (currentUser) {
71
+ useStore.getState().fetchUnreadCount();
72
+ }
73
+ }, [currentUser]);
74
+
75
+ const handleSwitchProfile = () => {
76
+ logout();
77
+ navigate('/');
78
+ };
79
+
80
+ return (
81
+ <div className={isAdminRoute ? "admin-container" : "app-container"}>
82
+ {/* Main Content Area */}
83
+ {isAdminRoute ? (
84
+ <Routes>
85
+ <Route path="/admin" element={<AdminLogin />} />
86
+ <Route path="/admin/dashboard" element={<AdminDashboard />} />
87
+ </Routes>
88
+ ) : (
89
+ <>
90
+ <header style={{
91
+ padding: '20px',
92
+ display: 'flex',
93
+ justifyContent: 'space-between',
94
+ alignItems: 'center',
95
+ borderBottom: '1px solid var(--border-color)',
96
+ backdropFilter: 'blur(10px)',
97
+ position: 'sticky',
98
+ top: 0,
99
+ zIndex: 10
100
+ }}>
101
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
102
+ <h2 className="text-gradient" onClick={() => navigate('/')} style={{ cursor: 'pointer', margin: 0 }}>🌾 Meri Mandi</h2>
103
+ <button
104
+ onClick={() => setShowQR(true)}
105
+ style={{ background: 'linear-gradient(135deg, #ec4899, #be185d)', color: 'white', border: 'none', borderRadius: '12px', padding: '4px 10px', fontSize: '11px', display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', fontWeight: 600, marginLeft: '8px' }}
106
+ >
107
+ <Heart size={12} fill="white" /> Support App
108
+ </button>
109
+ </div>
110
+ {currentUser && (
111
+ <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
112
+ <span style={{ fontSize: '14px', color: 'var(--text-muted)', textTransform: 'capitalize' }}>
113
+ {currentUser.name} ({currentUser.role})
114
+ </span>
115
+ <button
116
+ onClick={handleSwitchProfile}
117
+ className="btn-secondary"
118
+ style={{ fontSize: '12px', padding: '6px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
119
+ >
120
+ <KeyRound size={14} /> Switch
121
+ </button>
122
+ </div>
123
+ )}
124
+ </header>
125
+
126
+ <main style={{ flex: 1, padding: '20px', overflowY: 'auto' }}>
127
+ <Routes>
128
+ <Route path="/" element={
129
+ !currentUser ? <Auth /> : (
130
+ currentUser.role === 'buyer' ? <Navigate to="/buyer" /> : <Navigate to="/seller" />
131
+ )
132
+ } />
133
+
134
+ <Route path="/buyer" element={
135
+ <ProtectedRoute requiredRole="buyer">
136
+ <BuyerDashboard />
137
+ </ProtectedRoute>
138
+ } />
139
+ <Route path="/buyer/listing/:id" element={
140
+ <ProtectedRoute requiredRole="buyer">
141
+ <ListingDetails />
142
+ </ProtectedRoute>
143
+ } />
144
+
145
+ <Route path="/seller" element={
146
+ <ProtectedRoute requiredRole="seller">
147
+ <SellerDashboard />
148
+ </ProtectedRoute>
149
+ } />
150
+ <Route path="/seller/add" element={
151
+ <ProtectedRoute requiredRole="seller">
152
+ <AddListing />
153
+ </ProtectedRoute>
154
+ } />
155
+ </Routes>
156
+ </main>
157
+
158
+ {/* Floating Global Overlays */}
159
+ <CallOverlay />
160
+ <HelpOverlay />
161
+
162
+ {showQR && (
163
+ <div style={{
164
+ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999,
165
+ background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px'
166
+ }}>
167
+ <div className="glass-panel" style={{ position: 'relative', width: '100%', maxWidth: '350px', padding: '30px', textAlign: 'center' }}>
168
+ <button
169
+ onClick={() => setShowQR(false)}
170
+ style={{ position: 'absolute', top: '15px', right: '15px', background: 'var(--danger-glass)', border: 'none', color: 'var(--danger-color)', width: '30px', height: '30px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
171
+ >
172
+ <X size={16} />
173
+ </button>
174
+ <Heart size={40} color="#ec4899" fill="#ec4899" style={{ marginBottom: '16px' }} />
175
+ <h3 style={{ fontSize: '20px', marginBottom: '8px' }}>Support Meri Mandi</h3>
176
+ <p style={{ color: 'var(--text-muted)', fontSize: '14px', marginBottom: '24px' }}>Scan the QR code below through any UPI app to fund our servers and keep Meri Mandi free for farmers!</p>
177
+ <div style={{ background: 'white', padding: '10px', borderRadius: '16px', display: 'inline-block' }}>
178
+ <img src="/upiqr.jpeg" alt="UPI QR Code" style={{ width: '200px', height: '200px', objectFit: 'contain', display: 'block' }} />
179
+ </div>
180
+ </div>
181
+ </div>
182
+ )}
183
+ <InboxOverlay />
184
+ <ChatOverlay />
185
+ </>
186
+ )}
187
+ </div>
188
+ );
189
+ }
src/assets/hero.png ADDED
src/assets/react.svg ADDED
src/assets/vite.svg ADDED
src/components/AddListing.jsx ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useStore } from '../store';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { Camera, MapPin, X, Loader2, Check } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { CameraCapture } from './CameraCapture';
7
+ import { INDIA_DATA } from '../data/india-data';
8
+
9
+ const INDIAN_STATES = Object.keys(INDIA_DATA);
10
+
11
+ export function AddListing() {
12
+ const { currentUser } = useStore();
13
+ const navigate = useNavigate();
14
+
15
+ const [cropName, setCropName] = useState('');
16
+ const [quantity, setQuantity] = useState('');
17
+ const [price, setPrice] = useState('');
18
+ const [imageFiles, setImageFiles] = useState([]);
19
+ const [imagePreviewUrls, setImagePreviewUrls] = useState([]);
20
+
21
+ const [showCamera, setShowCamera] = useState(false);
22
+ const [location, setLocation] = useState(null);
23
+ const [locating, setLocating] = useState(false);
24
+
25
+ const [nearestCity, setNearestCity] = useState('');
26
+ const [district, setDistrict] = useState('');
27
+ const [state, setState] = useState('');
28
+ const [pincode, setPincode] = useState('');
29
+
30
+ const [showStateList, setShowStateList] = useState(false);
31
+ const [filteredStates, setFilteredStates] = useState(INDIAN_STATES);
32
+ const [showDistrictList, setShowDistrictList] = useState(false);
33
+ const [filteredDistricts, setFilteredDistricts] = useState([]);
34
+
35
+ const [error, setError] = useState('');
36
+ const [isSubmitting, setIsSubmitting] = useState(false);
37
+
38
+ const handleCapture = (file, dataUrl) => {
39
+ if (imageFiles.length >= 5) {
40
+ setError('You can only snap up to 5 photos.');
41
+ setShowCamera(false);
42
+ return;
43
+ }
44
+ setImageFiles(prev => [...prev, file]);
45
+ setImagePreviewUrls(prev => [...prev, dataUrl]);
46
+ setError('');
47
+ setShowCamera(false);
48
+ };
49
+
50
+ const removeImage = (index) => {
51
+ setImageFiles(prev => prev.filter((_, i) => i !== index));
52
+ setImagePreviewUrls(prev => prev.filter((_, i) => i !== index));
53
+ };
54
+
55
+ const getLocation = () => {
56
+ setLocating(true);
57
+ setError('');
58
+
59
+ if (!('geolocation' in navigator)) {
60
+ setError('Geolocation is not supported by your browser.');
61
+ setLocating(false);
62
+ return;
63
+ }
64
+
65
+ const options = {
66
+ enableHighAccuracy: true,
67
+ timeout: 15000,
68
+ maximumAge: 0
69
+ };
70
+
71
+ navigator.geolocation.getCurrentPosition(
72
+ (position) => {
73
+ setLocation({
74
+ lat: position.coords.latitude,
75
+ lng: position.coords.longitude,
76
+ address: `Precise GPS: ${position.coords.latitude.toFixed(4)}, ${position.coords.longitude.toFixed(4)}`
77
+ });
78
+ setLocating(false);
79
+ },
80
+ (err) => {
81
+ console.error("Location Error:", err);
82
+ let msg = 'Failed to get precise location.';
83
+ if (err.code === 1) msg = 'Location permission denied. Please allow location access in your browser settings.';
84
+ else if (err.code === 3) msg = 'Location request timed out. Please try again in an open area.';
85
+
86
+ setError(msg);
87
+
88
+ // Fallback to a default if absolutely necessary, but notify the user
89
+ setLocation({
90
+ lat: 23.0225,
91
+ lng: 72.5714,
92
+ address: 'Approximate Network Location'
93
+ });
94
+ setLocating(false);
95
+ },
96
+ options
97
+ );
98
+ };
99
+
100
+ const handleSubmit = async (e) => {
101
+ e.preventDefault();
102
+ if (imageFiles.length < 2) {
103
+ setError('Please snap at least 2 live photos of your crop.');
104
+ return;
105
+ }
106
+ if (!location) {
107
+ setError('Please provide your location.');
108
+ return;
109
+ }
110
+
111
+ setIsSubmitting(true);
112
+ setError('');
113
+
114
+ try {
115
+ const formData = new FormData();
116
+ formData.append('sellerId', currentUser.id);
117
+ formData.append('sellerName', currentUser.name);
118
+ formData.append('cropName', cropName);
119
+ formData.append('quantity', quantity);
120
+ formData.append('price', price);
121
+ formData.append('location', JSON.stringify(location));
122
+ formData.append('nearestCity', nearestCity);
123
+ formData.append('district', district);
124
+ formData.append('state', state);
125
+ formData.append('pincode', pincode);
126
+
127
+ imageFiles.forEach(file => {
128
+ formData.append('images', file);
129
+ });
130
+
131
+ const res = await fetch('/api/listings', {
132
+ method: 'POST',
133
+ body: formData,
134
+ });
135
+
136
+ const data = await res.json();
137
+
138
+ if (!res.ok) {
139
+ throw new Error(data.error || 'Failed to publish listing');
140
+ }
141
+
142
+ navigate('/seller');
143
+ } catch (err) {
144
+ console.error(err);
145
+ setError(err.message || 'An error occurred during upload. Please try again.');
146
+ } finally {
147
+ setIsSubmitting(false);
148
+ }
149
+ };
150
+
151
+ return (
152
+ <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
153
+ {showCamera && (
154
+ <CameraCapture
155
+ facingMode="environment"
156
+ onClose={() => setShowCamera(false)}
157
+ onCapture={handleCapture}
158
+ />
159
+ )}
160
+
161
+ <h2 style={{ fontSize: '24px', marginBottom: '8px' }}>List Your Crop</h2>
162
+ <p style={{ color: 'var(--text-muted)', marginBottom: '24px' }}>Take live photos directly from the app.</p>
163
+
164
+ {error && (
165
+ <div style={{ background: 'var(--danger-glass)', color: 'var(--danger-color)', padding: '16px', borderRadius: '14px', marginBottom: '20px' }}>
166
+ {error}
167
+ </div>
168
+ )}
169
+
170
+ <form onSubmit={handleSubmit} className="glass-panel" style={{ padding: '24px' }}>
171
+ <div style={{ marginBottom: '20px' }}>
172
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Crop Name</label>
173
+ <input
174
+ type="text"
175
+ className="input-base"
176
+ placeholder="e.g. Organic Tomatoes"
177
+ value={cropName}
178
+ onChange={(e) => setCropName(e.target.value)}
179
+ required
180
+ disabled={isSubmitting}
181
+ />
182
+ </div>
183
+
184
+ <div style={{ marginBottom: '20px' }}>
185
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Available Quantity</label>
186
+ <input
187
+ type="text"
188
+ className="input-base"
189
+ placeholder="e.g. 50 Quintal / 200 Kg"
190
+ value={quantity}
191
+ onChange={(e) => setQuantity(e.target.value)}
192
+ required
193
+ disabled={isSubmitting}
194
+ />
195
+ </div>
196
+
197
+ <div style={{ marginBottom: '20px' }}>
198
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Expected Price (Optional)</label>
199
+ <input
200
+ type="text"
201
+ className="input-base"
202
+ placeholder="e.g. ₹2000 / Quintal"
203
+ value={price}
204
+ onChange={(e) => setPrice(e.target.value)}
205
+ disabled={isSubmitting}
206
+ />
207
+ </div>
208
+
209
+ <div style={{ marginBottom: '24px' }}>
210
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Live Photos (Min 2)</label>
211
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', marginBottom: '12px' }}>
212
+ <AnimatePresence>
213
+ {imagePreviewUrls.map((img, idx) => (
214
+ <motion.div
215
+ initial={{ opacity: 0, scale: 0.8 }}
216
+ animate={{ opacity: 1, scale: 1 }}
217
+ exit={{ opacity: 0, scale: 0.8 }}
218
+ key={idx}
219
+ style={{ position: 'relative', aspectRatio: '1', borderRadius: '12px', overflow: 'hidden' }}
220
+ >
221
+ <img src={img} alt="preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
222
+ <button
223
+ type="button"
224
+ onClick={() => removeImage(idx)}
225
+ disabled={isSubmitting}
226
+ style={{
227
+ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.5)',
228
+ border: 'none', color: 'white', borderRadius: '50%', width: '24px', height: '24px',
229
+ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'
230
+ }}
231
+ >
232
+ <X size={14} />
233
+ </button>
234
+ </motion.div>
235
+ ))}
236
+ </AnimatePresence>
237
+
238
+ {imageFiles.length < 5 && (
239
+ <div
240
+ onClick={() => !isSubmitting && setShowCamera(true)}
241
+ style={{
242
+ border: '2px dashed var(--border-color)', borderRadius: '12px', aspectRatio: '1',
243
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
244
+ cursor: isSubmitting ? 'not-allowed' : 'pointer', background: 'var(--surface-light)', gap: '8px',
245
+ opacity: isSubmitting ? 0.5 : 1
246
+ }}
247
+ >
248
+ <Camera size={24} color="var(--text-muted)" />
249
+ <span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Snap Feed</span>
250
+ </div>
251
+ )}
252
+ </div>
253
+ </div>
254
+
255
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
256
+ <div style={{ position: 'relative' }}>
257
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>State</label>
258
+ <input
259
+ type="text"
260
+ className="input-base"
261
+ placeholder="State Name"
262
+ value={state}
263
+ onChange={(e) => {
264
+ const val = e.target.value;
265
+ setState(val);
266
+ setFilteredStates(INDIAN_STATES.filter(s => s.toLowerCase().includes(val.toLowerCase())));
267
+ setShowStateList(true);
268
+ setDistrict('');
269
+ }}
270
+ onFocus={() => setShowStateList(true)}
271
+ disabled={isSubmitting}
272
+ required
273
+ />
274
+ <AnimatePresence>
275
+ {showStateList && filteredStates.length > 0 && (
276
+ <motion.div
277
+ initial={{ opacity: 0, y: -10 }}
278
+ animate={{ opacity: 1, y: 0 }}
279
+ exit={{ opacity: 0, y: -10 }}
280
+ style={{
281
+ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 110,
282
+ background: 'var(--surface-light)', border: '1px solid var(--primary-color)',
283
+ borderRadius: '12px', marginTop: '4px', maxHeight: '200px', overflowY: 'auto',
284
+ boxShadow: '0 10px 25px rgba(0,0,0,0.8)',
285
+ backdropFilter: 'blur(20px)'
286
+ }}
287
+ >
288
+ {filteredStates.map(s => (
289
+ <div
290
+ key={s}
291
+ onClick={() => {
292
+ setState(s);
293
+ setShowStateList(false);
294
+ setDistrict('');
295
+ setFilteredDistricts(INDIA_DATA[s] || []);
296
+ }}
297
+ style={{
298
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
299
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
300
+ }}
301
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
302
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
303
+ >
304
+ <span style={{ fontSize: '14px' }}>{s}</span>
305
+ {state === s && <Check size={14} color="var(--primary-color)" />}
306
+ </div>
307
+ ))}
308
+ </motion.div>
309
+ )}
310
+ </AnimatePresence>
311
+ {showStateList && <div onClick={() => setShowStateList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
312
+ </div>
313
+
314
+ <div style={{ position: 'relative' }}>
315
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>District</label>
316
+ <input
317
+ type="text"
318
+ className="input-base"
319
+ placeholder={state ? "District Name" : "Select State First"}
320
+ value={district}
321
+ onChange={(e) => {
322
+ const val = e.target.value;
323
+ setDistrict(val);
324
+ const ds = INDIA_DATA[state] || [];
325
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(val.toLowerCase())));
326
+ setShowDistrictList(true);
327
+ }}
328
+ onFocus={() => {
329
+ if (state) {
330
+ const ds = INDIA_DATA[state] || [];
331
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(district.toLowerCase())));
332
+ setShowDistrictList(true);
333
+ }
334
+ }}
335
+ disabled={isSubmitting || !state}
336
+ required
337
+ />
338
+ <AnimatePresence>
339
+ {showDistrictList && filteredDistricts.length > 0 && (
340
+ <motion.div
341
+ initial={{ opacity: 0, y: -10 }}
342
+ animate={{ opacity: 1, y: 0 }}
343
+ exit={{ opacity: 0, y: -10 }}
344
+ style={{
345
+ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 110,
346
+ background: 'var(--surface-light)', border: '1px solid var(--primary-color)',
347
+ borderRadius: '12px', marginTop: '4px', maxHeight: '200px', overflowY: 'auto',
348
+ boxShadow: '0 10px 25px rgba(0,0,0,0.8)',
349
+ backdropFilter: 'blur(20px)'
350
+ }}
351
+ >
352
+ {filteredDistricts.map(d => (
353
+ <div
354
+ key={d}
355
+ onClick={() => {
356
+ setDistrict(d);
357
+ setShowDistrictList(false);
358
+ }}
359
+ style={{
360
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
361
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
362
+ }}
363
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
364
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
365
+ >
366
+ <span style={{ fontSize: '14px' }}>{d}</span>
367
+ {district === d && <Check size={14} color="var(--primary-color)" />}
368
+ </div>
369
+ ))}
370
+ </motion.div>
371
+ )}
372
+ </AnimatePresence>
373
+ {showDistrictList && <div onClick={() => setShowDistrictList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
374
+ </div>
375
+ </div>
376
+
377
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
378
+ <div>
379
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>Nearest City</label>
380
+ <input
381
+ type="text"
382
+ className="input-base"
383
+ placeholder="Nearest City"
384
+ value={nearestCity}
385
+ onChange={(e) => setNearestCity(e.target.value)}
386
+ disabled={isSubmitting}
387
+ required
388
+ />
389
+ </div>
390
+ <div>
391
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>Pincode</label>
392
+ <input
393
+ type="text"
394
+ className="input-base"
395
+ placeholder="Pincode"
396
+ value={pincode}
397
+ onChange={(e) => setPincode(e.target.value)}
398
+ disabled={isSubmitting}
399
+ required
400
+ />
401
+ </div>
402
+ </div>
403
+
404
+ <div style={{ marginBottom: '32px' }}>
405
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>
406
+ Location <span style={{ color: 'var(--danger-color)', fontSize: '13px' }}>(Required*)</span>
407
+ </label>
408
+ <div
409
+ onClick={isSubmitting ? null : getLocation}
410
+ style={{
411
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
412
+ padding: '16px', background: 'var(--surface-light)', borderRadius: '14px',
413
+ border: `1px solid ${location ? 'var(--primary-color)' : (error.includes('location') ? 'var(--danger-color)' : 'var(--border-color)')}`,
414
+ cursor: isSubmitting ? 'not-allowed' : 'pointer', transition: 'all 0.2s',
415
+ opacity: isSubmitting ? 0.5 : 1,
416
+ boxShadow: !location && error.includes('location') ? '0 0 0 2px var(--danger-glass)' : 'none'
417
+ }}
418
+ >
419
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
420
+ <MapPin size={24} color={location ? '#10b981' : (error.includes('location') ?'#ef4444' : '#94a3b8')} />
421
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
422
+ <span style={{ fontWeight: 600, color: location ? '#10b981' : (error.includes('location') ? '#ef4444' : '#f8fafc') }}>
423
+ {location ? 'Precise Location Captured' : (locating ? 'Capturing GPS...' : 'Get Primary Location (GPS)')}
424
+ </span>
425
+ {location && <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{location.address}</span>}
426
+ </div>
427
+ </div>
428
+ {locating && <Loader2 className="animate-spin" size={18} color="var(--primary-color)" />}
429
+ </div>
430
+ </div>
431
+
432
+ <button type="submit" disabled={isSubmitting} className="btn-primary" style={{ width: '100%', padding: '16px' }}>
433
+ {isSubmitting ? <><Loader2 className="animate-spin" size={20} /> Publishing...</> : 'Publish Listing'}
434
+ </button>
435
+ </form>
436
+ </motion.div>
437
+ );
438
+ }
src/components/Auth.jsx ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useStore } from '../store';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { Leaf, Store, User, Camera, Loader2, X, MapPin, Check } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { CameraCapture } from './CameraCapture';
7
+ import { INDIA_DATA } from '../data/india-data';
8
+
9
+ const INDIAN_STATES = Object.keys(INDIA_DATA);
10
+
11
+ export function Auth() {
12
+ const { login, deviceId, initDevice, deviceProfiles, deviceProfilesLoaded } = useStore();
13
+ const navigate = useNavigate();
14
+ const [name, setName] = useState('');
15
+ const [contact, setContact] = useState('');
16
+ const [nearestCity, setNearestCity] = useState('');
17
+ const [district, setDistrict] = useState('');
18
+ const [state, setState] = useState('');
19
+ const [pincode, setPincode] = useState('');
20
+ const [role, setRole] = useState('buyer');
21
+
22
+ const [showStateList, setShowStateList] = useState(false);
23
+ const [filteredStates, setFilteredStates] = useState(INDIAN_STATES);
24
+
25
+ const [showDistrictList, setShowDistrictList] = useState(false);
26
+ const [filteredDistricts, setFilteredDistricts] = useState([]);
27
+
28
+ const [location, setLocation] = useState(null);
29
+ const [locating, setLocating] = useState(false);
30
+
31
+ const [selfieFile, setSelfieFile] = useState(null);
32
+ const [selfiePreview, setSelfiePreview] = useState(null);
33
+ const [showCamera, setShowCamera] = useState(false);
34
+ const [isCreating, setIsCreating] = useState(false);
35
+ const [error, setError] = useState('');
36
+ const [isSubmitting, setIsSubmitting] = useState(false);
37
+
38
+ // Auto-init device id just in case they hit auth directly
39
+ if (!deviceId) initDevice();
40
+
41
+ const removeImage = () => {
42
+ setSelfieFile(null);
43
+ setSelfiePreview(null);
44
+ };
45
+
46
+ const getLocation = () => {
47
+ setLocating(true);
48
+ setError('');
49
+
50
+ if (!('geolocation' in navigator)) {
51
+ setError('Geolocation is not supported by your browser.');
52
+ setLocating(false);
53
+ return;
54
+ }
55
+
56
+ const options = {
57
+ enableHighAccuracy: true,
58
+ timeout: 15000,
59
+ maximumAge: 0
60
+ };
61
+
62
+ navigator.geolocation.getCurrentPosition(
63
+ (position) => {
64
+ setLocation({
65
+ lat: position.coords.latitude,
66
+ lng: position.coords.longitude,
67
+ address: `Precise GPS: ${position.coords.latitude.toFixed(4)}, ${position.coords.longitude.toFixed(4)}`
68
+ });
69
+ setLocating(false);
70
+ },
71
+ (err) => {
72
+ console.error("Auth Location Error:", err);
73
+ let msg = 'Failed to get precise GPS location.';
74
+ if (err.code === 1) msg = 'Location permission denied. Please allow location access in your browser settings.';
75
+ else if (err.code === 3) msg = 'Location request timed out. Please try again in an open area.';
76
+
77
+ setError(msg);
78
+ setLocating(false);
79
+ },
80
+ options
81
+ );
82
+ };
83
+
84
+ const handleLogin = async (e) => {
85
+ e.preventDefault();
86
+ if (!name.trim()) return;
87
+
88
+ // Validate Indian phone number format
89
+ const phoneRegex = /^(?:\+?91[-\s]?)?[0]?(?:91)?[6789]\d{9}$/;
90
+ if (!phoneRegex.test(contact)) {
91
+ setError('Please enter a valid 10-digit Indian phone number.');
92
+ return;
93
+ }
94
+
95
+ if (!selfieFile) {
96
+ setError('A live selfie snapshot is strictly required to ensure network trust.');
97
+ return;
98
+ }
99
+
100
+ if (!location) {
101
+ setError('Please capture your primary GPS location.');
102
+ return;
103
+ }
104
+
105
+ if (!nearestCity || !district || !state || !pincode) {
106
+ setError('Please fill in all location details.');
107
+ return;
108
+ }
109
+
110
+ setIsSubmitting(true);
111
+ setError('');
112
+
113
+ try {
114
+ const { deviceId: currentDeviceId, deviceToken } = deviceId ? { deviceId, deviceToken: useStore.getState().deviceToken } : initDevice();
115
+
116
+ const formData = new FormData();
117
+ formData.append('id', currentDeviceId);
118
+ formData.append('deviceToken', deviceToken);
119
+ formData.append('name', name);
120
+ formData.append('contact', contact);
121
+ formData.append('role', role);
122
+ formData.append('nearestCity', nearestCity);
123
+ formData.append('district', district);
124
+ formData.append('state', state);
125
+ formData.append('pincode', pincode);
126
+ formData.append('location', JSON.stringify(location));
127
+ formData.append('selfie', selfieFile);
128
+
129
+ const res = await fetch('/api/users', {
130
+ method: 'POST',
131
+ body: formData,
132
+ });
133
+
134
+ const data = await res.json();
135
+
136
+ if (!res.ok) {
137
+ throw new Error(data.error || 'Failed to register identity.');
138
+ }
139
+
140
+ console.log('REGISTRATION SUCCESS:', data);
141
+ console.log('NAVIGATING TO:', (role === 'buyer' ? '/buyer' : '/seller'));
142
+
143
+ // Zustand handles our local user binding
144
+ login(data);
145
+
146
+ navigate(role === 'buyer' ? '/buyer' : '/seller');
147
+ } catch (err) {
148
+ console.error(err);
149
+ setError(err.message || 'Registration failed. Please try again.');
150
+ } finally {
151
+ setIsSubmitting(false);
152
+ }
153
+ };
154
+
155
+ const handleProfileSelect = (profile) => {
156
+ login(profile);
157
+ navigate(profile.role === 'buyer' ? '/buyer' : '/seller');
158
+ };
159
+
160
+ const hasProfiles = deviceProfilesLoaded && deviceProfiles.length > 0;
161
+ const isCreatingNew = !hasProfiles || isCreating;
162
+
163
+ const hasBuyer = deviceProfiles.some(p => p.role === 'buyer');
164
+ const hasSeller = deviceProfiles.some(p => p.role === 'seller');
165
+
166
+ if (!deviceProfilesLoaded) {
167
+ return (
168
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
169
+ <Loader2 className="animate-spin" size={32} color="var(--primary-color)" />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ return (
175
+ <motion.div
176
+ initial={{ opacity: 0, y: 20 }}
177
+ animate={{ opacity: 1, y: 0 }}
178
+ transition={{ duration: 0.5 }}
179
+ style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100%', paddingBottom: '40px' }}
180
+ >
181
+ {showCamera && (
182
+ <CameraCapture
183
+ facingMode="user"
184
+ onClose={() => setShowCamera(false)}
185
+ onCapture={(file, dataUrl) => {
186
+ setSelfieFile(file);
187
+ setSelfiePreview(dataUrl);
188
+ setShowCamera(false);
189
+ setError('');
190
+ }}
191
+ />
192
+ )}
193
+
194
+ <div style={{ textAlign: 'center', marginBottom: '30px', marginTop: '30px' }}>
195
+ <div style={{
196
+ width: '70px', height: '70px', borderRadius: '24px',
197
+ background: 'linear-gradient(135deg, #10b981, #059669)',
198
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
199
+ margin: '0 auto 16px',
200
+ boxShadow: '0 10px 30px rgba(16, 185, 129, 0.3)'
201
+ }}>
202
+ <Leaf size={32} color="white" />
203
+ </div>
204
+ <h1 style={{ fontSize: '28px', marginBottom: '8px' }}>Welcome to Meri Mandi</h1>
205
+ <p style={{ color: 'var(--text-muted)' }}>The smart way to buy and sell crops directly.</p>
206
+ </div>
207
+
208
+ {error && (
209
+ <div style={{ width: '100%', background: 'var(--danger-glass)', color: 'var(--danger-color)', padding: '16px', borderRadius: '14px', marginBottom: '20px', textAlign: 'center' }}>
210
+ {error}
211
+ </div>
212
+ )}
213
+
214
+ {isCreatingNew ? (
215
+ <form onSubmit={handleLogin} className="glass-panel" style={{ width: '100%', padding: '24px' }}>
216
+
217
+ {hasProfiles && (
218
+ <div style={{ marginBottom: '24px' }}>
219
+ <button
220
+ type="button"
221
+ onClick={() => setIsCreating(false)}
222
+ className="btn-secondary"
223
+ style={{ width: '100%', padding: '12px' }}
224
+ >
225
+ Cancel Creation
226
+ </button>
227
+ </div>
228
+ )}
229
+
230
+ {/* Selfie Upload UI */}
231
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '24px' }}>
232
+ <p style={{ display: 'block', marginBottom: '12px', fontWeight: 500 }}>Take a Live Selfie Profile</p>
233
+
234
+ {selfiePreview ? (
235
+ <div style={{ position: 'relative', width: '120px', height: '120px', borderRadius: '50%', overflow: 'hidden', boxShadow: '0 0 20px rgba(0,0,0,0.5)' }}>
236
+ <img src={selfiePreview} alt="Selfie" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
237
+ <button
238
+ type="button"
239
+ onClick={removeImage}
240
+ style={{
241
+ position: 'absolute', top: 8, right: 8, background: 'rgba(239, 68, 68, 0.9)',
242
+ border: 'none', color: 'white', borderRadius: '50%', width: '24px', height: '24px',
243
+ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'
244
+ }}
245
+ >
246
+ <X size={14} />
247
+ </button>
248
+ </div>
249
+ ) : (
250
+ <div
251
+ onClick={() => setShowCamera(true)}
252
+ style={{
253
+ width: '120px', height: '120px', borderRadius: '50%',
254
+ border: '2px dashed var(--primary-color)',
255
+ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
256
+ cursor: 'pointer', background: 'var(--primary-glow)', gap: '8px'
257
+ }}
258
+ >
259
+ <Camera size={28} color="white" />
260
+ <span style={{ fontSize: '12px', fontWeight: 600 }}>Snap Live</span>
261
+ </div>
262
+ )}
263
+ <p style={{ marginTop: '12px', fontSize: '12px', color: 'var(--text-muted)' }}>We strictly ask for you to take a live photo over the network.</p>
264
+ </div>
265
+
266
+ <div style={{ marginBottom: '20px' }}>
267
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Your Name</label>
268
+ <input
269
+ type="text"
270
+ className="input-base"
271
+ placeholder="Enter your name"
272
+ value={name}
273
+ onChange={(e) => setName(e.target.value)}
274
+ disabled={isSubmitting}
275
+ required
276
+ />
277
+ </div>
278
+
279
+ <div style={{ marginBottom: '20px' }}>
280
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Contact Number</label>
281
+ <input
282
+ type="tel"
283
+ className="input-base"
284
+ placeholder="Enter your phone number"
285
+ value={contact}
286
+ onChange={(e) => setContact(e.target.value)}
287
+ disabled={isSubmitting}
288
+ required
289
+ />
290
+ </div>
291
+
292
+ {/* New Location Fields */}
293
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
294
+ <div style={{ position: 'relative' }}>
295
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>State</label>
296
+ <input
297
+ type="text"
298
+ className="input-base"
299
+ placeholder="State Name"
300
+ value={state}
301
+ onChange={(e) => {
302
+ const val = e.target.value;
303
+ setState(val);
304
+ setFilteredStates(INDIAN_STATES.filter(s => s.toLowerCase().includes(val.toLowerCase())));
305
+ setShowStateList(true);
306
+ setDistrict(''); // Reset district on state change
307
+ }}
308
+ onFocus={() => setShowStateList(true)}
309
+ disabled={isSubmitting}
310
+ required
311
+ />
312
+ <AnimatePresence>
313
+ {showStateList && filteredStates.length > 0 && (
314
+ <motion.div
315
+ initial={{ opacity: 0, y: -10 }}
316
+ animate={{ opacity: 1, y: 0 }}
317
+ exit={{ opacity: 0, y: -10 }}
318
+ style={{
319
+ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 110,
320
+ background: 'var(--surface-light)', border: '1px solid var(--primary-color)',
321
+ borderRadius: '12px', marginTop: '4px', maxHeight: '200px', overflowY: 'auto',
322
+ boxShadow: '0 10px 25px rgba(0,0,0,0.8)',
323
+ backdropFilter: 'blur(20px)'
324
+ }}
325
+ >
326
+ {filteredStates.map(s => (
327
+ <div
328
+ key={s}
329
+ onClick={() => {
330
+ setState(s);
331
+ setShowStateList(false);
332
+ setError('');
333
+ setDistrict('');
334
+ setFilteredDistricts(INDIA_DATA[s] || []);
335
+ }}
336
+ style={{
337
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
338
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
339
+ }}
340
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
341
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
342
+ >
343
+ <span style={{ fontSize: '14px' }}>{s}</span>
344
+ {state === s && <Check size={14} color="var(--primary-color)" />}
345
+ </div>
346
+ ))}
347
+ </motion.div>
348
+ )}
349
+ </AnimatePresence>
350
+ {showStateList && <div onClick={() => setShowStateList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
351
+ </div>
352
+
353
+ <div style={{ position: 'relative' }}>
354
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>District</label>
355
+ <input
356
+ type="text"
357
+ className="input-base"
358
+ placeholder={state ? "District Name" : "Select State First"}
359
+ value={district}
360
+ onChange={(e) => {
361
+ const val = e.target.value;
362
+ setDistrict(val);
363
+ const ds = INDIA_DATA[state] || [];
364
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(val.toLowerCase())));
365
+ setShowDistrictList(true);
366
+ }}
367
+ onFocus={() => {
368
+ if (state) {
369
+ const ds = INDIA_DATA[state] || [];
370
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(district.toLowerCase())));
371
+ setShowDistrictList(true);
372
+ }
373
+ }}
374
+ disabled={isSubmitting || !state}
375
+ required
376
+ />
377
+ <AnimatePresence>
378
+ {showDistrictList && filteredDistricts.length > 0 && (
379
+ <motion.div
380
+ initial={{ opacity: 0, y: -10 }}
381
+ animate={{ opacity: 1, y: 0 }}
382
+ exit={{ opacity: 0, y: -10 }}
383
+ style={{
384
+ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 110,
385
+ background: 'var(--surface-light)', border: '1px solid var(--primary-color)',
386
+ borderRadius: '12px', marginTop: '4px', maxHeight: '200px', overflowY: 'auto',
387
+ boxShadow: '0 10px 25px rgba(0,0,0,0.8)',
388
+ backdropFilter: 'blur(20px)'
389
+ }}
390
+ >
391
+ {filteredDistricts.map(d => (
392
+ <div
393
+ key={d}
394
+ onClick={() => {
395
+ setDistrict(d);
396
+ setShowDistrictList(false);
397
+ setError('');
398
+ }}
399
+ style={{
400
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
401
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
402
+ }}
403
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
404
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
405
+ >
406
+ <span style={{ fontSize: '14px' }}>{d}</span>
407
+ {district === d && <Check size={14} color="var(--primary-color)" />}
408
+ </div>
409
+ ))}
410
+ </motion.div>
411
+ )}
412
+ </AnimatePresence>
413
+ {showDistrictList && <div onClick={() => setShowDistrictList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
414
+ </div>
415
+ </div>
416
+
417
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
418
+ <div>
419
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>Nearest City</label>
420
+ <input
421
+ type="text"
422
+ className="input-base"
423
+ placeholder="Nearest City"
424
+ value={nearestCity}
425
+ onChange={(e) => setNearestCity(e.target.value)}
426
+ disabled={isSubmitting}
427
+ required
428
+ />
429
+ </div>
430
+ <div>
431
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500, fontSize: '14px' }}>Pincode</label>
432
+ <input
433
+ type="text"
434
+ className="input-base"
435
+ placeholder="Pincode"
436
+ value={pincode}
437
+ onChange={(e) => setPincode(e.target.value)}
438
+ disabled={isSubmitting}
439
+ required
440
+ />
441
+ </div>
442
+ </div>
443
+
444
+ <div style={{ marginBottom: '24px' }}>
445
+ <label style={{ display: 'block', marginBottom: '8px', fontWeight: 500 }}>Primary Location (GPS)</label>
446
+ <div
447
+ onClick={isSubmitting ? null : getLocation}
448
+ style={{
449
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
450
+ padding: '16px', background: 'var(--surface-light)', borderRadius: '14px',
451
+ border: `1px solid ${location ? 'var(--primary-color)' : 'var(--border-color)'}`,
452
+ cursor: isSubmitting ? 'not-allowed' : 'pointer', transition: 'all 0.2s',
453
+ opacity: isSubmitting ? 0.5 : 1
454
+ }}
455
+ >
456
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
457
+ <MapPin size={24} color={location ? '#10b981' : '#94a3b8'} />
458
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
459
+ <span style={{ fontWeight: 600, color: location ? '#10b981' : '#f8fafc' }}>
460
+ {location ? 'Precise Location Captured' : 'Get Primary Location (GPS)'}
461
+ </span>
462
+ {location && <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{location.address}</span>}
463
+ </div>
464
+ </div>
465
+ {locating && <Loader2 className="animate-spin" size={18} color="var(--text-muted)" />}
466
+ </div>
467
+ </div>
468
+
469
+ <div style={{ marginBottom: '24px' }}>
470
+ <label style={{ display: 'block', marginBottom: '12px', fontWeight: 500 }}>Select Role</label>
471
+ <div style={{ display: 'flex', gap: '12px' }}>
472
+ <div
473
+ onClick={() => !isSubmitting && !hasBuyer && setRole('buyer')}
474
+ style={{
475
+ flex: 1, padding: '16px', borderRadius: '16px',
476
+ border: `2px solid ${role === 'buyer' ? 'var(--primary-color)' : 'var(--border-color)'}`,
477
+ background: role === 'buyer' ? 'var(--primary-glow)' : 'transparent',
478
+ cursor: isSubmitting || hasBuyer ? 'not-allowed' : 'pointer', textAlign: 'center', transition: 'all 0.2s',
479
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
480
+ opacity: isSubmitting || hasBuyer ? 0.5 : 1
481
+ }}
482
+ >
483
+ <User size={24} color={role === 'buyer' ? '#10b981' : '#f8fafc'} />
484
+ <span style={{ fontWeight: 600, color: role === 'buyer' ? '#10b981' : '#f8fafc' }}>{hasBuyer ? 'Buyer (Owned)' : 'Buyer'}</span>
485
+ </div>
486
+
487
+ <div
488
+ onClick={() => !isSubmitting && !hasSeller && setRole('seller')}
489
+ style={{
490
+ flex: 1, padding: '16px', borderRadius: '16px',
491
+ border: `2px solid ${role === 'seller' ? 'var(--primary-color)' : 'var(--border-color)'}`,
492
+ background: role === 'seller' ? 'var(--primary-glow)' : 'transparent',
493
+ cursor: isSubmitting || hasSeller ? 'not-allowed' : 'pointer', textAlign: 'center', transition: 'all 0.2s',
494
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
495
+ opacity: isSubmitting || hasSeller ? 0.5 : 1
496
+ }}
497
+ >
498
+ <Store size={24} color={role === 'seller' ? '#10b981' : '#f8fafc'} />
499
+ <span style={{ fontWeight: 600, color: role === 'seller' ? '#10b981' : '#f8fafc' }}>{hasSeller ? 'Seller (Owned)' : 'Seller'}</span>
500
+ </div>
501
+ </div>
502
+ </div>
503
+
504
+ <button type="submit" disabled={isSubmitting || (role === 'buyer' && hasBuyer) || (role === 'seller' && hasSeller)} className="btn-primary" style={{ width: '100%', padding: '16px' }}>
505
+ {isSubmitting ? <><Loader2 className="animate-spin" size={20} /> Registering...</> : `Create ${role === 'buyer' ? 'Buyer' : 'Seller'} Profile`}
506
+ </button>
507
+ </form>
508
+ ) : (
509
+ <div style={{ width: '100%' }}>
510
+ <h3 style={{ fontSize: '20px', marginBottom: '16px', textAlign: 'center' }}>Select Profile</h3>
511
+ <div style={{ display: 'grid', gap: '16px', marginBottom: '24px' }}>
512
+ {deviceProfiles.map(profile => (
513
+ <div
514
+ key={profile.id}
515
+ onClick={() => handleProfileSelect(profile)}
516
+ className="glass-panel"
517
+ style={{
518
+ padding: '20px', display: 'flex', alignItems: 'center', gap: '16px',
519
+ cursor: 'pointer', transition: 'transform 0.2s, box-shadow 0.2s'
520
+ }}
521
+ onMouseOver={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
522
+ onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
523
+ >
524
+ <div style={{ width: '60px', height: '60px', borderRadius: '50%', overflow: 'hidden', border: '2px solid var(--primary-color)' }}>
525
+ <img src={profile.selfiePath} alt="Selfie" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
526
+ </div>
527
+ <div style={{ flex: 1 }}>
528
+ <h4 style={{ fontSize: '18px', marginBottom: '4px' }}>{profile.name}</h4>
529
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
530
+ {profile.role === 'buyer' ? <User size={14} color="var(--primary-color)" /> : <Store size={14} color="var(--primary-color)" />}
531
+ <span style={{ fontSize: '14px', color: 'var(--text-muted)', textTransform: 'capitalize' }}>{profile.role}</span>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ ))}
536
+ </div>
537
+
538
+ {deviceProfiles.length < 2 && (
539
+ <button
540
+ className="btn-secondary"
541
+ style={{ width: '100%', padding: '16px', borderStyle: 'dashed' }}
542
+ onClick={() => {
543
+ setRole(hasBuyer ? 'seller' : 'buyer');
544
+ setIsCreating(true);
545
+ }}
546
+ >
547
+ + Create {hasBuyer ? 'Seller' : 'Buyer'} Profile
548
+ </button>
549
+ )}
550
+ </div>
551
+ )}
552
+ </motion.div>
553
+ );
554
+ }
src/components/BuyerDashboard.jsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '../store';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Search, MapPin, Grid, List as ListIcon, Loader2, MessageSquare } from 'lucide-react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import { useState, useEffect } from 'react';
6
+
7
+ export function BuyerDashboard() {
8
+ const { listings, fetchListings, isLoading, toggleInbox, unreadCount } = useStore();
9
+ const navigate = useNavigate();
10
+ const [viewMode, setViewMode] = useState('list');
11
+ const [searchTerm, setSearchTerm] = useState('');
12
+
13
+ useEffect(() => {
14
+ fetchListings();
15
+ // eslint-disable-next-line react-hooks/exhaustive-deps
16
+ }, []);
17
+
18
+ const filteredListings = listings.filter(l =>
19
+ l.status !== 'sold' && (
20
+ l.cropName.toLowerCase().includes(searchTerm.toLowerCase()) ||
21
+ l.sellerName.toLowerCase().includes(searchTerm.toLowerCase())
22
+ )
23
+ );
24
+
25
+ return (
26
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
27
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '24px' }}>
28
+ <div>
29
+ <h2 style={{ fontSize: '28px', marginBottom: '8px' }}>Explore Crops</h2>
30
+ <p style={{ color: 'var(--text-muted)' }}>Find fresh produce directly from farmers.</p>
31
+ </div>
32
+ <button
33
+ onClick={toggleInbox}
34
+ style={{ position: 'relative', background: 'var(--surface-light)', border: 'none', color: 'var(--primary-color)', width: '48px', height: '48px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: '0.2s' }}
35
+ onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
36
+ onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
37
+ >
38
+ <MessageSquare size={24} />
39
+ {unreadCount > 0 && (
40
+ <div style={{ position: 'absolute', top: '8px', right: '8px', background: 'var(--danger-color)', color: 'white', fontSize: '10px', fontWeight: 'bold', width: '16px', height: '16px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '2px solid var(--surface-light)' }}>
41
+ {unreadCount > 9 ? '9+' : unreadCount}
42
+ </div>
43
+ )}
44
+ </button>
45
+ </div>
46
+
47
+ <div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
48
+ <div style={{ flex: 1, position: 'relative' }}>
49
+ <Search size={20} color="var(--text-muted)" style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)' }} />
50
+ <input
51
+ type="text"
52
+ className="input-base"
53
+ placeholder="Search crops or farmers..."
54
+ value={searchTerm}
55
+ onChange={(e) => setSearchTerm(e.target.value)}
56
+ style={{ paddingLeft: '48px', borderRadius: '14px', background: 'var(--surface-light)' }}
57
+ />
58
+ </div>
59
+ <div style={{ display: 'flex', background: 'var(--surface-light)', borderRadius: '14px', padding: '4px' }}>
60
+ <button
61
+ className="btn-icon-circular"
62
+ onClick={() => setViewMode('grid')}
63
+ style={{ width: '40px', height: '40px', background: viewMode === 'grid' ? 'var(--bg-color)' : 'transparent', borderRadius: '10px' }}
64
+ >
65
+ <Grid size={18} color={viewMode === 'grid' ? '#10b981' : '#f8fafc'} />
66
+ </button>
67
+ <button
68
+ className="btn-icon-circular"
69
+ onClick={() => setViewMode('list')}
70
+ style={{ width: '40px', height: '40px', background: viewMode === 'list' ? 'var(--bg-color)' : 'transparent', borderRadius: '10px' }}
71
+ >
72
+ <ListIcon size={18} color={viewMode === 'list' ? '#10b981' : '#f8fafc'} />
73
+ </button>
74
+ </div>
75
+ </div>
76
+
77
+ {isLoading ? (
78
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
79
+ <Loader2 className="animate-spin" size={32} color="var(--primary-color)" />
80
+ </div>
81
+ ) : (
82
+ <AnimatePresence>
83
+ {filteredListings.length === 0 ? (
84
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ textAlign: 'center', padding: '40px' }}>
85
+ <p style={{ color: 'var(--text-muted)' }}>No crops found matching your search.</p>
86
+ </motion.div>
87
+ ) : (
88
+ <motion.div
89
+ layout
90
+ style={{
91
+ display: 'grid',
92
+ gridTemplateColumns: viewMode === 'grid' ? 'repeat(auto-fill, minmax(140px, 1fr))' : '1fr',
93
+ gap: '12px'
94
+ }}
95
+ >
96
+ {filteredListings.map(listing => (
97
+ <motion.div
98
+ layout
99
+ key={listing.id}
100
+ className="glass-panel"
101
+ onClick={() => navigate(`/buyer/listing/${listing.id}`)}
102
+ whileHover={{ y: -4, scale: 1.02 }}
103
+ style={{
104
+ overflow: 'hidden', cursor: 'pointer',
105
+ display: 'flex',
106
+ flexDirection: viewMode === 'list' ? 'row' : 'column',
107
+ alignItems: viewMode === 'list' ? 'center' : 'stretch'
108
+ }}
109
+ >
110
+ <div style={{
111
+ width: viewMode === 'list' ? '100px' : '100%',
112
+ height: viewMode === 'list' ? '100px' : '120px',
113
+ flexShrink: 0
114
+ }}>
115
+ <img
116
+ src={listing.images[0]}
117
+ alt={listing.cropName}
118
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
119
+ />
120
+ </div>
121
+ <div style={{ padding: '12px', flex: 1, display: 'flex', flexDirection: 'column' }}>
122
+ <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '2px' }}>
123
+ {new Date(listing.timestamp).toLocaleDateString()}
124
+ </div>
125
+ <h3 style={{ fontSize: '15px', marginBottom: '4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
126
+ {listing.cropName} {listing.quantity && <span style={{ fontSize: '12px', color: 'var(--text-muted)', fontWeight: 'normal' }}>({listing.quantity})</span>}
127
+ </h3>
128
+ <p className="text-gradient" style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>
129
+ {listing.price || 'Contact for price'}
130
+ </p>
131
+
132
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', borderTop: '1px solid var(--border-color)', paddingTop: '8px' }}>
133
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--text-muted)', fontSize: '11px' }}>
134
+ <MapPin size={11} />
135
+ <span style={{ maxWidth: viewMode === 'grid' ? '100px' : '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
136
+ {listing.nearestCity ? `${listing.nearestCity}, ${listing.district}, ${listing.state}` : listing.location.address}
137
+ </span>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </motion.div>
142
+ ))}
143
+ </motion.div>
144
+ )}
145
+ </AnimatePresence>
146
+ )}
147
+ </motion.div>
148
+ );
149
+ }