+
+
{
+ const val = e.target.value;
+ setState(val);
+ setFilteredStates(INDIAN_STATES.filter(s => s.toLowerCase().includes(val.toLowerCase())));
+ setShowStateList(true);
+ setDistrict(''); // Reset district on state change
+ }}
+ onFocus={() => setShowStateList(true)}
+ disabled={isSubmitting}
+ required
+ />
+
+ {showStateList && filteredStates.length > 0 && (
+
+ {filteredStates.map(s => (
+ {
+ setState(s);
+ setShowStateList(false);
+ setError('');
+ setDistrict('');
+ setFilteredDistricts(INDIA_DATA[s] || []);
+ }}
+ style={{
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
+ >
+ {s}
+ {state === s && }
+
+ ))}
+
+ )}
+
+ {showStateList &&
setShowStateList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
+
+
+
+
+
{
+ const val = e.target.value;
+ setDistrict(val);
+ const ds = INDIA_DATA[state] || [];
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(val.toLowerCase())));
+ setShowDistrictList(true);
+ }}
+ onFocus={() => {
+ if (state) {
+ const ds = INDIA_DATA[state] || [];
+ setFilteredDistricts(ds.filter(d => d.toLowerCase().includes(district.toLowerCase())));
+ setShowDistrictList(true);
+ }
+ }}
+ disabled={isSubmitting || !state}
+ required
+ />
+
+ {showDistrictList && filteredDistricts.length > 0 && (
+
+ {filteredDistricts.map(d => (
+ {
+ setDistrict(d);
+ setShowDistrictList(false);
+ setError('');
+ }}
+ style={{
+ padding: '12px 16px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)',
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between'
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--primary-glow)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
+ >
+ {d}
+ {district === d && }
+
+ ))}
+
+ )}
+
+ {showDistrictList &&
setShowDistrictList(false)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 105 }} />}
+
+
+
+
+
+
+
+
+
+
+
+
+ {location ? 'Precise Location Captured' : 'Get Primary Location (GPS)'}
+
+ {location && {location.address}}
+
+
+ {locating &&
}
+
+
+
+
+
+
+
!isSubmitting && !hasBuyer && setRole('buyer')}
+ style={{
+ flex: 1, padding: '16px', borderRadius: '16px',
+ border: `2px solid ${role === 'buyer' ? 'var(--primary-color)' : 'var(--border-color)'}`,
+ background: role === 'buyer' ? 'var(--primary-glow)' : 'transparent',
+ cursor: isSubmitting || hasBuyer ? 'not-allowed' : 'pointer', textAlign: 'center', transition: 'all 0.2s',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
+ opacity: isSubmitting || hasBuyer ? 0.5 : 1
+ }}
+ >
+
+ {hasBuyer ? 'Buyer (Owned)' : 'Buyer'}
+
+
+
!isSubmitting && !hasSeller && setRole('seller')}
+ style={{
+ flex: 1, padding: '16px', borderRadius: '16px',
+ border: `2px solid ${role === 'seller' ? 'var(--primary-color)' : 'var(--border-color)'}`,
+ background: role === 'seller' ? 'var(--primary-glow)' : 'transparent',
+ cursor: isSubmitting || hasSeller ? 'not-allowed' : 'pointer', textAlign: 'center', transition: 'all 0.2s',
+ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
+ opacity: isSubmitting || hasSeller ? 0.5 : 1
+ }}
+ >
+
+ {hasSeller ? 'Seller (Owned)' : 'Seller'}
+
+
+
+
+
+
+ ) : (
+
+
Select Profile
+
+ {deviceProfiles.map(profile => (
+
handleProfileSelect(profile)}
+ className="glass-panel"
+ style={{
+ padding: '20px', display: 'flex', alignItems: 'center', gap: '16px',
+ cursor: 'pointer', transition: 'transform 0.2s, box-shadow 0.2s'
+ }}
+ onMouseOver={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
+ onMouseOut={(e) => e.currentTarget.style.transform = 'translateY(0)'}
+ >
+
+

+
+
+
{profile.name}
+
+ {profile.role === 'buyer' ? : }
+ {profile.role}
+
+
+
+ ))}
+
+
+ {deviceProfiles.length < 2 && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/BuyerDashboard.jsx b/src/components/BuyerDashboard.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c97ab7cbd8935a666d5a8be95de414eb1ab2c1f6
--- /dev/null
+++ b/src/components/BuyerDashboard.jsx
@@ -0,0 +1,149 @@
+import { useStore } from '../store';
+import { useNavigate } from 'react-router-dom';
+import { Search, MapPin, Grid, List as ListIcon, Loader2, MessageSquare } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useState, useEffect } from 'react';
+
+export function BuyerDashboard() {
+ const { listings, fetchListings, isLoading, toggleInbox, unreadCount } = useStore();
+ const navigate = useNavigate();
+ const [viewMode, setViewMode] = useState('list');
+ const [searchTerm, setSearchTerm] = useState('');
+
+ useEffect(() => {
+ fetchListings();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const filteredListings = listings.filter(l =>
+ l.status !== 'sold' && (
+ l.cropName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ l.sellerName.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ );
+
+ return (
+
+
+
+
Explore Crops
+
Find fresh produce directly from farmers.
+
+
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ style={{ paddingLeft: '48px', borderRadius: '14px', background: 'var(--surface-light)' }}
+ />
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {filteredListings.length === 0 ? (
+
+ No crops found matching your search.
+
+ ) : (
+
+ {filteredListings.map(listing => (
+ navigate(`/buyer/listing/${listing.id}`)}
+ whileHover={{ y: -4, scale: 1.02 }}
+ style={{
+ overflow: 'hidden', cursor: 'pointer',
+ display: 'flex',
+ flexDirection: viewMode === 'list' ? 'row' : 'column',
+ alignItems: viewMode === 'list' ? 'center' : 'stretch'
+ }}
+ >
+
+

+
+
+
+ {new Date(listing.timestamp).toLocaleDateString()}
+
+
+ {listing.cropName} {listing.quantity && ({listing.quantity})}
+
+
+ {listing.price || 'Contact for price'}
+
+
+
+
+
+
+ {listing.nearestCity ? `${listing.nearestCity}, ${listing.district}, ${listing.state}` : listing.location.address}
+
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/CallOverlay.jsx b/src/components/CallOverlay.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8777d260ee40b6ce3f2f731e53c98e6ea0917882
--- /dev/null
+++ b/src/components/CallOverlay.jsx
@@ -0,0 +1,454 @@
+import { useStore } from '../store';
+import { Phone, PhoneOff, Mic, MicOff, Video, VideoOff } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useState, useEffect, useRef } from 'react';
+import { socket } from '../socket';
+
+export function CallOverlay() {
+ const { activeCall, endCall, currentUser, incrementMissedCalls } = useStore();
+
+ const [micMuted, setMicMuted] = useState(false);
+ const [videoMuted, setVideoMuted] = useState(false);
+
+ const [stream, setStream] = useState(null);
+ const [callAccepted, setCallAccepted] = useState(false);
+ const [videoActive, setVideoActive] = useState(false);
+ const [incomingVideoRequest, setIncomingVideoRequest] = useState(false);
+ const [isRequestingVideo, setIsRequestingVideo] = useState(false);
+
+
+ const localVideoRef = useRef(null);
+ const remoteVideoRef = useRef(null);
+ const connectionRef = useRef(null);
+
+ const leaveCall = () => {
+ if (connectionRef.current) connectionRef.current.close();
+
+ // Stop local media defensively via both state and active ref source
+ if (stream) {
+ stream.getTracks().forEach(track => track.stop());
+ }
+ if (localVideoRef.current && localVideoRef.current.srcObject) {
+ localVideoRef.current.srcObject.getTracks().forEach(track => track.stop());
+ localVideoRef.current.srcObject = null;
+ }
+
+ // Notify peer
+ if (activeCall) {
+ const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
+ socket.emit('end-call', { to: peerId });
+ }
+
+ setStream(null);
+ setCallAccepted(false);
+ endCall();
+ };
+
+ // We need to listen to incoming calls globally even if not in an active call state
+ useEffect(() => {
+ socket.on('incoming-call', (data) => {
+ // Setup incoming state in store manually by injecting it
+ useStore.setState({
+ activeCall: {
+ callerId: data.from,
+ callerName: data.callerName,
+ callerSelfie: data.callerSelfie,
+ receiverId: currentUser?.id,
+ receiverName: currentUser?.name,
+ receiverSelfie: currentUser?.selfiePath,
+ status: 'ringing',
+ signalData: data.signal // Stashed for answering
+ }
+ });
+
+ if ("Notification" in window && Notification.permission === "granted") {
+ new Notification("Incoming Call \u260E\uFE0F", {
+ body: `${data.callerName} is calling you on Meri Mandi!`,
+ icon: data.callerSelfie || '/upiqr.jpeg'
+ });
+ }
+ });
+
+ socket.on('call-ended', () => {
+ leaveCall();
+ });
+
+ socket.on('missed-call', () => {
+ incrementMissedCalls();
+ leaveCall();
+ });
+
+ socket.on('video-requested', () => {
+ setIncomingVideoRequest(true);
+ });
+
+ socket.on('video-accepted', () => {
+ setIsRequestingVideo(false);
+ enableVideoTrack();
+ });
+
+ socket.on('video-rejected', () => {
+ setIsRequestingVideo(false);
+ alert("Video call request was declined.");
+ });
+
+ return () => {
+ socket.off('incoming-call');
+ socket.off('call-ended');
+ socket.off('missed-call');
+ socket.off('video-requested');
+ socket.off('video-accepted');
+ socket.off('video-rejected');
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentUser]);
+
+ // Timeout logic (60 seconds)
+ useEffect(() => {
+ let timeoutId;
+ if (activeCall && activeCall.status === 'ringing') {
+ timeoutId = setTimeout(() => {
+ const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
+ if (activeCall.receiverId === currentUser?.id) {
+ // I didn't answer - increment my missed calls
+ incrementMissedCalls();
+ }
+ // Notify other party
+ socket.emit('missed-call', { to: peerId });
+ leaveCall();
+ }, 60000);
+ }
+ return () => clearTimeout(timeoutId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeCall]);
+
+ // When activeCall becomes truthy and we are the CALLER (initiating), setup streams.
+ useEffect(() => {
+ if (activeCall && !stream && !callAccepted && activeCall.callerId === currentUser?.id) {
+ navigator.mediaDevices.getUserMedia({ video: false, audio: true }).then((currentStream) => {
+ setStream(currentStream);
+ if (localVideoRef.current) localVideoRef.current.srcObject = currentStream;
+
+ // Setup Peer Connection
+ const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
+
+ currentStream.getTracks().forEach(track => peer.addTrack(track, currentStream));
+
+ peer.ontrack = (event) => {
+ if (remoteVideoRef.current) {
+ remoteVideoRef.current.srcObject = event.streams[0];
+ }
+ };
+
+ peer.onicecandidate = (event) => {
+ if (event.candidate) {
+ // ICE handling omitted for simplicity in this exact demo, or bundled in offer
+ }
+ };
+
+ peer.createOffer().then(offer => {
+ peer.setLocalDescription(offer);
+ socket.emit('call-user', {
+ userToCall: activeCall.receiverId,
+ signalData: offer,
+ from: currentUser.id,
+ callerName: currentUser.name,
+ callerSelfie: currentUser.selfiePath
+ });
+ });
+
+ socket.on('call-accepted', (signal) => {
+ setCallAccepted(true);
+ peer.setRemoteDescription(new RTCSessionDescription(signal));
+ });
+
+ connectionRef.current = peer;
+ }).catch(err => {
+ console.error("Failed to get media", err);
+ alert("Camera and Mic permissions are required for calling");
+ endCall();
+ });
+ }
+
+ // Cleanup when overlay completely unmounts
+ return () => {
+ if (!activeCall) {
+ socket.off('call-accepted');
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeCall, stream, currentUser]);
+
+
+ const answerCall = () => {
+ if (!activeCall || !activeCall.signalData) return;
+
+ setCallAccepted(true);
+ useStore.setState({ activeCall: { ...activeCall, status: 'connected' } });
+
+ navigator.mediaDevices.getUserMedia({ video: false, audio: true }).then((currentStream) => {
+ setStream(currentStream);
+ if (localVideoRef.current) localVideoRef.current.srcObject = currentStream;
+
+ const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
+
+ currentStream.getTracks().forEach(track => peer.addTrack(track, currentStream));
+
+ peer.ontrack = (event) => {
+ if (remoteVideoRef.current) {
+ remoteVideoRef.current.srcObject = event.streams[0];
+ }
+ };
+
+ peer.setRemoteDescription(new RTCSessionDescription(activeCall.signalData)).then(() => {
+ peer.createAnswer().then(answer => {
+ peer.setLocalDescription(answer);
+ socket.emit('answer-call', { signal: answer, to: activeCall.callerId });
+ });
+ });
+
+ connectionRef.current = peer;
+ });
+ };
+
+ const toggleMic = () => {
+ if (stream) {
+ stream.getAudioTracks()[0].enabled = micMuted;
+ setMicMuted(!micMuted);
+ }
+ };
+
+ const requestVideo = () => {
+ const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
+ setIsRequestingVideo(true);
+ socket.emit('request-video', { to: peerId, from: currentUser.id });
+ };
+
+ const acceptVideo = () => {
+ const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
+ setIncomingVideoRequest(false);
+ socket.emit('accept-video', { to: peerId });
+ enableVideoTrack();
+ };
+
+ const rejectVideo = () => {
+ const peerId = activeCall.callerId === currentUser?.id ? activeCall.receiverId : activeCall.callerId;
+ setIncomingVideoRequest(false);
+ socket.emit('reject-video', { to: peerId });
+ };
+
+ const enableVideoTrack = async () => {
+ try {
+ const videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
+ const videoTrack = videoStream.getVideoTracks()[0];
+
+ if (stream) {
+ stream.addTrack(videoTrack);
+ if (localVideoRef.current) localVideoRef.current.srcObject = stream;
+ }
+
+ if (connectionRef.current) {
+ const senders = connectionRef.current.getSenders();
+ const videoSender = senders.find(s => s.track && s.track.kind === 'video');
+ if (videoSender) {
+ videoSender.replaceTrack(videoTrack);
+ } else {
+ connectionRef.current.addTrack(videoTrack, stream);
+ }
+ }
+ setVideoActive(true);
+ setVideoMuted(false);
+ } catch (err) {
+ console.error("Failed to start video", err);
+ }
+ };
+
+ const toggleVideo = () => {
+ if (!videoActive) {
+ requestVideo();
+ return;
+ }
+ if (stream && stream.getVideoTracks().length > 0) {
+ const track = stream.getVideoTracks()[0];
+ track.enabled = videoMuted;
+ setVideoMuted(!videoMuted);
+ }
+ };
+
+ if (!activeCall) return null;
+
+ const isRinging = activeCall.status === 'ringing';
+ const isIncoming = activeCall.receiverId === currentUser?.id && isRinging;
+ const isOutgoing = activeCall.callerId === currentUser?.id;
+ const peerSelfie = isOutgoing ? activeCall.receiverSelfie : activeCall.callerSelfie;
+
+ return (
+
+
+ {/* Remote Video / Avatar Container */}
+
+
+
+ {/* Live Native Video */}
+
+
+ {/* Fallback Static Avatar */}
+ {!videoActive && (peerSelfie ? (
+

+ ) : (
+ isOutgoing ? activeCall.receiverName.charAt(0) : activeCall.callerName.charAt(0)
+ ))}
+
+ {/* Picture-in-Picture Local Viewer */}
+ {videoActive && (
+
+
+
+ )}
+
+
+
+ {incomingVideoRequest && (
+
+ Requesting Video Call...
+
+
+
+
+
+ )}
+
+ {isRequestingVideo && (
+
+ Waiting for video call acceptance...
+
+ )}
+
+
+
+ {isOutgoing ? activeCall.receiverName : activeCall.callerName}
+
+
+ {isRinging ? (isIncoming ? 'Incoming Call...' : 'Ringing...') : 'Connected Securely'}
+
+
+
+ {/* Action Controls */}
+
+ {(!isRinging || callAccepted) && (
+ <>
+
+
+
+ >
+ )}
+
+ {isIncoming && isRinging && (
+
+ )}
+
+ {isOutgoing && isRinging && !callAccepted && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/CameraCapture.jsx b/src/components/CameraCapture.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c9f79f30c71c947c3757ec2c5d94a516f3572b33
--- /dev/null
+++ b/src/components/CameraCapture.jsx
@@ -0,0 +1,252 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { Camera, X, RefreshCw, Loader2 } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+export function CameraCapture({ onCapture, onClose, facingMode = 'user' }) {
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const streamRef = useRef(null);
+ const [error, setError] = useState('');
+ const [currentFacingMode, setCurrentFacingMode] = useState(facingMode);
+ const [capturing, setCapturing] = useState(false);
+ const [videoReady, setVideoReady] = useState(false);
+
+ // Attach stream to video element reliably (handles Android timing issues)
+ const attachStream = useCallback((mediaStream) => {
+ const video = videoRef.current;
+ if (!video || !mediaStream) return;
+
+ video.srcObject = mediaStream;
+
+ // Listen for metadata to confirm dimensions are available
+ const onLoadedMetadata = () => {
+ setVideoReady(true);
+ video.removeEventListener('loadedmetadata', onLoadedMetadata);
+ };
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
+
+ // Explicitly call play() — required on Android Chrome even with autoPlay + muted
+ video.play().catch((err) => {
+ // AbortError is harmless (happens if component re-renders quickly)
+ if (err.name !== 'AbortError') {
+ console.error('Video play() failed:', err);
+ }
+ });
+ }, []);
+
+ const stopCamera = useCallback(() => {
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach(track => track.stop());
+ streamRef.current = null;
+ }
+ if (videoRef.current && videoRef.current.srcObject) {
+ videoRef.current.srcObject.getTracks().forEach(track => track.stop());
+ videoRef.current.srcObject = null;
+ }
+ setVideoReady(false);
+ }, []);
+
+ const startCamera = useCallback(async () => {
+ stopCamera();
+ try {
+ const mediaStream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ facingMode: currentFacingMode,
+ // Suggest reasonable resolution for mobile devices
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ },
+ audio: false,
+ });
+ streamRef.current = mediaStream;
+ attachStream(mediaStream);
+ setError('');
+ } catch (err) {
+ console.error("Camera error:", err);
+ if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
+ setError('Camera permission denied. Please allow camera access in your browser settings and reload.');
+ } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
+ setError('No camera found on this device.');
+ } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
+ setError('Camera is in use by another app. Please close other apps using the camera and try again.');
+ } else if (err.name === 'OverconstrainedError') {
+ // Fallback: retry without facingMode constraint (some devices don't support it)
+ try {
+ const fallbackStream = await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: false,
+ });
+ streamRef.current = fallbackStream;
+ attachStream(fallbackStream);
+ setError('');
+ } catch (fallbackErr) {
+ setError('Camera access failed. Please ensure camera permissions are enabled.');
+ }
+ } else {
+ setError('Camera access denied or unavailable. Please enable permissions.');
+ }
+ }
+ }, [currentFacingMode, stopCamera, attachStream]);
+
+ useEffect(() => {
+ startCamera();
+ return () => stopCamera();
+ }, [startCamera, stopCamera]);
+
+ const toggleCamera = () => {
+ setCurrentFacingMode(prev => prev === 'user' ? 'environment' : 'user');
+ };
+
+ const handleSnap = async () => {
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ if (!video || !canvas) {
+ console.warn("Video or canvas ref not available");
+ return;
+ }
+
+ // Wait briefly for dimensions if not immediately ready
+ let width = video.videoWidth;
+ let height = video.videoHeight;
+ if (!width || !height) {
+ // Give Android a moment to report dimensions
+ await new Promise(resolve => setTimeout(resolve, 200));
+ width = video.videoWidth;
+ height = video.videoHeight;
+ }
+
+ if (!width || !height) {
+ console.warn("Video dimensions not available yet");
+ setError('Camera is still initializing. Please wait a moment and try again.');
+ return;
+ }
+
+ setCapturing(true);
+
+ // Set canvas dimensions to match actual video feed exactly
+ canvas.width = width;
+ canvas.height = height;
+
+ const context = canvas.getContext('2d');
+
+ // Mirror the canvas for selfies to feel natural
+ if (currentFacingMode === 'user') {
+ context.translate(canvas.width, 0);
+ context.scale(-1, 1);
+ }
+
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+ // Reset transform
+ context.setTransform(1, 0, 0, 1, 0, 0);
+
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
+
+ // Release camera immediately after capturing the pixels
+ stopCamera();
+
+ // Convert base64 to File object for the backend Multer pipeline
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ setCapturing(false);
+ setError('Failed to capture image. Please try again.');
+ return;
+ }
+ const file = new File([blob], `live_snap_${Date.now()}.jpg`, { type: 'image/jpeg' });
+ onCapture(file, dataUrl);
+ setCapturing(false);
+ }, 'image/jpeg', 0.9);
+ };
+
+ const handleClose = () => {
+ stopCamera();
+ onClose();
+ };
+
+ return (
+
+
+ {/* Header Bar */}
+
+
+ Live Capture
+
+
+
+ {/* Video Viewer */}
+
+ {error ? (
+
+
{error}
+
+
+ ) : (
+ <>
+
+ {/* Hidden canvas used solely for extraction */}
+
+ >
+ )}
+
+
+ {/* Action Bottom Bar */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ChatOverlay.jsx b/src/components/ChatOverlay.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ca2c4fa73157f5969f8e1205db65a6f007adcbb7
--- /dev/null
+++ b/src/components/ChatOverlay.jsx
@@ -0,0 +1,172 @@
+import { useState, useEffect, useRef } from 'react';
+import { useStore } from '../store';
+import { socket } from '../socket';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X, Send, User } from 'lucide-react';
+import { v4 as uuidv4 } from 'uuid';
+
+export function ChatOverlay() {
+ const { activeChatUser, closeChat, currentUser } = useStore();
+ const [messages, setMessages] = useState([]);
+ const [inputText, setInputText] = useState('');
+ const messagesEndRef = useRef(null);
+
+ useEffect(() => {
+ if (!activeChatUser || !currentUser) return;
+
+ // Fetch History
+ fetch(`/api/messages/history/${currentUser.id}/${activeChatUser.id}`)
+ .then(res => res.json())
+ .then(data => {
+ setMessages(data);
+ return fetch(`/api/messages/read/${activeChatUser.id}/${currentUser.id}`, { method: 'PATCH' });
+ })
+ .then(() => useStore.getState().fetchUnreadCount())
+ .catch(console.error);
+
+ const handleReceiveMessage = (msg) => {
+ // Only append if it belongs to this conversation
+ if (msg.senderId === activeChatUser.id || msg.receiverId === activeChatUser.id) {
+ setMessages(prev => {
+ if (prev.some(m => m.id === msg.id)) return prev;
+ return [...prev, msg];
+ });
+ }
+ };
+
+ socket.on('receive-message', handleReceiveMessage);
+
+ return () => {
+ socket.off('receive-message', handleReceiveMessage);
+ };
+ }, [activeChatUser, currentUser]);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ const sendMessage = async (e) => {
+ e.preventDefault();
+ if (!inputText.trim() || !activeChatUser) return;
+
+ const newMsg = {
+ id: uuidv4(),
+ senderId: currentUser.id,
+ receiverId: activeChatUser.id,
+ message: inputText.trim(),
+ timestamp: Date.now()
+ };
+
+ // Optimistically update UI
+ setMessages(prev => [...prev, newMsg]);
+ setInputText('');
+
+ // Emit via socket purely for live relay
+ socket.emit('send-message', newMsg);
+
+ // Save strictly to database
+ try {
+ await fetch(`/api/messages`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-socket-id': socket.id },
+ body: JSON.stringify(newMsg)
+ });
+ } catch(err) { console.error("Failed to send message", err); }
+ };
+
+ if (!activeChatUser) return null;
+
+ return (
+
+
+
e.stopPropagation()} // Prevent closing when clicking inside
+ >
+ {/* Chat Header */}
+
+
+
+
+ {activeChatUser.selfiePath ? (
+

+ ) : (
+
+ )}
+
+
+
{activeChatUser.name}
+
Active Now
+
+
+
+ {/* Chat Transcript Log */}
+
+ {messages.length === 0 ? (
+
+ Start your conversation!
Messages are end-to-end direct.
+
+ ) : (
+ messages.map(msg => {
+ const isMe = msg.senderId === currentUser?.id;
+ return (
+
+
+ {msg.message}
+
+
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+ );
+ })
+ )}
+
+
+
+ {/* Chat Input Dock */}
+
+
+
+
+ );
+}
diff --git a/src/components/HelpOverlay.jsx b/src/components/HelpOverlay.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..443e3b79a4f1a486fa5db6999c49522c1e05b1d4
--- /dev/null
+++ b/src/components/HelpOverlay.jsx
@@ -0,0 +1,198 @@
+import { useState, useEffect } from 'react';
+import { MessageSquare, X, Send } from 'lucide-react';
+import { useStore } from '../store';
+import { motion, AnimatePresence } from 'framer-motion';
+
+export function HelpOverlay() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [message, setMessage] = useState('');
+ const [history, setHistory] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const { currentUser, deviceId } = useStore();
+
+ const fetchUnreadCount = async () => {
+ const id = currentUser ? currentUser.id.split('_')[0] : deviceId;
+ if (!id) return;
+ try {
+ const res = await fetch(`/api/support/unread/${id}`);
+ if (res.ok) setUnreadCount((await res.json()).count);
+ } catch {}
+ };
+
+ const markAsRead = async () => {
+ const id = currentUser ? currentUser.id.split('_')[0] : deviceId;
+ if (!id) return;
+ try {
+ await fetch(`/api/support/read/${id}`, { method: 'PATCH' });
+ setUnreadCount(0);
+ } catch {}
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ if (currentUser || deviceId) {
+ fetchHistory();
+ markAsRead();
+ }
+ } else if (currentUser || deviceId) {
+ fetchUnreadCount();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, currentUser, deviceId]);
+
+ useEffect(() => {
+ if (!currentUser && !deviceId) return;
+ import('../socket').then(({ socket }) => {
+ const handler = () => {
+ if (isOpen) {
+ fetchHistory();
+ markAsRead();
+ } else {
+ fetchUnreadCount();
+ }
+ };
+ socket.on('support-ticket-updated', handler);
+ return () => socket.off('support-ticket-updated', handler);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, currentUser, deviceId]);
+
+ const fetchHistory = async () => {
+ try {
+ const id = currentUser ? currentUser.id.split('_')[0] : deviceId;
+ if (!id) return;
+ const res = await fetch(`/api/support/history/${id}`);
+ if (res.ok) {
+ const data = await res.json();
+ setHistory(data);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const senderId = currentUser ? currentUser.id : deviceId;
+ if (!message.trim() || !senderId) return;
+
+ try {
+ const res = await fetch(`/api/support`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ senderId, message })
+ });
+ if (res.ok) {
+ setMessage('');
+ fetchHistory();
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+
+ return (
+ <>
+
+
+
+ {isOpen && (
+
+
+
Support
+
+
+
+
+ {history.length === 0 ? (
+
No previous tickets.
How can we help you today?
+ ) : (
+ history.map(msg => (
+
+
{msg.message}
+ {msg.adminReply && (
+
+ Admin: {msg.adminReply}
+
+ )}
+
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+ {msg.isResolved ? 'Resolved' : 'Pending'}
+
+
+ ))
+ )}
+
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/components/InboxOverlay.jsx b/src/components/InboxOverlay.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7a1a217ce76aefae1066defda3a3d64273d62f06
--- /dev/null
+++ b/src/components/InboxOverlay.jsx
@@ -0,0 +1,117 @@
+import { useState, useEffect } from 'react';
+import { useStore } from '../store';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X, MessageSquare, User } from 'lucide-react';
+
+export function InboxOverlay() {
+ const { currentUser, showInbox, toggleInbox, startChat } = useStore();
+ const [inbox, setInbox] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!showInbox || !currentUser) return;
+
+ const fetchInbox = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/messages/inbox/${currentUser.id}`);
+ const data = await res.json();
+ setInbox(data || []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchInbox();
+ }, [showInbox, currentUser]);
+
+ if (!showInbox) return null;
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
Your Messages
+
+
+
+
+ {/* List */}
+
+ {loading ? (
+
Loading messages...
+ ) : inbox.length === 0 ? (
+
+
+
No messages yet.
+
Conversations with buyers or sellers will appear here.
+
+ ) : (
+
+ {inbox.map(chat => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/ListingDetails.jsx b/src/components/ListingDetails.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c80c4664f12e10cb937cc50f107007f3fa6da147
--- /dev/null
+++ b/src/components/ListingDetails.jsx
@@ -0,0 +1,128 @@
+import { useStore } from '../store';
+import { useParams, useNavigate } from 'react-router-dom';
+import { Phone, MessageSquare, MapPin, ChevronLeft, Calendar } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { useEffect } from 'react';
+
+export function ListingDetails() {
+ const { id } = useParams();
+ const { listings, fetchListings, isLoading, startCall, currentUser, startChat } = useStore();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (listings.length === 0) {
+ fetchListings();
+ }
+ }, [listings.length, fetchListings]);
+
+ const listing = listings.find(l => l.id === id);
+
+ useEffect(() => {
+ if (listing?.status === 'sold' && currentUser?.role === 'buyer') {
+ navigate('/buyer'); // Kicks them out if someone bought it while they were looking
+ }
+ }, [listing, navigate, currentUser]);
+
+ if (isLoading) return
Loading...
;
+ if (!listing) return
Listing not found.
;
+
+ return (
+
+ {/* Back Button */}
+
+
+ {/* Image Carousel (Simplified) */}
+
+ {listing.images.map((img, idx) => (
+
+

+
+ ))}
+
+
+
+
+ {listing.cropName} {listing.quantity && ({listing.quantity})}
+
+
+ {listing.price || 'Contact for price'}
+
+
+
+
+
+
+
+
+
Location
+
+ {listing.nearestCity ? `${listing.nearestCity}, ${listing.district}, ${listing.state}` : listing.location.address}
+
+
+
+
+
+
+
+
+
+
Listed On
+
{new Date(listing.timestamp).toLocaleDateString()}
+
+
+
+
+
+
+
Seller Info
+
+
+
+ {listing.sellerSelfie ? (
+

+ ) : (
+
{listing.sellerName.charAt(0)}
+ )}
+
+
+
{listing.sellerName}
+
Meri Mandi Seller
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8a84ebbc04e600435dd7280291cb07e8b8a53149
--- /dev/null
+++ b/src/components/ProtectedRoute.jsx
@@ -0,0 +1,18 @@
+import { Navigate } from 'react-router-dom';
+import { useStore } from '../store';
+
+export function ProtectedRoute({ children, requiredRole }) {
+ const { currentUser } = useStore();
+
+ if (!currentUser) {
+ // Redirect to the registration/auth page if not logged in
+ return
;
+ }
+
+ if (requiredRole && currentUser.role !== requiredRole) {
+ // If they have the wrong role, redirect back to their appropriate dashboard
+ return
;
+ }
+
+ return children;
+}
diff --git a/src/components/SellerDashboard.jsx b/src/components/SellerDashboard.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..1b37e18cfe533ecf65260ec33874a98cbbabed67
--- /dev/null
+++ b/src/components/SellerDashboard.jsx
@@ -0,0 +1,232 @@
+import { useStore } from '../store';
+import { useNavigate } from 'react-router-dom';
+import { PlusCircle, MapPin, Search, Loader2, MessageSquare } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { useEffect, useState } from 'react';
+
+export function SellerDashboard() {
+ const { listings, fetchListings, currentUser, isLoading, toggleInbox, unreadCount, missedCalls, resetMissedCalls } = useStore();
+ const navigate = useNavigate();
+
+ const handleMessageClick = () => {
+ resetMissedCalls();
+ toggleInbox();
+ };
+
+ useEffect(() => {
+ fetchListings();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const [editingListing, setEditingListing] = useState(null);
+ const [editForm, setEditForm] = useState({ cropName: '', quantity: '', price: '' });
+
+ const myListings = listings.filter(l => l.sellerId === currentUser?.id && l.status !== 'sold');
+
+ const handleMarkSold = async (e, id) => {
+ e.stopPropagation();
+ try {
+ await fetch(`/api/listings/${id}/sold`, { method: 'PATCH' });
+ } catch (err) {
+ console.error("Failed to mark sold", err);
+ }
+ };
+
+ const startEditing = (e, listing) => {
+ e.stopPropagation();
+ setEditingListing(listing);
+ setEditForm({ cropName: listing.cropName, quantity: listing.quantity || '', price: listing.price || '' });
+ };
+
+ const handleEditSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ await fetch(`/api/listings/${editingListing.id}/edit`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(editForm)
+ });
+ setEditingListing(null);
+ } catch (err) {
+ console.error("Failed to edit", err);
+ }
+ };
+
+ return (
+
+
+
+
My Listings
+
Manage your crops and produce.
+
+
+
+
+ {missedCalls > 0 && (
+
+ {missedCalls}
+
+ )}
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : myListings.length === 0 ? (
+
+
+
+
+
No Listings Yet
+
List your first crop to connect with buyers.
+
+ ) : (
+
+ {myListings.map(listing => {
+ const isSold = listing.status === 'sold';
+ return (
+
+
+

+ {isSold && (
+
+ SOLD
+
+ )}
+
+
+
+ {listing.cropName} {listing.quantity && ({listing.quantity})}
+
+
+ {listing.price || 'Contact for price'}
+
+
+
+
+ {listing.nearestCity ? `${listing.nearestCity}, ${listing.district}, ${listing.state}` : 'Location based'}
+
+
+
+ {!isSold && (
+
+
+
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ {editingListing && (
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/AdminDashboard.jsx b/src/components/admin/AdminDashboard.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..8951885e8031c3c76ee2ac34faa094ca805dbc77
--- /dev/null
+++ b/src/components/admin/AdminDashboard.jsx
@@ -0,0 +1,393 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { LogOut, Users, ShoppingBag, Trash2, Ban, UserCheck, RefreshCw, LifeBuoy } from 'lucide-react';
+
+export function AdminDashboard() {
+ const [activeTab, setActiveTab] = useState('users');
+ const [users, setUsers] = useState([]);
+ const [listings, setListings] = useState([]);
+ const [supportTickets, setSupportTickets] = useState([]);
+ const [stats, setStats] = useState({ pendingSupport: 0 });
+ const [loading, setLoading] = useState(true);
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [replyText, setReplyText] = useState('');
+ const navigate = useNavigate();
+
+ const token = sessionStorage.getItem('adminToken');
+
+ useEffect(() => {
+ if (!token) {
+ navigate('/admin');
+ return;
+ }
+ fetchData();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab, token, navigate]);
+
+ useEffect(() => {
+ if (!token) return;
+ import('../../socket').then(({ socket }) => {
+ const handler = () => {
+ if (activeTab === 'support') {
+ fetchData(); // This also fetches stats
+ } else {
+ fetch(`/api/admin/stats`, {
+ headers: { 'x-admin-key': token }
+ }).then(r => r.json()).then(data => setStats(data)).catch(()=>{});
+ }
+ };
+ socket.on('support-ticket-updated', handler);
+ return () => socket.off('support-ticket-updated', handler);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab, token]);
+
+ const fetchData = async () => {
+ setLoading(true);
+ try {
+ const statsRes = await fetch(`/api/admin/stats`, {
+ headers: { 'x-admin-key': token }
+ });
+ if (statsRes.status === 403) {
+ sessionStorage.removeItem('adminToken');
+ navigate('/admin');
+ return;
+ }
+ if (statsRes.ok) {
+ setStats(await statsRes.json());
+ }
+
+ let endpoint = '';
+ if (activeTab === 'users') endpoint = '/api/admin/users';
+ else if (activeTab === 'listings') endpoint = '/api/admin/listings';
+ else if (activeTab === 'support') endpoint = '/api/admin/support';
+
+ const res = await fetch(`${endpoint}`, {
+ headers: { 'x-admin-key': token }
+ });
+ if (res.status === 403) {
+ sessionStorage.removeItem('adminToken');
+ navigate('/admin');
+ return;
+ }
+ const data = await res.json();
+ if (activeTab === 'users') setUsers(data);
+ else if (activeTab === 'listings') setListings(data);
+ else if (activeTab === 'support') setSupportTickets(data);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBlockUser = async (id, currentStatus) => {
+ if (!window.confirm(`Are you sure you want to ${currentStatus ? 'unblock' : 'block'} this user?`)) return;
+ try {
+ const res = await fetch(`/api/admin/users/${id}/block`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'x-admin-key': token },
+ body: JSON.stringify({ isBlocked: !currentStatus })
+ });
+ if (res.ok) {
+ setUsers(users.map(u => u.id === id ? { ...u, isBlocked: currentStatus ? 0 : 1 } : u));
+ }
+ } catch (err) { console.error(err); }
+ };
+
+ const handleDeleteUser = async (id) => {
+ if (!window.confirm('CRITICAL WARING: Deleting a user will permanently wipe their profile and all active listings. Proceed?')) return;
+ try {
+ const res = await fetch(`/api/admin/users/${id}`, {
+ method: 'DELETE',
+ headers: { 'x-admin-key': token }
+ });
+ if (res.ok) setUsers(users.filter(u => u.id !== id));
+ } catch (err) { console.error(err); }
+ };
+
+ const handleDeleteListing = async (id) => {
+ if (!window.confirm('Delete this listing permanently?')) return;
+ try {
+ const res = await fetch(`/api/admin/listings/${id}`, {
+ method: 'DELETE',
+ headers: { 'x-admin-key': token }
+ });
+ if (res.ok) setListings(listings.filter(l => l.id !== id));
+ } catch (err) { console.error(err); }
+ };
+
+ const handleResolveTicket = async (id) => {
+ try {
+ const res = await fetch(`/api/admin/support/${id}/resolve`, {
+ method: 'PATCH',
+ headers: { 'x-admin-key': token }
+ });
+ if (res.ok) {
+ setSupportTickets(supportTickets.map(t => t.id === id ? { ...t, isResolved: 1 } : t));
+ setStats(prev => ({ ...prev, pendingSupport: Math.max(0, prev.pendingSupport - 1) }));
+ }
+ } catch (err) { console.error(err); }
+ };
+
+ const handleReplyTicket = async (id, reply) => {
+ if (!reply || !reply.trim()) return;
+ try {
+ const res = await fetch(`/api/admin/support/${id}/reply`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'x-admin-key': token },
+ body: JSON.stringify({ reply })
+ });
+ if (res.ok) {
+ setSupportTickets(supportTickets.map(t => t.id === id ? { ...t, adminReply: reply } : t));
+ setReplyingTo(null);
+ setReplyText('');
+ } else {
+ const errorData = await res.json();
+ alert(`Failed to send reply: ${errorData.error || 'Server error'}`);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('Network error: Could not connect to support server.');
+ }
+ };
+
+ if (!token) return null;
+
+ return (
+
+
+ {/* Sidebar */}
+
+
+
Meri Mandi Admin
+
+
+
+
+
+
+
+
+
+ {/* Main Content Area */}
+
+
+
+ {activeTab === 'users' ? 'User Management' : activeTab === 'listings' ? 'Listings Moderation' : 'Support Inbox'}
+
+
+
+
+ {loading ? (
+
Loading data...
+ ) : (
+
+
+ {activeTab === 'users' && (
+
+
+
+ | User |
+ Role |
+ Contact |
+ Status |
+ Actions |
+
+
+
+ {users.length === 0 ? | No users found. |
: users.map(user => (
+
+
+
+ {user.name}
+ |
+ {user.role} |
+ {user.contact || 'N/A'} |
+
+ {user.isBlocked ?
+ Blocked :
+ Active
+ }
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'listings' && (
+
+
+
+ | Crop Details |
+ Seller |
+ Location |
+ Price Info |
+ Actions |
+
+
+
+ {listings.length === 0 ? | No listings found. |
: listings.map(listing => (
+
+
+ {listing.images && listing.images.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ {listing.cropName}
+ Qty: {listing.quantity} • {listing.status === 'sold' ? Sold : Live}
+
+ |
+
+ {listing.sellerNameDisplay || listing.sellerName}
+ Phone: {listing.sellerContact || 'N/A'}
+ |
+
+ {(() => {
+ try { const loc = JSON.parse(listing.location); return `${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}`; } catch { return listing.location; }
+ })()}
+ |
+ {listing.price ? `₹${listing.price}` : 'Offer'} |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'support' && (
+
+ {supportTickets.length === 0 ?
No support tickets found.
: supportTickets.map(ticket => (
+
+
+ {ticket.selfiePath ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {ticket.name || 'Guest Farmer'} {ticket.role || 'Unregistered'}
+
+
+ Contact: {ticket.contact || 'N/A'} • {new Date(ticket.timestamp).toLocaleString()}
+
+
+ {ticket.message}
+ {ticket.adminReply && (
+
+ Admin Reply: {ticket.adminReply}
+
+ )}
+
+
+
+
+ {!ticket.isResolved && !ticket.adminReply && replyingTo !== ticket.id && (
+
+ )}
+ {!ticket.isResolved ? (
+
+ ) : (
+
Resolved ✓
+ )}
+
+
+ {replyingTo === ticket.id && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/admin/AdminLogin.jsx b/src/components/admin/AdminLogin.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a267051227c99365e057340ed042c809497f4b36
--- /dev/null
+++ b/src/components/admin/AdminLogin.jsx
@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { KeyRound, ShieldAlert } from 'lucide-react';
+
+export function AdminLogin() {
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const navigate = useNavigate();
+
+ const handleLogin = async (e) => {
+ e.preventDefault();
+ try {
+ // Encrypt password securely on client before sending
+ const msgUint8 = new TextEncoder().encode(password);
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+
+ const res = await fetch(`/api/admin/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ passwordHash: hashHex })
+ });
+ const data = await res.json();
+ if (res.ok) {
+ sessionStorage.setItem('adminToken', data.token);
+ navigate('/admin/dashboard');
+ } else {
+ setError(data.error || 'Login failed');
+ }
+ } catch {
+ setError('Server unreachable');
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/data/india-data.js b/src/data/india-data.js
new file mode 100644
index 0000000000000000000000000000000000000000..e061c9081cf2b93a827b8be28792712d53ad5733
--- /dev/null
+++ b/src/data/india-data.js
@@ -0,0 +1,38 @@
+export const INDIA_DATA = {
+ "Andaman and Nicobar Islands": ["Nicobar", "North and Middle Andaman", "South Andaman"],
+ "Andhra Pradesh": ["Anantapur", "Chittoor", "East Godavari", "Guntur", "Krishna", "Kurnool", "Prakasam", "Srikakulam", "Visakhapatnam", "Vizianagaram", "West Godavari", "YSR Kadapa", "Nellore"],
+ "Arunachal Pradesh": ["Anjaw", "Changlang", "Kamle", "Kra Daadi", "Kurung Kumey", "Lepa Rada", "Lohit", "Longding", "Lower Dibang Valley", "Lower Siang", "Lower Subansiri", "Namsai", "Pakke Kessang", "Papum Pare", "Shi Yomi", "Siang", "Tawang", "Tirap", "Upper Dibang Valley", "Upper Siang", "Upper Subansiri", "West Kameng", "West Siang"],
+ "Assam": ["Baksa", "Barpeta", "Biswanath", "Bongaigaon", "Cachar", "Charaideo", "Chirang", "Darrang", "Dhemaji", "Dhubri", "Dibrugarh", "Dima Hasao", "Goalpara", "Golaghat", "Hailakandi", "Hojai", "Jorhat", "Kamrup", "Kamrup Metropolitan", "Karbi Anglong", "Karimganj", "Kokrajhar", "Lakhimpur", "Majuli", "Morigaon", "Nagaon", "Nalbari", "Sivasagar", "Sonitpur", "South Salmara-Mankachar", "Tinsukia", "Udalguri", "West Karbi Anglong"],
+ "Bihar": ["Araria", "Arwal", "Aurangabad", "Banka", "Begusarai", "Bhagalpur", "Bhojpur", "Buxar", "Darbhanga", "East Champaran", "Gaya", "Gopalganj", "Jamui", "Jehanabad", "Kaimur", "Katihar", "Khagaria", "Kishanganj", "Lakhisarai", "Madhepura", "Madhubani", "Munger", "Muzaffarpur", "Nalanda", "Nawada", "Patna", "Purnia", "Rohtas", "Saharsa", "Samastipur", "Saran", "Sheikhpura", "Sheohar", "Sitamarhi", "Siwan", "Supaul", "Vaishali", "West Champaran"],
+ "Chandigarh": ["Chandigarh"],
+ "Chhattisgarh": ["Balod", "Baloda Bazar", "Balrampur", "Bastar", "Bemetara", "Bijapur", "Bilaspur", "Dantewada", "Dhamtari", "Durg", "Gariaband", "Gaurela-Pendra-Marwahi", "Janjgir-Champa", "Jashpur", "Kabirdham", "Kanker", "Kondagaon", "Korba", "Koriya", "Mahasamund", "Mungeli", "Narayanpur", "Raigarh", "Raipur", "Rajnandgaon", "Sukma", "Surajpur", "Surguja"],
+ "Dadra and Nagar Haveli and Daman and Diu": ["Dadra and Nagar Haveli", "Daman", "Diu"],
+ "Delhi": ["Central Delhi", "East Delhi", "New Delhi", "North Delhi", "North East Delhi", "North West Delhi", "Shahdara", "South Delhi", "South East Delhi", "South West Delhi", "West Delhi"],
+ "Goa": ["North Goa", "South Goa"],
+ "Gujarat": ["Ahmedabad", "Amreli", "Anand", "Aravalli", "Banaskantha", "Bharuch", "Bhavnagar", "Botad", "Chhota Udepur", "Dahod", "Dang", "Devbhumi Dwarka", "Gandhinagar", "Gir Somnath", "Jamnagar", "Junagadh", "Kheda", "Kutch", "Mahisagar", "Mehsana", "Morbi", "Narmada", "Navsari", "Panchmahal", "Patan", "Porbandar", "Rajkot", "Sabarkantha", "Surat", "Surendranagar", "Tapi", "Vadodara", "Valsad"],
+ "Haryana": ["Ambala", "Bhiwani", "Charkhi Dadri", "Faridabad", "Fatehabad", "Gurugram", "Hisar", "Jhajjar", "Jind", "Kaithal", "Karnal", "Kurukshetra", "Mahendragarh", "Nuh", "Palwal", "Panchkula", "Panipat", "Rewari", "Rohtak", "Sirsa", "Sonipat", "Yamunanagar"],
+ "Himachal Pradesh": ["Bilaspur", "Chamba", "Hamirpur", "Kangra", "Kinnaur", "Kullu", "Lahaul and Spiti", "Mandi", "Shimla", "Sirmaur", "Solan", "Una"],
+ "Jammu and Kashmir": ["Anantnag", "Bandipora", "Baramulla", "Budgam", "Doda", "Ganderbal", "Jammu", "Kathua", "Kishtwar", "Kulgam", "Kupwara", "Poonch", "Pulwama", "Rajouri", "Ramban", "Reasi", "Samba", "Shopian", "Srinagar", "Udhampur"],
+ "Jharkhand": ["Bokaro", "Chatra", "Deoghar", "Dhanbad", "Dumka", "East Singhbhum", "Garhwa", "Giridih", "Godda", "Gumla", "Hazaribagh", "Jamtara", "Khunti", "Koderma", "Latehar", "Lohardaga", "Pakur", "Palamu", "Ramgarh", "Ranchi", "Sahebganj", "Seraikela-Kharsawan", "Simdega", "West Singhbhum"],
+ "Karnataka": ["Bagalkot", "Ballari", "Belagavi", "Bengaluru Rural", "Bengaluru Urban", "Bidar", "Chamarajanagar", "Chikkaballapur", "Chikkamagaluru", "Chitradurga", "Dakshina Kannada", "Davanagere", "Dharwad", "Gadag", "Hassan", "Haveri", "Kalaburagi", "Kodagu", "Kolar", "Koppal", "Mandya", "Mysuru", "Raichur", "Ramanagara", "Shivamogga", "Tumakuru", "Udupi", "Uttara Kannada", "Vijayapura", "Yadgir"],
+ "Kerala": ["Alappuzha", "Ernakulam", "Idukki", "Kannur", "Kasaragod", "Kollam", "Kottayam", "Kozhikode", "Malappuram", "Palakkad", "Pathanamthitta", "Thiruvananthapuram", "Thrissur", "Wayanad"],
+ "Ladakh": ["Kargil", "Leh"],
+ "Lakshadweep": ["Lakshadweep"],
+ "Madhya Pradesh": ["Agar Malwa", "Alirajpur", "Anuppur", "Ashoknagar", "Balaghat", "Barwani", "Betul", "Bhind", "Bhopal", "Burhanpur", "Chhatarpur", "Chhindwara", "Damoh", "Datia", "Dewas", "Dhar", "Dindori", "Guna", "Gwalior", "Harda", "Hoshangabad", "Indore", "Jabalpur", "Jhabua", "Katni", "Khandwa", "Khargone", "Mandla", "Mandsaur", "Morena", "Narsinghpur", "Neemuch", "Niwari", "Panna", "Raisen", "Rajgarh", "Ratlam", "Rewa", "Sagar", "Satna", "Sehore", "Seoni", "Shahdol", "Shajapur", "Sheopur", "Shivpuri", "Sidhi", "Singrauli", "Tikamgarh", "Ujjain", "Umaria", "Vidisha"],
+ "Maharashtra": ["Ahmednagar", "Akola", "Amravati", "Aurangabad", "Beed", "Bhandara", "Buldhana", "Chandrapur", "Dhule", "Gadchiroli", "Gondia", "Hingoli", "Jalgaon", "Jalna", "Kolhapur", "Latur", "Mumbai City", "Mumbai Suburban", "Nagpur", "Nanded", "Nandurbar", "Nashik", "Osmanabad", "Palghar", "Parbhani", "Pune", "Raigad", "Ratnagiri", "Sangli", "Satara", "Sindhudurg", "Solapur", "Thane", "Wardha", "Washim", "Yavatmal"],
+ "Manipur": ["Bishnupur", "Chandel", "Churachandpur", "Imphal East", "Imphal West", "Jiribam", "Kakching", "Kamjong", "Kangpokpi", "Noney", "Pherzawl", "Senapati", "Tamenglong", "Tengnoupal", "Thoubal", "Ukhrul"],
+ "Meghalaya": ["East Garo Hills", "East Jaintia Hills", "East Khasi Hills", "North Garo Hills", "Ri Bhoi", "South Garo Hills", "South West Garo Hills", "South West Khasi Hills", "West Garo Hills", "West Jaintia Hills", "West Khasi Hills"],
+ "Mizoram": ["Aizawl", "Champhai", "Hnahthial", "Khawzawl", "Kolasib", "Lawngtlai", "Lunglei", "Mamit", "Saiha", "Saitual", "Serchhip"],
+ "Nagaland": ["Dimapur", "Kiphire", "Kohima", "Longleng", "Mokokchung", "Mon", "Noklak", "Peren", "Phek", "Tuensang", "Wokha", "Zunheboto"],
+ "Odisha": ["Angul", "Balangir", "Balasore", "Bargarh", "Bhadrak", "Boudh", "Cuttack", "Deogarh", "Dhenkanal", "Gajapati", "Ganjam", "Jagatsinghpur", "Jajpur", "Jharsuguda", "Kalahandi", "Kandhamal", "Kendrapara", "Kendujhar", "Khordha", "Koraput", "Malkangiri", "Mayurbhanj", "Nabarangpur", "Nayagarh", "Nuapada", "Puri", "Rayagada", "Sambalpur", "Subarnapur", "Sundargarh"],
+ "Puducherry": ["Karaikal", "Mahe", "Puducherry", "Yanam"],
+ "Punjab": ["Amritsar", "Barnala", "Bathinda", "Faridkot", "Fatehgarh Sahib", "Fazilka", "Ferozepur", "Gurdaspur", "Hoshiarpur", "Jalandhar", "Kapurthala", "Ludhiana", "Mansa", "Moga", "Muktsar", "Pathankot", "Patiala", "Rupnagar", "Sahibzada Ajit Singh Nagar", "Sangrur", "Shahid Bhagat Singh Nagar", "Sri Muktsar Sahib", "Tarn Taran"],
+ "Rajasthan": ["Ajmer", "Alwar", "Banswara", "Baran", "Barmer", "Bharatpur", "Bhilwara", "Bikaner", "Bundi", "Chittorgarh", "Churu", "Dausa", "Dholpur", "Dungarpur", "Hanumangarh", "Jaipur", "Jaisalmer", "Jalore", "Jhalawar", "Jhunjhunu", "Jodhpur", "Karauli", "Kota", "Nagaur", "Pali", "Pratapgarh", "Rajsamand", "Sawai Madhopur", "Sikar", "Sirohi", "Sri Ganganagar", "Tonk", "Udaipur"],
+ "Sikkim": ["East Sikkim", "North Sikkim", "South Sikkim", "West Sikkim"],
+ "Tamil Nadu": ["Ariyalur", "Chengalpattu", "Chennai", "Coimbatore", "Cuddalore", "Dharmapuri", "Dindigul", "Erode", "Kallakurichi", "Kanchipuram", "Kanyakumari", "Karur", "Krishnagiri", "Madurai", "Mayiladuthurai", "Nagapattinam", "Namakkal", "Nilgiris", "Perambalur", "Pudukkottai", "Ramanathapuram", "Ranipet", "Salem", "Sivaganga", "Tenkasi", "Thanjavur", "Theni", "Thoothukudi", "Tiruchirappalli", "Tirunelveli", "Tirupathur", "Tiruppur", "Tiruvallur", "Tiruvannamalai", "Tiruvarur", "Vellore", "Viluppuram", "Virudhunagar"],
+ "Telangana": ["Adilabad", "Bhadradri Kothagudem", "Hyderabad", "Jagtial", "Jangaon", "Jayashankar Bhupalpally", "Jogulamba Gadwal", "Kamareddy", "Karimnagar", "Khammam", "Kumuram Bheem", "Mahabubabad", "Mahabubnagar", "Mancherial", "Medak", "Medchal-Malkajgiri", "Mulugu", "Nagarkurnool", "Nalgonda", "Narayanpet", "Nirmal", "Nizamabad", "Peddapalli", "Rajanna Sircilla", "Rangareddy", "Sangareddy", "Siddipet", "Suryapet", "Vikarabad", "Wanaparthy", "Warangal Rural", "Warangal Urban", "Yadadri Bhuvanagiri"],
+ "Tripura": ["Dhalai", "Gomati", "Khowai", "North Tripura", "Sepahijala", "South Tripura", "Unakoti", "West Tripura"],
+ "Uttar Pradesh": ["Agra", "Aligarh", "Ambedkar Nagar", "Amethi", "Amroha", "Auraiya", "Ayodhya", "Azamgarh", "Baghpat", "Bahraich", "Ballia", "Balrampur", "Banda", "Barabanki", "Bareilly", "Basti", "Bhadohi", "Bijnor", "Budaun", "Bulandshahr", "Chandauli", "Chitrakoot", "Deoria", "Etah", "Etawah", "Farrukhabad", "Fatehpur", "Firozabad", "Gautam Buddha Nagar", "Ghaziabad", "Ghazipur", "Gonda", "Gorakhpur", "Hamirpur", "Hapur", "Hardoi", "Hathras", "Jalaun", "Jaunpur", "Jhansi", "Kannauj", "Kanpur Dehat", "Kanpur Nagar", "Kasganj", "Kaushambi", "Kushinagar", "Lakhimpur Kheri", "Lalitpur", "Lucknow", "Maharajganj", "Mahoba", "Mainpuri", "Mathura", "Mau", "Meerut", "Mirzapur", "Moradabad", "Muzaffarnagar", "Pilibhit", "Pratapgarh", "Prayagraj", "Rae Bareli", "Rampur", "Saharanpur", "Sambhal", "Sant Kabir Nagar", "Shahjahanpur", "Shamli", "Shravasti", "Siddharthnagar", "Sitapur", "Sonbhadra", "Sultanpur", "Unnao", "Varanasi"],
+ "Uttarakhand": ["Almora", "Bageshwar", "Chamoli", "Champawat", "Dehradun", "Haridwar", "Nainital", "Pauri Garhwal", "Pithoragarh", "Rudraprayag", "Tehri Garhwal", "Udham Singh Nagar", "Uttarkashi"],
+ "West Bengal": ["Alipurduar", "Bankura", "Birbhum", "Cooch Behar", "Dakshin Dinajpur", "Darjeeling", "Hooghly", "Howrah", "Jalpaiguri", "Jhargram", "Kalimpong", "Kolkata", "Malda", "Murshidabad", "Nadia", "North 24 Parganas", "Paschim Bardhaman", "Paschim Medinipur", "Purba Bardhaman", "Purba Medinipur", "Purulia", "South 24 Parganas", "Uttar Dinajpur"]
+};
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..b9c9e3b75189fd0a513302e069c2d5a6985f06c3
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,192 @@
+@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap');
+
+:root {
+ --bg-color: #060907;
+ --surface-color: #0f1612;
+ --surface-light: #18231c;
+ --surface-glass: rgba(24, 35, 28, 0.7);
+ --primary-color: #10b981;
+ --primary-dark: #059669;
+ --primary-glow: rgba(16, 185, 129, 0.4);
+ --text-main: #f8fafc;
+ --text-muted: #94a3b8;
+ --danger-color: #ef4444;
+ --danger-glass: rgba(239, 68, 68, 0.2);
+ --border-color: rgba(255, 255, 255, 0.05);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ font-family: 'Outfit', sans-serif;
+}
+
+body {
+ background-color: var(--bg-color);
+ color: var(--text-main);
+ min-height: 100vh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+}
+
+.app-container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ min-height: 100vh;
+ background-color: var(--surface-color);
+ position: relative;
+ box-shadow: 0 0 100px rgba(0, 0, 0, 0.8);
+ display: flex;
+ flex-direction: column;
+}
+
+.admin-container {
+ width: 100%;
+ min-height: 100vh;
+ background-color: var(--bg-color);
+ display: flex;
+ flex-direction: column;
+}
+
+/* Typography elements */
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.text-gradient {
+ background: linear-gradient(135deg, #34d399, #10b981);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+/* Glass UI styles */
+.glass-panel {
+ background: var(--surface-glass);
+ backdrop-filter: blur(12px);
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+}
+
+/* Buttons */
+.btn-primary {
+ background: linear-gradient(135deg, #10b981, #059669);
+ color: white;
+ border: none;
+ padding: 14px 24px;
+ border-radius: 14px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 8px 24px var(--primary-glow);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-primary:active {
+ transform: scale(0.96);
+ box-shadow: 0 4px 12px var(--primary-glow);
+}
+
+.btn-secondary {
+ background: var(--surface-light);
+ color: var(--text-main);
+ border: 1px solid var(--border-color);
+ padding: 14px 24px;
+ border-radius: 14px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-secondary:active, .btn-icon:active {
+ transform: scale(0.96);
+}
+
+.btn-icon-circular {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface-light);
+ color: var(--text-main);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+/* Call specific buttons */
+.btn-call-accept {
+ background: #10b981;
+ color: white;
+ box-shadow: 0 8px 30px rgba(16, 185, 129, 0.4);
+}
+.btn-call-reject {
+ background: #ef4444;
+ color: white;
+ box-shadow: 0 8px 30px rgba(239, 68, 68, 0.4);
+}
+
+/* Input Fields */
+.input-base {
+ width: 100%;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ color: var(--text-main);
+ padding: 16px;
+ border-radius: 14px;
+ font-size: 16px;
+ outline: none;
+ transition: all 0.2s;
+}
+.input-base:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px var(--primary-glow);
+}
+
+.input-base::placeholder {
+ color: var(--text-muted);
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes pulse-ring {
+ 0% { transform: scale(0.8); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
+ 70% { transform: scale(1); box-shadow: 0 0 0 15px rgba(16, 185, 129, 0); }
+ 100% { transform: scale(0.8); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
+}
+
+/* Utilities */
+.scroll-hide::-webkit-scrollbar {
+ display: none;
+}
+.scroll-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.fixed-bottom {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 20px;
+ background: linear-gradient(to top, var(--surface-color) 70%, transparent);
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cf01adadfbd11ff0deec5e223bba9882d9448b43
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './index.css';
+import { BrowserRouter } from 'react-router-dom';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+ ,
+);
diff --git a/src/socket.js b/src/socket.js
new file mode 100644
index 0000000000000000000000000000000000000000..73043fdf7769ed5fba9b5a9147cfb9df0e63848e
--- /dev/null
+++ b/src/socket.js
@@ -0,0 +1,3 @@
+import { io } from 'socket.io-client';
+
+export const socket = io('/', { autoConnect: false });
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 0000000000000000000000000000000000000000..db6c9ed190fd52144f8515964c39357fb97988a4
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,139 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { v4 as uuidv4 } from 'uuid';
+
+export const useStore = create(
+ persist(
+ (set, get) => ({
+ deviceId: null,
+ deviceToken: null,
+ currentUser: null,
+ deviceProfiles: [],
+ deviceProfilesLoaded: false,
+ users: [],
+ listings: [], // Now fetched from backend
+ messages: [],
+ activeCall: null,
+ missedCalls: 0,
+ isLoading: false,
+
+ initDevice: () => {
+ let id = get().deviceId;
+ let token = get().deviceToken;
+ if (!id) {
+ id = uuidv4();
+ token = uuidv4(); // Unique secret for this device
+ set({ deviceId: id, deviceToken: token });
+ }
+ get().fetchDeviceProfiles(id, token);
+ return { deviceId: id, deviceToken: token };
+ },
+
+ fetchDeviceProfiles: async (deviceId, deviceToken) => {
+ try {
+ const id = deviceId || get().deviceId;
+ const token = deviceToken || get().deviceToken;
+
+ if (!id) return;
+
+ const res = await fetch(`/api/users/device/${id}`, {
+ headers: { 'x-device-token': token || '' }
+ });
+
+ if (res.ok) {
+ const profiles = await res.json();
+ set({ deviceProfiles: profiles, deviceProfilesLoaded: true });
+ } else {
+ set({ deviceProfilesLoaded: true });
+ }
+ } catch (err) {
+ console.error(err);
+ set({ deviceProfilesLoaded: true });
+ }
+ },
+
+ login: (userRecord) => {
+ set({ currentUser: userRecord });
+ get().fetchDeviceProfiles(get().deviceId, get().deviceToken);
+ },
+
+ logout: () => {
+ set({ currentUser: null });
+ if (get().deviceId) get().fetchDeviceProfiles(get().deviceId, get().deviceToken);
+ },
+
+ // Messaging State
+ unreadCount: 0,
+ activeChatUser: null,
+ showInbox: false,
+ startChat: (id, name, selfiePath) => set({ activeChatUser: { id, name, selfiePath }, showInbox: false }),
+ closeChat: () => set({ activeChatUser: null }),
+ toggleInbox: () => set(state => ({ showInbox: !state.showInbox })),
+ setUnreadCount: (count) => set({ unreadCount: count }),
+ incrementMissedCalls: () => set(state => ({ missedCalls: state.missedCalls + 1 })),
+ resetMissedCalls: () => set({ missedCalls: 0 }),
+ fetchUnreadCount: async () => {
+ const user = get().currentUser;
+ if (!user) return;
+ try {
+ const res = await fetch(`/api/messages/unread-count/${user.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ set({ unreadCount: data.count });
+ }
+ } catch (err) { console.error(err); }
+ },
+
+ fetchListings: async () => {
+ set({ isLoading: true });
+ try {
+ const res = await fetch('/api/listings');
+ if (res.ok) {
+ const data = await res.json();
+ set({ listings: data });
+ }
+ } catch (error) {
+ console.error("Failed to fetch listings:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ updateListingStatus: (id, status) => {
+ set(state => ({
+ listings: state.listings.map(l => l.id === id ? { ...l, status } : l)
+ }));
+ },
+
+ editListingDetails: (id, updates) => {
+ set(state => ({
+ listings: state.listings.map(l => l.id === id ? { ...l, ...updates } : l)
+ }));
+ },
+
+ startCall: (receiverId, receiverName, receiverSelfie) => {
+ set({
+ activeCall: {
+ callerId: get().currentUser.id,
+ callerName: get().currentUser.name,
+ callerSelfie: get().currentUser.selfiePath,
+ receiverId,
+ receiverName,
+ receiverSelfie,
+ status: 'ringing'
+ }
+ });
+ },
+
+ endCall: () => set({ activeCall: null })
+ }),
+ {
+ name: 'mandi-storage',
+ partialize: (state) => ({
+ deviceId: state.deviceId,
+ deviceToken: state.deviceToken,
+ currentUser: state.currentUser
+ }),
+ }
+ )
+);
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..a66c49e699ae604f52b8c18957461f4f2ed63662
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,28 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3001',
+ changeOrigin: true
+ },
+ '/uploads': {
+ target: 'http://localhost:3001',
+ changeOrigin: true
+ },
+ '/socket.io': {
+ target: 'http://localhost:3001',
+ ws: true,
+ changeOrigin: true
+ }
+ }
+ }
+})
+