Upload 65 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +13 -0
- .env.example +13 -0
- .gitattributes +1 -0
- .gitignore +29 -0
- Dockerfile +53 -0
- eslint.config.js +30 -0
- index.html +13 -0
- package-lock.json +0 -0
- package.json +54 -0
- public/favicon.svg +1 -0
- public/icons.svg +24 -0
- public/upiqr.jpeg +0 -0
- scripts/migrate.js +23 -0
- server/db.js +95 -0
- server/index.js +722 -0
- server/mandi.db +0 -0
- server/models/group1-shard1of1 +3 -0
- server/models/model.json +0 -0
- server/test-nsfw.js +10 -0
- server/uploads/24c331bc-c580-4b26-8b66-bd952c84ddab.jpg +0 -0
- server/uploads/2eaf1e25-d636-4498-80a5-952b6f597a96.jpg +0 -0
- server/uploads/2fc68f29-0051-43b5-8267-8ec386ce5a0e.jpg +0 -0
- server/uploads/4104a2e9-4716-4945-8cb2-9d82314d5f46.jpg +0 -0
- server/uploads/445a0c5c-f518-4603-b56a-40947daf9514.jpg +0 -0
- server/uploads/4df08cb1-3942-4a11-a212-729ab615e238.jpg +0 -0
- server/uploads/5275088a-59e6-49bb-b8b5-be64df4b7ed0.jpg +0 -0
- server/uploads/61696cec-0adb-4f3d-9063-cd61412a235e.jpg +0 -0
- server/uploads/68ecf409-8fbe-4513-9d83-a256923cc58c.jpg +0 -0
- server/uploads/6a01ff8b-b925-49ed-97f7-e98e2c13faac.jpg +0 -0
- server/uploads/6c68841c-5d0a-4033-84e6-66f792ea94fe.jpg +0 -0
- server/uploads/8a05b98d-72db-400b-b506-e091c8d7d6a8.jpg +0 -0
- server/uploads/8bed15fe-ee0a-45f0-a649-3bc3d15e618f.jpg +0 -0
- server/uploads/8d906b44-c7f7-401d-aecf-57015bba7031.jpg +0 -0
- server/uploads/8e39ceb8-aa0a-4c2b-985e-c05aeaee9a47.jpg +0 -0
- server/uploads/9706a044-d954-4ea7-92bc-3ee9acb04f54.jpg +0 -0
- server/uploads/aea7aa8f-f4ee-4e32-94c4-32bb9ecfc7eb.jpg +0 -0
- server/uploads/b2f36863-9274-4c31-9879-7bcd7541ac5c.jpg +0 -0
- server/uploads/b539c7b1-e419-457b-9873-72d417966488.jpg +0 -0
- server/uploads/c558d211-042a-4e1e-8e8d-d9e1cd41b1d6.jpg +0 -0
- server/uploads/c56cdcbf-e2ed-4be0-8d79-88965c7664ab.jpg +0 -0
- server/uploads/eb4ae4ff-89d3-44ea-a622-ff714b6780de.jpg +0 -0
- server/uploads/ed8399fd-041c-4594-b650-bb29ee5805aa.jpg +0 -0
- src/App.css +184 -0
- src/App.jsx +189 -0
- src/assets/hero.png +0 -0
- src/assets/react.svg +1 -0
- src/assets/vite.svg +1 -0
- src/components/AddListing.jsx +438 -0
- src/components/Auth.jsx +554 -0
- 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 |
+
}
|