From 4753ee401c00ff3e98dee25c01df64d219727e9c Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 12:20:06 +0530 Subject: [PATCH 01/30] feat: integrate Mapbox for enhanced mapping functionality - Added HomeMap component to display a Mapbox map with geolocation features. - Updated Home page to render the HomeMap component. - Introduced PrivateLayout component for consistent layout structure. - Updated TypeScript configuration to include additional type definitions. - Added Mapbox GL and related dependencies to package.json and package-lock.json. - Created global.d.ts for Mapbox CSS module declaration. --- client/app/(private)/home/page.tsx | 21 +- client/app/(private)/layout.tsx | 7 + client/components/home/HomeMap.tsx | 310 ++++++++++++++++++++++ client/components/landing/Footer.tsx | 2 +- client/components/profile/ProfileCard.tsx | 4 +- client/package-lock.json | 254 ++++++++++++++++++ client/package.json | 1 + client/tsconfig.json | 3 +- client/types/global.d.ts | 1 + server/src/middleware/tokenVerify.ts | 4 +- 10 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 client/app/(private)/layout.tsx create mode 100644 client/components/home/HomeMap.tsx create mode 100644 client/types/global.d.ts diff --git a/client/app/(private)/home/page.tsx b/client/app/(private)/home/page.tsx index 1c5c7b8..68a2e36 100644 --- a/client/app/(private)/home/page.tsx +++ b/client/app/(private)/home/page.tsx @@ -1,22 +1,5 @@ -"use client"; +import HomeMap from "@/components/home/HomeMap"; export default function Home() { - const handleLogout = () => { - window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/auth/google/logout`; - }; - - return ( -
-
-

Home

-

You are logged in!

- -
-
- ); + return ; } diff --git a/client/app/(private)/layout.tsx b/client/app/(private)/layout.tsx new file mode 100644 index 0000000..d40dfb1 --- /dev/null +++ b/client/app/(private)/layout.tsx @@ -0,0 +1,7 @@ +export default function PrivateLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx new file mode 100644 index 0000000..df8f65d --- /dev/null +++ b/client/components/home/HomeMap.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import { ArrowRight, Info, MapPin, ShieldCheck } from "lucide-react"; +import mapboxgl from "mapbox-gl"; +import "mapbox-gl/dist/mapbox-gl.css"; + +type HomeMapProps = { + className?: string; +}; + +export default function HomeMap({ className }: HomeMapProps) { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const [showRightModal, setShowRightModal] = useState(true); + const revealTimeoutRef = useRef | null>(null); + const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; + const [mapStatus, setMapStatus] = useState< + "loading" | "ready" | "error" | "no-token" + >(mapboxToken ? "loading" : "no-token"); + const [mapError, setMapError] = useState(null); + const [geoError, setGeoError] = useState(null); + + const mapStatusLabel = useMemo(() => { + if (mapStatus === "no-token") return "Mapbox token missing"; + if (mapStatus === "error") return mapError || "Map failed to load"; + if (mapStatus === "loading") return "Loading map..."; + return ""; + }, [mapStatus, mapError]); + + useEffect(() => { + const container = mapContainerRef.current; + if (!container) return; + if (mapRef.current) return; + + if (!mapboxToken) return; + mapboxgl.accessToken = mapboxToken; + + const map = new mapboxgl.Map({ + container, + style: "mapbox://styles/mapbox/light-v11", + center: [0, 0], + zoom: 2, + attributionControl: false, + }); + + mapRef.current = map; + map.addControl( + new mapboxgl.NavigationControl({ showCompass: false }), + "bottom-right" + ); + + const handleMapLoad = () => setMapStatus("ready"); + const handleMapError = () => { + setMapStatus("error"); + setMapError("Map failed to load. Check your token or network."); + }; + + map.on("load", handleMapLoad); + map.on("error", handleMapError); + + const hideRightModal = () => { + if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); + setShowRightModal(false); + }; + + const scheduleShowRightModal = () => { + if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); + revealTimeoutRef.current = setTimeout(() => { + setShowRightModal(true); + }, 1000); + }; + + map.on("movestart", hideRightModal); + map.on("moveend", scheduleShowRightModal); + + let marker: mapboxgl.Marker | null = null; + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const { longitude, latitude } = position.coords; + map.flyTo({ + center: [longitude, latitude], + zoom: 13, + essential: true, + }); + + marker = new mapboxgl.Marker({ color: "#2bee6c" }) + .setLngLat([longitude, latitude]) + .addTo(map); + }, + (error) => { + setGeoError(error.message || "Location permission was denied."); + map.flyTo({ center: [0, 0], zoom: 2 }); + }, + { enableHighAccuracy: true, timeout: 8000 } + ); + } else { + setTimeout(() => { + setGeoError("Geolocation is not supported in this browser."); + }, 0); + } + + const resize = () => map.resize(); + const raf = requestAnimationFrame(resize); + window.addEventListener("resize", resize); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(container); + } + + return () => { + map.off("movestart", hideRightModal); + map.off("moveend", scheduleShowRightModal); + map.off("load", handleMapLoad); + map.off("error", handleMapError); + if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); + if (marker) marker.remove(); + window.removeEventListener("resize", resize); + if (resizeObserver) resizeObserver.disconnect(); + cancelAnimationFrame(raf); + map.remove(); + mapRef.current = null; + }; + }, [mapboxToken]); + + return ( +
+ {/* Map Background */} +
+
+
+ + {/* Subtle Air Quality Blobs */} +
+
+ + {mapStatus !== "ready" && ( +
+
+ {mapStatusLabel} +
+
+ )} + + {/* Floating Overlays */} +
+ {/* Left Section: Search Card — compact */} +
+
+

+ Start your healthy journey +

+

+ Enter your route to find the cleanest path. +

+
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ + {geoError && ( +
+ {geoError} +
+ )} + +
+
+
+
+ + Clean + +
+
+
+ + Fair + +
+
+
+ + Poor + +
+
+ + Live Air Quality Data + +
+
+
+ + {/* Right Section: Onboarding — prominent emphasis */} + +
+
+
+ ); +} diff --git a/client/components/landing/Footer.tsx b/client/components/landing/Footer.tsx index 48b3689..b8ee9c2 100644 --- a/client/components/landing/Footer.tsx +++ b/client/components/landing/Footer.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { AtSign, Globe, Leaf } from "lucide-react"; +import { AtSign, Globe } from "lucide-react"; const footerLinks = { "Web App": [ diff --git a/client/components/profile/ProfileCard.tsx b/client/components/profile/ProfileCard.tsx index 962591a..8ea2559 100644 --- a/client/components/profile/ProfileCard.tsx +++ b/client/components/profile/ProfileCard.tsx @@ -1,3 +1,5 @@ +import Image from "next/image"; + import { Calendar, Mail, MapPin, Shield, User } from "lucide-react"; import type { UserData } from "./types"; @@ -23,7 +25,7 @@ export default function ProfileCard({ user }: { user: UserData }) {
{user.picture ? ( - {user.name}= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==", + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -3866,6 +3919,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3880,6 +3948,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -3890,6 +3964,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3917,6 +3997,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -5084,6 +5173,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", + "license": "ISC" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5383,6 +5478,12 @@ "node": ">= 8" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5680,6 +5781,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/eciesjs": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", @@ -6874,6 +6981,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7006,6 +7119,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7079,6 +7198,12 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8114,6 +8239,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8544,6 +8675,57 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mapbox-gl": { + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.18.1.tgz", + "integrity": "sha512-Izc8dee2zkmb6Pn9hXFbVioPRLXJz1OFUcrvri69MhFACPU4bhLyVmhEsD9AyW1qOAP0Yvhzm60v63xdMIHPPw==", + "license": "SEE LICENSE IN LICENSE.txt", + "workspaces": [ + "src/style-spec", + "test/build/vite", + "test/build/webpack", + "test/build/typings" + ], + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "^3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "grid-index": "^1.1.0", + "kdbush": "^4.0.2", + "martinez-polygon-clipping": "^0.8.1", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + } + }, + "node_modules/martinez-polygon-clipping": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz", + "integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^2.0.4", + "splaytree": "^0.1.4", + "tinyqueue": "3.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8747,6 +8929,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -9374,6 +9562,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9456,6 +9656,12 @@ "node": ">=4" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -9531,6 +9737,12 @@ "react-is": "^16.13.1" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9592,6 +9804,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -9914,6 +10132,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -9949,6 +10176,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10493,6 +10726,12 @@ "node": ">=0.10.0" } }, + "node_modules/splaytree": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", + "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", + "license": "MIT" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10775,6 +11014,15 @@ } } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10910,6 +11158,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tldts": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", diff --git a/client/package.json b/client/package.json index 4879348..082aaa2 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "mapbox-gl": "^3.18.1", "next": "16.1.6", "radix-ui": "^1.4.3", "react": "19.2.3", diff --git a/client/tsconfig.json b/client/tsconfig.json index 3a13f90..dab9b34 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -28,7 +28,8 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + "**/*.d.ts" ], "exclude": ["node_modules"] } diff --git a/client/types/global.d.ts b/client/types/global.d.ts new file mode 100644 index 0000000..02e31b5 --- /dev/null +++ b/client/types/global.d.ts @@ -0,0 +1 @@ +declare module "mapbox-gl/dist/mapbox-gl.css"; diff --git a/server/src/middleware/tokenVerify.ts b/server/src/middleware/tokenVerify.ts index b260a7b..1a248a6 100644 --- a/server/src/middleware/tokenVerify.ts +++ b/server/src/middleware/tokenVerify.ts @@ -5,7 +5,7 @@ export const tokenVerify = ( req: Request, res: Response, next: NextFunction -) => { +): Response | void => { try { const token = req.cookies.refreshToken; if (!token) { @@ -16,7 +16,7 @@ export const tokenVerify = ( process.env.REFRESH_TOKEN_SECRET! ) as JwtPayload; req.userId = decoded.userId; - next(); + return next(); } catch (error) { console.log("Error in tokenVerify:", error); return res.status(500).json({ errorMsg: "Internal server error", error }); From 84f3c35c0eb9ea77cd96efd073be9602a706df79 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 12:41:21 +0530 Subject: [PATCH 02/30] fix: remove error logging from token verification middleware response --- client/components/home/HomeMap.tsx | 7 ++++++- server/src/middleware/tokenVerify.ts | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx index df8f65d..f56bae0 100644 --- a/client/components/home/HomeMap.tsx +++ b/client/components/home/HomeMap.tsx @@ -37,12 +37,13 @@ export default function HomeMap({ className }: HomeMapProps) { if (!mapboxToken) return; mapboxgl.accessToken = mapboxToken; + let isCancelled = false; const map = new mapboxgl.Map({ container, style: "mapbox://styles/mapbox/light-v11", center: [0, 0], zoom: 2, - attributionControl: false, + attributionControl: true, }); mapRef.current = map; @@ -80,6 +81,7 @@ export default function HomeMap({ className }: HomeMapProps) { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { + if (isCancelled) return; const { longitude, latitude } = position.coords; map.flyTo({ center: [longitude, latitude], @@ -92,6 +94,7 @@ export default function HomeMap({ className }: HomeMapProps) { .addTo(map); }, (error) => { + if (isCancelled) return; setGeoError(error.message || "Location permission was denied."); map.flyTo({ center: [0, 0], zoom: 2 }); }, @@ -99,6 +102,7 @@ export default function HomeMap({ className }: HomeMapProps) { ); } else { setTimeout(() => { + if (isCancelled) return; setGeoError("Geolocation is not supported in this browser."); }, 0); } @@ -114,6 +118,7 @@ export default function HomeMap({ className }: HomeMapProps) { } return () => { + isCancelled = true; map.off("movestart", hideRightModal); map.off("moveend", scheduleShowRightModal); map.off("load", handleMapLoad); diff --git a/server/src/middleware/tokenVerify.ts b/server/src/middleware/tokenVerify.ts index 1a248a6..32eda3a 100644 --- a/server/src/middleware/tokenVerify.ts +++ b/server/src/middleware/tokenVerify.ts @@ -17,8 +17,7 @@ export const tokenVerify = ( ) as JwtPayload; req.userId = decoded.userId; return next(); - } catch (error) { - console.log("Error in tokenVerify:", error); - return res.status(500).json({ errorMsg: "Internal server error", error }); + } catch { + return res.status(500).json({ errorMsg: "Internal server error" }); } }; From e16f2df6b39bc3f6d9ff0dddc97e7551b4e15872 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 15:38:42 +0530 Subject: [PATCH 03/30] feat: Implement interactive map for selecting route source/destination points and define route data schema. --- client/components/home/HomeMap.tsx | 363 ++++++++++++++++++++++++----- server/src/Schema/route.schema.ts | 185 +++++++++++++++ 2 files changed, 495 insertions(+), 53 deletions(-) create mode 100644 server/src/Schema/route.schema.ts diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx index f56bae0..6f7bc72 100644 --- a/client/components/home/HomeMap.tsx +++ b/client/components/home/HomeMap.tsx @@ -1,8 +1,15 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { ArrowRight, Info, MapPin, ShieldCheck } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + ArrowRight, + Crosshair, + Info, + LocateFixed, + MapPin, + ShieldCheck, +} from "lucide-react"; import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css"; @@ -10,18 +17,44 @@ type HomeMapProps = { className?: string; }; +type LocationData = { + lng: number; + lat: number; + address: string; +}; + export default function HomeMap({ className }: HomeMapProps) { const mapContainerRef = useRef(null); const mapRef = useRef(null); const [showRightModal, setShowRightModal] = useState(true); const revealTimeoutRef = useRef | null>(null); + const markersRef = useRef<{ + source: mapboxgl.Marker | null; + dest: mapboxgl.Marker | null; + }>({ source: null, dest: null }); + const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; + + // Loading States const [mapStatus, setMapStatus] = useState< "loading" | "ready" | "error" | "no-token" >(mapboxToken ? "loading" : "no-token"); + const [isLocating, setIsLocating] = useState(!!mapboxToken); const [mapError, setMapError] = useState(null); const [geoError, setGeoError] = useState(null); + // Selection States + const [sourceLocation, setSourceLocation] = useState( + null + ); + const [destLocation, setDestLocation] = useState(null); + const [pickingMode, setPickingMode] = useState<"source" | "dest" | null>( + null + ); + const [isGettingCurrentLocation, setIsGettingCurrentLocation] = useState< + "source" | "dest" | null + >(null); + const mapStatusLabel = useMemo(() => { if (mapStatus === "no-token") return "Mapbox token missing"; if (mapStatus === "error") return mapError || "Map failed to load"; @@ -29,12 +62,95 @@ export default function HomeMap({ className }: HomeMapProps) { return ""; }, [mapStatus, mapError]); + // Helper to reverse geocode + const reverseGeocode = useCallback( + async (lng: number, lat: number): Promise => { + try { + const response = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxToken}` + ); + const data = await response.json(); + if (data.features && data.features.length > 0) { + return data.features[0].place_name; // Full address + } + return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; // Fallback + } catch (error) { + console.error("Geocoding failed:", error); + return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + } + }, + [mapboxToken] + ); + + // Helper to update markers + const updateMarker = (type: "source" | "dest", lng: number, lat: number) => { + const map = mapRef.current; + if (!map) return; + + // Remove existing marker if any + if (markersRef.current[type]) { + markersRef.current[type]?.remove(); + } + + // Create new marker + const color = type === "source" ? "#2bee6c" : "#f43f5e"; // Green for source, Red for dest + const marker = new mapboxgl.Marker({ color }) + .setLngLat([lng, lat]) + .addTo(map); + + markersRef.current[type] = marker; + }; + + // Helper: Use Current Location for specific input + const handleNavigationClick = (type: "source" | "dest") => { + if (!navigator.geolocation) { + setGeoError("Geolocation is not supported in this browser."); + return; + } + + setIsGettingCurrentLocation(type); + + navigator.geolocation.getCurrentPosition( + async (position) => { + const { longitude, latitude } = position.coords; + const address = await reverseGeocode(longitude, latitude); + + if (type === "source") { + setSourceLocation({ lng: longitude, lat: latitude, address }); + updateMarker("source", longitude, latitude); + } else { + setDestLocation({ lng: longitude, lat: latitude, address }); + updateMarker("dest", longitude, latitude); + } + + // Fly to updated location + mapRef.current?.flyTo({ + center: [longitude, latitude], + zoom: 14, + essential: true, + }); + + setIsGettingCurrentLocation(null); + setGeoError(null); + }, + (error) => { + setGeoError(error.message || "Location permission denied."); + setIsGettingCurrentLocation(null); + }, + { enableHighAccuracy: true } + ); + }; + useEffect(() => { const container = mapContainerRef.current; if (!container) return; if (mapRef.current) return; - if (!mapboxToken) return; + if (!mapboxToken) { + // Status is already set to 'no-token' by initial state + return; + } + mapboxgl.accessToken = mapboxToken; let isCancelled = false; @@ -56,6 +172,7 @@ export default function HomeMap({ className }: HomeMapProps) { const handleMapError = () => { setMapStatus("error"); setMapError("Map failed to load. Check your token or network."); + setIsLocating(false); }; map.on("load", handleMapLoad); @@ -65,8 +182,9 @@ export default function HomeMap({ className }: HomeMapProps) { if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); setShowRightModal(false); }; - const scheduleShowRightModal = () => { + // Only show if NOT picking + // We'll handle this in the picking logic if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); revealTimeoutRef.current = setTimeout(() => { setShowRightModal(true); @@ -76,26 +194,34 @@ export default function HomeMap({ className }: HomeMapProps) { map.on("movestart", hideRightModal); map.on("moveend", scheduleShowRightModal); - let marker: mapboxgl.Marker | null = null; - + // Initial Geolocation if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( - (position) => { + async (position) => { if (isCancelled) return; const { longitude, latitude } = position.coords; + + // Fetch address for current location + const address = await reverseGeocode(longitude, latitude); + if (isCancelled) return; + + setSourceLocation({ lng: longitude, lat: latitude, address }); + updateMarker("source", longitude, latitude); + + map.once("moveend", () => { + if (!isCancelled) setIsLocating(false); + }); + map.flyTo({ center: [longitude, latitude], zoom: 13, essential: true, }); - - marker = new mapboxgl.Marker({ color: "#2bee6c" }) - .setLngLat([longitude, latitude]) - .addTo(map); }, (error) => { if (isCancelled) return; setGeoError(error.message || "Location permission was denied."); + setIsLocating(false); map.flyTo({ center: [0, 0], zoom: 2 }); }, { enableHighAccuracy: true, timeout: 8000 } @@ -104,19 +230,13 @@ export default function HomeMap({ className }: HomeMapProps) { setTimeout(() => { if (isCancelled) return; setGeoError("Geolocation is not supported in this browser."); + setIsLocating(false); }, 0); } const resize = () => map.resize(); - const raf = requestAnimationFrame(resize); window.addEventListener("resize", resize); - let resizeObserver: ResizeObserver | null = null; - if (typeof ResizeObserver !== "undefined") { - resizeObserver = new ResizeObserver(resize); - resizeObserver.observe(container); - } - return () => { isCancelled = true; map.off("movestart", hideRightModal); @@ -124,14 +244,60 @@ export default function HomeMap({ className }: HomeMapProps) { map.off("load", handleMapLoad); map.off("error", handleMapError); if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); - if (marker) marker.remove(); + + // Safe Cleanup + const markers = markersRef.current; + if (markers.source) markers.source.remove(); + if (markers.dest) markers.dest.remove(); + window.removeEventListener("resize", resize); - if (resizeObserver) resizeObserver.disconnect(); - cancelAnimationFrame(raf); map.remove(); mapRef.current = null; }; - }, [mapboxToken]); + }, [mapboxToken, reverseGeocode]); + + // Effect to handle Map Clicks based on pickingMode + // We attach this separately so it can access the latest 'pickingMode' state + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const onMapClick = async (e: mapboxgl.MapMouseEvent) => { + if (!pickingMode) return; + + const { lng, lat } = e.lngLat; + const address = await reverseGeocode(lng, lat); + + if (pickingMode === "source") { + setSourceLocation({ lng, lat, address }); + updateMarker("source", lng, lat); + } else { + setDestLocation({ lng, lat, address }); + updateMarker("dest", lng, lat); + } + + setPickingMode(null); // Exit picking mode + }; + + map.on("click", onMapClick); + + // Change cursor style + if (pickingMode) { + map.getCanvas().style.cursor = "crosshair"; + } else { + map.getCanvas().style.cursor = ""; + } + + return () => { + map.off("click", onMapClick); + map.getCanvas().style.cursor = ""; + }; + }, [pickingMode, reverseGeocode]); + + const shouldShowLoader = + (mapStatus === "loading" || isLocating) && + mapStatus !== "error" && + mapStatus !== "no-token"; return (
+ {/* Picking Mode Overlay Banner */} + {pickingMode && ( +
+
+ Click on the map to choose{" "} + {pickingMode === "source" ? "Start Point" : "Destination"} +
+
+ )} + {/* Subtle Air Quality Blobs */}
- {mapStatus !== "ready" && ( + {/* Full Screen Loading Overlay */} + {shouldShowLoader && ( +
+
+
+
+
+ +
+
+

+ {mapStatus === "loading" + ? "Initializing Map" + : "Locating You"} +

+

+ Finding the cleanest air near you... +

+
+
+
+ )} + + {mapStatus !== "ready" && !shouldShowLoader && (
-
+
{mapStatusLabel}
)} {/* Floating Overlays */} -
- {/* Left Section: Search Card — compact */} +
+ {/* Left Section: Search Card */}

@@ -167,32 +374,86 @@ export default function HomeMap({ className }: HomeMapProps) {

Enter your route to find the cleanest path.

-
-
- -
-
- -
-
-
- +
+ {" "} + {/* Increased spacing for buttons */} +
+ {/* Source Input Group */} +
+
+
+ + {/* Navigation/Current Location Button */} + +
+ {/* Choose on Map Button for Source */} + +
+ {/* Destination Input Group */} +
+
+
+ +
+ + {/* Navigation/Current Location Button for Dest */} +
- + {/* Choose on Map Button for Destination */} +
-
- @@ -232,7 +493,7 @@ export default function HomeMap({ className }: HomeMapProps) {
- {/* Right Section: Onboarding — prominent emphasis */} + {/* Right Section: Onboarding */} -
@@ -264,7 +524,6 @@ export default function HomeMap({ className }: HomeMapProps) {

-
2 @@ -279,7 +538,6 @@ export default function HomeMap({ className }: HomeMapProps) {

-
3 @@ -293,7 +551,6 @@ export default function HomeMap({ className }: HomeMapProps) {
-
diff --git a/server/src/Schema/route.schema.ts b/server/src/Schema/route.schema.ts new file mode 100644 index 0000000..c070c21 --- /dev/null +++ b/server/src/Schema/route.schema.ts @@ -0,0 +1,185 @@ +import mongoose, { Document, Schema } from "mongoose"; + +/* ================= GEO TYPES ================= */ + +export interface IPoint { + type: "Point"; + coordinates: [number, number]; // [longitude, latitude] +} + +export interface ILineString { + type: "LineString"; + coordinates: [number, number][]; +} + +/* ================= ROUTE OPTION ================= */ + +export interface IRouteOption { + distance: number; // in km + duration: number; // in minutes + + routeGeometry: ILineString; + + // dynamically updated by pollution engine + lastComputedScore?: number; + lastComputedAt?: Date; +} + +/* ================= MAIN ROUTE ================= */ + +export interface IRoute extends Document { + userId: mongoose.Types.ObjectId; + name?: string; + + from: { + address: string; + location: IPoint; + }; + + to: { + address: string; + location: IPoint; + }; + + routes: IRouteOption[]; + + isFavorite: boolean; + + createdAt: Date; + updatedAt: Date; +} + +/* ================= SCHEMAS ================= */ + +const pointSchema = new Schema( + { + type: { + type: String, + enum: ["Point"], + required: true, + }, + coordinates: { + type: [Number], + required: true, + }, + }, + { _id: false } +); + +const lineStringSchema = new Schema( + { + type: { + type: String, + enum: ["LineString"], + required: true, + }, + coordinates: { + type: [[Number]], + required: true, + }, + }, + { _id: false } +); + +/* ===== Route Option Schema ===== */ + +const routeOptionSchema = new Schema( + { + distance: { + type: Number, + required: true, + }, + duration: { + type: Number, + required: true, + }, + + routeGeometry: { + type: lineStringSchema, + required: true, + }, + + lastComputedScore: { + type: Number, + default: null, + }, + + lastComputedAt: { + type: Date, + default: null, + }, + }, + { _id: false } +); + +/* ===== Main Route Schema ===== */ + +const routeSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + name: { + type: String, + default: "Saved Route", + trim: true, + }, + + from: { + address: { + type: String, + required: true, + }, + location: { + type: pointSchema, + required: true, + }, + }, + + to: { + address: { + type: String, + required: true, + }, + location: { + type: pointSchema, + required: true, + }, + }, + + routes: { + type: [routeOptionSchema], + required: true, + validate: { + validator: (v: IRouteOption[]) => v.length > 0 && v.length <= 5, + message: "Routes must contain between 1 and 5 route options", + }, + }, + + isFavorite: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +/* ================= INDEXES ================= */ + +// Needed for geo queries (AQI lookup, weather zone) +routeSchema.index({ "from.location": "2dsphere" }); +routeSchema.index({ "to.location": "2dsphere" }); +routeSchema.index({ "routes.routeGeometry": "2dsphere" }); + +// User queries +routeSchema.index({ userId: 1, updatedAt: -1 }); +routeSchema.index({ userId: 1, isFavorite: 1 }); + +/* ================= MODEL ================= */ + +const Route = mongoose.model("Route", routeSchema); +export default Route; From 3385deef462c0cf3043271e5e59e2506f19bfd29 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 16:14:03 +0530 Subject: [PATCH 04/30] feat: Implement interactive Mapbox map component for source and destination selection with search and geolocation. --- client/components/home/HomeMap.tsx | 98 ++++++++++++++++++++++++--- client/package-lock.json | 104 +++++++++++++++++++++++++++++ client/package.json | 1 + 3 files changed, 192 insertions(+), 11 deletions(-) diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx index ad7f892..aeb7842 100644 --- a/client/components/home/HomeMap.tsx +++ b/client/components/home/HomeMap.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SearchBox } from "@mapbox/search-js-react"; import { ArrowRight, Crosshair, @@ -55,6 +56,13 @@ export default function HomeMap({ className }: HomeMapProps) { "source" | "dest" | null >(null); + // Search Box Queries + const [sourceQuery, setSourceQuery] = useState(""); + const [destQuery, setDestQuery] = useState(""); + + // Sync queries with locations (removed useEffects to avoid lint errors/loops) + // We will manually sync state when setting locations. + const mapStatusLabel = useMemo(() => { if (mapStatus === "no-token") return "Mapbox token missing"; if (mapStatus === "error") return mapError || "Map failed to load"; @@ -117,9 +125,11 @@ export default function HomeMap({ className }: HomeMapProps) { if (type === "source") { setSourceLocation({ lng: longitude, lat: latitude, address }); + setSourceQuery(address); updateMarker("source", longitude, latitude); } else { setDestLocation({ lng: longitude, lat: latitude, address }); + setDestQuery(address); updateMarker("dest", longitude, latitude); } @@ -206,6 +216,7 @@ export default function HomeMap({ className }: HomeMapProps) { if (isCancelled) return; setSourceLocation({ lng: longitude, lat: latitude, address }); + setSourceQuery(address); updateMarker("source", longitude, latitude); map.once("moveend", () => { @@ -243,7 +254,6 @@ export default function HomeMap({ className }: HomeMapProps) { map.off("moveend", scheduleShowRightModal); map.off("load", handleMapLoad); map.off("error", handleMapError); - if (revealTimeoutRef.current) clearTimeout(revealTimeoutRef.current); // Safe Cleanup const markers = markersRef.current; @@ -270,9 +280,11 @@ export default function HomeMap({ className }: HomeMapProps) { if (pickingMode === "source") { setSourceLocation({ lng, lat, address }); + setSourceQuery(address); updateMarker("source", lng, lat); } else { setDestLocation({ lng, lat, address }); + setDestQuery(address); updateMarker("dest", lng, lat); } @@ -387,12 +399,44 @@ export default function HomeMap({ className }: HomeMapProps) {
- setSourceQuery(val)} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onRetrieve={(res: any) => { + const feature = res.features[0]; + const [lng, lat] = feature.geometry.coordinates; + const address = + feature.properties?.place_name || + feature.properties?.full_address || + feature.place_name || + sourceQuery; + setSourceLocation({ + lng, + lat, + address, + }); + setSourceQuery(address); + updateMarker("source", lng, lat); + mapRef.current?.flyTo({ + center: [lng, lat], + zoom: 14, + }); + }} placeholder="Current location..." - type="text" - value={sourceLocation?.address || ""} - readOnly // Let's make it read-only for now if we rely on map picking/geo + options={{ language: "en", limit: 5 }} + theme={{ + variables: { + fontFamily: "inherit", + padding: "12px 48px 12px 42px", + borderRadius: "8px", + boxShadow: "none", + }, + icons: { + search: '', + }, + }} /> {/* Navigation/Current Location Button */}
- setDestQuery(val)} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onRetrieve={(res: any) => { + const feature = res.features[0]; + const [lng, lat] = feature.geometry.coordinates; + const address = + feature.properties?.place_name || + feature.properties?.full_address || + feature.place_name || + destQuery; + setDestLocation({ + lng, + lat, + address, + }); + setDestQuery(address); + updateMarker("dest", lng, lat); + mapRef.current?.flyTo({ + center: [lng, lat], + zoom: 14, + }); + }} placeholder="Search destination..." - type="text" - value={destLocation?.address || ""} - readOnly + options={{ language: "en", limit: 5 }} + theme={{ + variables: { + fontFamily: "inherit", + padding: "12px 48px 12px 42px", + borderRadius: "8px", + boxShadow: "none", + }, + icons: { + search: '', + }, + }} /> {/* Navigation/Current Location Button for Dest */} +
+
+ ); +} diff --git a/client/components/routes/MapControls.tsx b/client/components/routes/MapControls.tsx new file mode 100644 index 0000000..2752b44 --- /dev/null +++ b/client/components/routes/MapControls.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { LocateFixed, Minus, Plus } from "lucide-react"; + +export default function MapControls() { + return ( +
+ + + +
+ ); +} diff --git a/client/components/routes/RouteComparisonPanel.tsx b/client/components/routes/RouteComparisonPanel.tsx new file mode 100644 index 0000000..07f6fe5 --- /dev/null +++ b/client/components/routes/RouteComparisonPanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { AlertTriangle, Ruler, Timer } from "lucide-react"; + +type TravelMode = "walking" | "driving" | "cycling"; + +type RouteData = { + distance: number; + duration: number; + geometry: { + coordinates: [number, number][]; + type: string; + }; +}; + +type RouteComparisonPanelProps = { + routes: RouteData[]; + isLoading: boolean; + error: string | null; + selectedMode: TravelMode; + selectedRouteIndex: number; + onRouteSelect: (index: number) => void; +}; + +export default function RouteComparisonPanel({ + routes, + isLoading, + error, + selectedMode, + selectedRouteIndex, + onRouteSelect, +}: RouteComparisonPanelProps) { + // Format duration (seconds to readable format) + const formatDuration = (seconds: number): string => { + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; + }; + + // Format distance (meters to km) + const formatDistance = (meters: number): string => { + const km = (meters / 1000).toFixed(1); + return `${km} km`; + }; + + return ( + + ); +} diff --git a/client/components/routes/RouteDiscoveryPanel.tsx b/client/components/routes/RouteDiscoveryPanel.tsx new file mode 100644 index 0000000..7182e55 --- /dev/null +++ b/client/components/routes/RouteDiscoveryPanel.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { Activity, Bike, Car, CircleDot, MapPin, User } from "lucide-react"; + +type TravelMode = "walking" | "driving" | "cycling"; + +type RouteDiscoveryPanelProps = { + sourceAddress: string; + destAddress: string; + selectedMode: TravelMode; + onModeChange: (mode: TravelMode) => void; + onFindRoutes?: () => void; +}; + +export default function RouteDiscoveryPanel({ + sourceAddress, + destAddress, + selectedMode, + onModeChange, + onFindRoutes, +}: RouteDiscoveryPanelProps) { + const router = useRouter(); + + const handleChangeRoute = () => { + router.push("/home"); + }; + + return ( + + ); +} diff --git a/client/components/routes/RouteMapBackground.tsx b/client/components/routes/RouteMapBackground.tsx new file mode 100644 index 0000000..f4614ad --- /dev/null +++ b/client/components/routes/RouteMapBackground.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import mapboxgl from "mapbox-gl"; +import "mapbox-gl/dist/mapbox-gl.css"; + +type Coordinates = { + lng: number; + lat: number; +}; + +type RouteData = { + distance: number; + duration: number; + geometry: { + coordinates: [number, number][]; + type: string; + }; +}; + +type RouteMapBackgroundProps = { + source: Coordinates | null; + destination: Coordinates | null; + routes: RouteData[]; + selectedRouteIndex: number; +}; + +const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; + +export default function RouteMapBackground({ + source, + destination, + routes, + selectedRouteIndex, +}: RouteMapBackgroundProps) { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (!mapContainerRef.current || !MAPBOX_TOKEN) return; + + // Initialize map + mapboxgl.accessToken = MAPBOX_TOKEN; + const map = new mapboxgl.Map({ + container: mapContainerRef.current, + style: "mapbox://styles/mapbox/light-v11", + center: [0, 0], + zoom: 2, + }); + + mapRef.current = map; + + map.on("load", () => { + // Add route sources and layers + for (let i = 0; i < 3; i++) { + map.addSource(`route-${i}`, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: [], + }, + }, + }); + + map.addLayer({ + id: `route-${i}`, + type: "line", + source: `route-${i}`, + layout: { + "line-join": "round", + "line-cap": "round", + }, + paint: { + "line-color": "#2bee6c", + "line-width": 4, + "line-opacity": 0.5, + }, + }); + } + }); + + return () => { + map.remove(); + }; + }, []); + + // Update routes on map + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + // Update route data + routes.forEach((route, index) => { + const source = map.getSource(`route-${index}`) as mapboxgl.GeoJSONSource; + if (source) { + source.setData({ + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: route.geometry.coordinates, + }, + }); + } + }); + + // Fit map to show all routes + if (routes.length > 0 && routes[0].geometry.coordinates.length > 0) { + const coordinates = routes[0].geometry.coordinates; + const bounds = coordinates.reduce( + (bounds, coord) => bounds.extend(coord as [number, number]), + new mapboxgl.LngLatBounds( + coordinates[0] as [number, number], + coordinates[0] as [number, number] + ) + ); + + map.fitBounds(bounds, { + padding: { top: 100, bottom: 100, left: 450, right: 450 }, + duration: 1000, + }); + } + }, [routes]); + + // Update route styles based on selected route + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + // Update line styles for all routes + for (let i = 0; i < 3; i++) { + if (map.getLayer(`route-${i}`)) { + map.setPaintProperty( + `route-${i}`, + "line-color", + i === selectedRouteIndex ? "#2bee6c" : "#94a3b8" + ); + map.setPaintProperty( + `route-${i}`, + "line-width", + i === selectedRouteIndex ? 6 : 4 + ); + map.setPaintProperty( + `route-${i}`, + "line-opacity", + i === selectedRouteIndex ? 1 : 0.6 + ); + } + } + }, [selectedRouteIndex]); + + // Add markers for source and destination + useEffect(() => { + const map = mapRef.current; + if (!map || !source || !destination) return; + + // Remove existing markers + const existingMarkers = document.querySelectorAll(".mapboxgl-marker"); + existingMarkers.forEach((marker) => marker.remove()); + + // Add source marker (green) + new mapboxgl.Marker({ color: "#2bee6c" }) + .setLngLat([source.lng, source.lat]) + .addTo(map); + + // Add destination marker (red) + new mapboxgl.Marker({ color: "#ef4444" }) + .setLngLat([destination.lng, destination.lat]) + .addTo(map); + }, [source, destination]); + + return ( +
+
+
+ ); +} diff --git a/client/package-lock.json b/client/package-lock.json index 2639809..7633237 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,9 +14,11 @@ "lucide-react": "^0.563.0", "mapbox-gl": "^3.18.1", "next": "16.1.6", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -9135,6 +9137,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10799,6 +10811,16 @@ "dev": true, "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/client/package.json b/client/package.json index f3c027c..c5c3215 100644 --- a/client/package.json +++ b/client/package.json @@ -18,9 +18,11 @@ "lucide-react": "^0.563.0", "mapbox-gl": "^3.18.1", "next": "16.1.6", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, "devDependencies": { From b01b12921048db55da1b8f4c1ab3ced812b20551 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 20:22:53 +0530 Subject: [PATCH 08/30] feat1: implement route discovery, comparison, and map visualization with proper routing --- .../home/routes/(from)/(to)/page.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/client/app/(private)/home/routes/(from)/(to)/page.tsx b/client/app/(private)/home/routes/(from)/(to)/page.tsx index 07a0a6c..555bd76 100644 --- a/client/app/(private)/home/routes/(from)/(to)/page.tsx +++ b/client/app/(private)/home/routes/(from)/(to)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; @@ -38,7 +38,7 @@ type MapboxRoute = { const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; -const RoutePage = () => { +const RouteContent = () => { const searchParams = useSearchParams(); const [source, setSource] = useState(null); const [destination, setDestination] = useState(null); @@ -183,4 +183,21 @@ const RoutePage = () => { ); }; -export default RoutePage; +export default function RoutePage() { + return ( + +
+
+

+ Loading route parameters... +

+
+
+ } + > + +
+ ); +} From 4ab28ec44af1ad51f8c857b78b7b153107494399 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 20:50:31 +0530 Subject: [PATCH 09/30] feat: Implement route discovery and comparison features with pollution insights and map integration. --- .../home/routes/(from)/(to)/page.tsx | 100 +++++++- client/components/home/HomeMap.tsx | 13 +- client/components/routes/InsightToast.tsx | 26 +- client/components/routes/MapControls.tsx | 28 ++- .../routes/RouteComparisonPanel.tsx | 37 ++- .../components/routes/RouteDiscoveryPanel.tsx | 34 ++- .../components/routes/RouteMapBackground.tsx | 237 ++++++++++++------ 7 files changed, 358 insertions(+), 117 deletions(-) diff --git a/client/app/(private)/home/routes/(from)/(to)/page.tsx b/client/app/(private)/home/routes/(from)/(to)/page.tsx index 555bd76..bf7048e 100644 --- a/client/app/(private)/home/routes/(from)/(to)/page.tsx +++ b/client/app/(private)/home/routes/(from)/(to)/page.tsx @@ -23,6 +23,9 @@ type RouteData = { coordinates: [number, number][]; type: string; }; + aqiScore?: number; + pollutionReductionPct?: number; + exposureWarning?: string; }; type TravelMode = "walking" | "driving" | "cycling"; @@ -38,6 +41,13 @@ type MapboxRoute = { const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; +if (!MAPBOX_TOKEN) { + const warning = `BreathClean Dev Warning: MAPBOX_TOKEN is empty (value: "${MAPBOX_TOKEN}"). Mapbox API calls will fail.`; + if (process.env.NODE_ENV !== "production") { + console.warn(warning); + } +} + const RouteContent = () => { const searchParams = useSearchParams(); const [source, setSource] = useState(null); @@ -77,17 +87,34 @@ const RouteContent = () => { // Reverse geocode to get address from coordinates const reverseGeocode = async (lng: number, lat: number): Promise => { + if (!MAPBOX_TOKEN) { + console.error( + `Aborting reverseGeocode: MAPBOX_TOKEN is missing (value: "${MAPBOX_TOKEN}").` + ); + return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + } + try { const response = await fetch( `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${MAPBOX_TOKEN}` ); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = `Geocoding API Error: ${response.status} ${response.statusText}`; + console.error(`${errorMsg} | Body: ${errorText}`); + // Return clear error string to caller instead of fallback coordinates + return `${errorMsg}`; + } + const data = await response.json(); if (data.features && data.features.length > 0) { return data.features[0].place_name; } return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; } catch (error) { - console.error("Geocoding failed:", error); + // Network/Runtime errors: Log and return fallback coordinates + console.error("Geocoding network/runtime error:", error); return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; } }; @@ -96,6 +123,13 @@ const RouteContent = () => { const fetchRoutes = useCallback( async (mode: TravelMode) => { if (!source || !destination) return; + if (!MAPBOX_TOKEN) { + setError("Mapbox configuration error: Missing Token"); + console.error( + `Aborting fetchRoutes: MAPBOX_TOKEN is missing (value: "${MAPBOX_TOKEN}").` + ); + return; + } setIsLoading(true); setError(null); @@ -108,17 +142,60 @@ const RouteContent = () => { const url = `https://api.mapbox.com/directions/v5/${profile}/${coordinates}?alternatives=true&geometries=geojson&overview=full&steps=true&access_token=${MAPBOX_TOKEN}`; const response = await fetch(url); + + // Check for HTTP errors before parsing JSON + if (!response.ok) { + let errorDetails = response.statusText; + try { + const errorClone = response.clone(); + const errorJson = await errorClone.json(); + errorDetails = JSON.stringify(errorJson); + } catch { + errorDetails = await response.text(); + } + + console.error( + `FetchRoutes HTTP error: ${response.status} ${response.statusText}`, + errorDetails + ); + setError( + `Route fetch failed: ${response.status} ${response.statusText}` + ); + setRoutes([]); + return; + } + const data = await response.json(); if (data.code === "Ok" && data.routes && data.routes.length > 0) { // Take up to 3 routes const fetchedRoutes = data.routes .slice(0, 3) - .map((route: MapboxRoute) => ({ - distance: route.distance, - duration: route.duration, - geometry: route.geometry, - })); + .map((route: MapboxRoute, index: number) => { + // Placeholder/Demo data logic + let aqiScore = 80; + let pollutionReductionPct: number | undefined = undefined; + let exposureWarning: string | undefined = undefined; + + if (index === 0) { + aqiScore = 92; + pollutionReductionPct = 34; + } else if (index === 1) { + aqiScore = 74; + } else { + aqiScore = 42; + exposureWarning = "High PM2.5 Exposure Zone"; + } + + return { + distance: route.distance, + duration: route.duration, + geometry: route.geometry, + aqiScore, + pollutionReductionPct, + exposureWarning, + }; + }); setRoutes(fetchedRoutes); } else { setError("No routes found. Please try different locations."); @@ -155,6 +232,11 @@ const RouteContent = () => { return (
+ {!MAPBOX_TOKEN && process.env.NODE_ENV !== "production" && ( +
+ DEV WARNING: MAPBOX_TOKEN is missing! +
+ )}
{ selectedRouteIndex={selectedRouteIndex} onRouteSelect={handleRouteSelect} /> - + {!isLoading && !error && routes.length > 0 && ( + + )}
diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx index 741bb9c..2c54cc6 100644 --- a/client/components/home/HomeMap.tsx +++ b/client/components/home/HomeMap.tsx @@ -53,6 +53,7 @@ export default function HomeMap({ className }: HomeMapProps) { const [isLocating, setIsLocating] = useState(!!mapboxToken); const [mapError, setMapError] = useState(null); const [geoError, setGeoError] = useState(null); + const [routeError, setRouteError] = useState(null); // Selection States const [sourceLocation, setSourceLocation] = useState( @@ -121,12 +122,13 @@ export default function HomeMap({ className }: HomeMapProps) { }; const handleFindRoute = () => { + setRouteError(null); if (!sourceLocation) { - console.log("Please select a starting location."); + setRouteError("Please select a starting location."); return; } if (!destLocation) { - console.log("Please select a destination."); + setRouteError("Please select a destination."); return; } @@ -134,7 +136,7 @@ export default function HomeMap({ className }: HomeMapProps) { sourceLocation.lng === destLocation.lng && sourceLocation.lat === destLocation.lat ) { - console.log("Start and destination cannot be the same."); + setRouteError("Start and destination cannot be the same."); return; } @@ -615,6 +617,11 @@ export default function HomeMap({ className }: HomeMapProps) { Find Cleanest Route + {routeError && ( +
+ {routeError} +
+ )}
{geoError && ( diff --git a/client/components/routes/InsightToast.tsx b/client/components/routes/InsightToast.tsx index 4752bb8..62e7639 100644 --- a/client/components/routes/InsightToast.tsx +++ b/client/components/routes/InsightToast.tsx @@ -1,8 +1,25 @@ "use client"; +import { useState } from "react"; + import { X } from "lucide-react"; -export default function InsightToast() { +export default function InsightToast({ + pm25Reduction = 30, + onClose, +}: { + pm25Reduction?: number; + onClose?: () => void; +}) { + const [isVisible, setIsVisible] = useState(true); + + if (!isVisible) return null; + + const handleClose = () => { + setIsVisible(false); + if (onClose) onClose(); + }; + return (
@@ -10,11 +27,14 @@ export default function InsightToast() {

Health Insight:{" "} - This route reduces PM2.5 exposure by 30% + This route reduces PM2.5 exposure by {pm25Reduction}% {" "} compared to your last trip.

-
diff --git a/client/components/routes/MapControls.tsx b/client/components/routes/MapControls.tsx index 2752b44..3c88ea3 100644 --- a/client/components/routes/MapControls.tsx +++ b/client/components/routes/MapControls.tsx @@ -2,16 +2,36 @@ import { LocateFixed, Minus, Plus } from "lucide-react"; -export default function MapControls() { +export default function MapControls({ + onZoomIn, + onZoomOut, + onLocate, +}: { + onZoomIn?: () => void; + onZoomOut?: () => void; + onLocate?: () => void; +}) { return (
- - -
diff --git a/client/components/routes/RouteComparisonPanel.tsx b/client/components/routes/RouteComparisonPanel.tsx index 07f6fe5..c0169a1 100644 --- a/client/components/routes/RouteComparisonPanel.tsx +++ b/client/components/routes/RouteComparisonPanel.tsx @@ -4,13 +4,16 @@ import { AlertTriangle, Ruler, Timer } from "lucide-react"; type TravelMode = "walking" | "driving" | "cycling"; -type RouteData = { +export type RouteData = { distance: number; duration: number; geometry: { coordinates: [number, number][]; type: string; }; + aqiScore?: number; + pollutionReductionPct?: number; + exposureWarning?: string; }; type RouteComparisonPanelProps = { @@ -45,6 +48,16 @@ export default function RouteComparisonPanel({ return `${km} km`; }; + const getRouteLabel = (route: RouteData, allRoutes: RouteData[]) => { + // Find max AQI and min Duration + const maxAqi = Math.max(...allRoutes.map((r) => r.aqiScore || 0)); + const minDuration = Math.min(...allRoutes.map((r) => r.duration)); + + if (route.aqiScore === maxAqi && maxAqi > 0) return "Cleanest Path"; + if (route.duration === minDuration) return "Fastest"; + return "Balanced"; + }; + return (
diff --git a/client/components/routes/RouteDiscoveryPanel.tsx b/client/components/routes/RouteDiscoveryPanel.tsx index 7182e55..4e9d237 100644 --- a/client/components/routes/RouteDiscoveryPanel.tsx +++ b/client/components/routes/RouteDiscoveryPanel.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; + import { useRouter } from "next/navigation"; import { Activity, Bike, Car, CircleDot, MapPin, User } from "lucide-react"; @@ -11,7 +13,7 @@ type RouteDiscoveryPanelProps = { destAddress: string; selectedMode: TravelMode; onModeChange: (mode: TravelMode) => void; - onFindRoutes?: () => void; + onAvoidBusyRoadsChange?: (avoid: boolean) => void; }; export default function RouteDiscoveryPanel({ @@ -19,9 +21,18 @@ export default function RouteDiscoveryPanel({ destAddress, selectedMode, onModeChange, - onFindRoutes, + onAvoidBusyRoadsChange, }: RouteDiscoveryPanelProps) { const router = useRouter(); + const [avoidBusyRoads, setAvoidBusyRoads] = useState(false); + + const toggleAvoidBusyRoads = () => { + const newState = !avoidBusyRoads; + setAvoidBusyRoads(newState); + if (onAvoidBusyRoadsChange) { + onAvoidBusyRoadsChange(newState); + } + }; const handleChangeRoute = () => { router.push("/home"); @@ -129,9 +140,22 @@ export default function RouteDiscoveryPanel({ Avoid Busy Roads -
-
-
+
diff --git a/client/components/routes/RouteMapBackground.tsx b/client/components/routes/RouteMapBackground.tsx index f4614ad..fa25e1e 100644 --- a/client/components/routes/RouteMapBackground.tsx +++ b/client/components/routes/RouteMapBackground.tsx @@ -36,6 +36,10 @@ export default function RouteMapBackground({ }: RouteMapBackgroundProps) { const mapContainerRef = useRef(null); const mapRef = useRef(null); + const markersRef = useRef<{ + source: mapboxgl.Marker | null; + dest: mapboxgl.Marker | null; + }>({ source: null, dest: null }); useEffect(() => { if (!mapContainerRef.current || !MAPBOX_TOKEN) return; @@ -52,35 +56,7 @@ export default function RouteMapBackground({ mapRef.current = map; map.on("load", () => { - // Add route sources and layers - for (let i = 0; i < 3; i++) { - map.addSource(`route-${i}`, { - type: "geojson", - data: { - type: "Feature", - properties: {}, - geometry: { - type: "LineString", - coordinates: [], - }, - }, - }); - - map.addLayer({ - id: `route-${i}`, - type: "line", - source: `route-${i}`, - layout: { - "line-join": "round", - "line-cap": "round", - }, - paint: { - "line-color": "#2bee6c", - "line-width": 4, - "line-opacity": 0.5, - }, - }); - } + // Map loaded }); return () => { @@ -91,86 +67,181 @@ export default function RouteMapBackground({ // Update routes on map useEffect(() => { const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - // Update route data - routes.forEach((route, index) => { - const source = map.getSource(`route-${index}`) as mapboxgl.GeoJSONSource; - if (source) { - source.setData({ - type: "Feature", - properties: {}, - geometry: { - type: "LineString", - coordinates: route.geometry.coordinates, - }, - }); + if (!map) return; + + const updateRoutes = () => { + // Clean up stale layers/sources + // we check all previously tracked layers or just iterate widely if we didn't track them before (but we added tracking now) + // For safety, let's remove any layer starting with route- that is beyond our current count, or just rebuild keys. + + // First, remove layers that are no longer needed or refresh all + // The prompt asks to "base the loop on actual routes array length... and remove any stale layers" + // Let's assume we maintain route-{i} + + // Cleanup layers/sources that exceed current routes length + // We can check strictly locally based on index + // But simpler: ensure route-i exists for i < length, and remove for i >= length + let i = 0; + while (true) { + const id = `route-${i}`; + const exists = map.getSource(id); + if (!exists && i >= routes.length) break; // No more layers to check + + if (i >= routes.length) { + // Remove stale + if (map.getLayer(id)) map.removeLayer(id); + if (map.getSource(id)) map.removeSource(id); + } else { + // Create or Update + const route = routes[i]; + if (!map.getSource(id)) { + map.addSource(id, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: route.geometry.coordinates as [number, number][], + }, + }, + }); + map.addLayer({ + id: id, + type: "line", + source: id, + layout: { + "line-join": "round", + "line-cap": "round", + }, + paint: { + "line-color": "#2bee6c", + "line-width": 4, + "line-opacity": 0.5, + }, + }); + } else { + (map.getSource(id) as mapboxgl.GeoJSONSource).setData({ + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: route.geometry.coordinates as [number, number][], + }, + }); + } + } + i++; } - }); - // Fit map to show all routes - if (routes.length > 0 && routes[0].geometry.coordinates.length > 0) { - const coordinates = routes[0].geometry.coordinates; - const bounds = coordinates.reduce( - (bounds, coord) => bounds.extend(coord as [number, number]), - new mapboxgl.LngLatBounds( + // Fit map + if (routes.length > 0 && routes[0].geometry.coordinates.length > 0) { + const coordinates = routes[0].geometry.coordinates; // Use primary route for bounds + // Fix: Use LngLatBounds properly + const bounds = new mapboxgl.LngLatBounds( coordinates[0] as [number, number], coordinates[0] as [number, number] - ) - ); + ); - map.fitBounds(bounds, { - padding: { top: 100, bottom: 100, left: 450, right: 450 }, - duration: 1000, - }); + // We can traverse all routes to get full bounds if desired, + // but using the first route is usually sufficient or we can extend with others. + routes.forEach((r) => { + r.geometry.coordinates.forEach((coord) => { + bounds.extend(coord as [number, number]); + }); + }); + + map.fitBounds(bounds, { + padding: { top: 100, bottom: 100, left: 450, right: 450 }, + duration: 1000, + }); + } + }; + + if (map.isStyleLoaded()) { + updateRoutes(); + } else { + map.once("styledata", updateRoutes); } + + // Cleanup listener if effect re-runs (mostly relevant if we had a persistent listener) + return () => { + map.off("styledata", updateRoutes); + }; }, [routes]); // Update route styles based on selected route useEffect(() => { const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - // Update line styles for all routes - for (let i = 0; i < 3; i++) { - if (map.getLayer(`route-${i}`)) { - map.setPaintProperty( - `route-${i}`, - "line-color", - i === selectedRouteIndex ? "#2bee6c" : "#94a3b8" - ); - map.setPaintProperty( - `route-${i}`, - "line-width", - i === selectedRouteIndex ? 6 : 4 - ); - map.setPaintProperty( - `route-${i}`, - "line-opacity", - i === selectedRouteIndex ? 1 : 0.6 - ); - } + if (!map) return; + + const updateStyles = () => { + // We iterate based on routes length, or check existence + // Assuming we created up to routes.length-1 + // But to be safe, we can check 0..10 or just loop routes. + // Let's loop routes length as layers should correspond + routes.forEach((_, i) => { + const layerId = `route-${i}`; + if (map.getLayer(layerId)) { + map.setPaintProperty( + layerId, + "line-color", + i === selectedRouteIndex ? "#2bee6c" : "#94a3b8" + ); + map.setPaintProperty( + layerId, + "line-width", + i === selectedRouteIndex ? 6 : 4 + ); + map.setPaintProperty( + layerId, + "line-opacity", + i === selectedRouteIndex ? 1 : 0.6 + ); + } + }); + }; + + if (map.isStyleLoaded()) { + updateStyles(); + } else { + map.once("styledata", updateStyles); } - }, [selectedRouteIndex]); + + return () => { + map.off("styledata", updateStyles); + }; + }, [selectedRouteIndex, routes]); // Add markers for source and destination useEffect(() => { const map = mapRef.current; if (!map || !source || !destination) return; - // Remove existing markers - const existingMarkers = document.querySelectorAll(".mapboxgl-marker"); - existingMarkers.forEach((marker) => marker.remove()); + // Capture current ref value to use in cleanup + const currentMarkers = markersRef.current; + + // Clean up existing markers + if (currentMarkers.source) currentMarkers.source.remove(); + if (currentMarkers.dest) currentMarkers.dest.remove(); // Add source marker (green) - new mapboxgl.Marker({ color: "#2bee6c" }) + const sourceMarker = new mapboxgl.Marker({ color: "#2bee6c" }) .setLngLat([source.lng, source.lat]) .addTo(map); + currentMarkers.source = sourceMarker; // Add destination marker (red) - new mapboxgl.Marker({ color: "#ef4444" }) + const destMarker = new mapboxgl.Marker({ color: "#ef4444" }) .setLngLat([destination.lng, destination.lat]) .addTo(map); + currentMarkers.dest = destMarker; + + // Cleanup on unmount (optional but good practice) + return () => { + if (currentMarkers.source) currentMarkers.source.remove(); + if (currentMarkers.dest) currentMarkers.dest.remove(); + }; }, [source, destination]); return ( From a8da1032888df13f1f37f443ad6cf67f5210d47e Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 22:06:59 +0530 Subject: [PATCH 10/30] feat: Implement route discovery, comparison, and saving features with Mapbox integration. --- .../home/routes/(from)/(to)/page.tsx | 132 +++++++++++++----- client/app/layout.tsx | 6 +- .../components/routes/RouteDiscoveryPanel.tsx | 90 ++++++------ .../saved-routes/RouteInsightsPanel.tsx | 10 ++ .../saved-routes/SavedRouteCard.tsx | 14 +- .../saved-routes/SavedRoutesList.tsx | 3 + .../saved-routes/SavedRoutesView.tsx | 52 ++++++- client/components/ui/sonner.tsx | 41 ++++++ server/src/Schema/route.schema.ts | 5 + .../controllers/savedRoutes.controllers.ts | 1 - server/src/index.ts | 4 +- 11 files changed, 272 insertions(+), 86 deletions(-) create mode 100644 client/components/ui/sonner.tsx diff --git a/client/app/(private)/home/routes/(from)/(to)/page.tsx b/client/app/(private)/home/routes/(from)/(to)/page.tsx index bf7048e..56444fa 100644 --- a/client/app/(private)/home/routes/(from)/(to)/page.tsx +++ b/client/app/(private)/home/routes/(from)/(to)/page.tsx @@ -4,6 +4,9 @@ import { Suspense, useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { Bookmark } from "lucide-react"; +import { toast } from "sonner"; + import InsightToast from "@/components/routes/InsightToast"; import MapControls from "@/components/routes/MapControls"; import RouteComparisonPanel from "@/components/routes/RouteComparisonPanel"; @@ -42,7 +45,8 @@ type MapboxRoute = { const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; if (!MAPBOX_TOKEN) { - const warning = `BreathClean Dev Warning: MAPBOX_TOKEN is empty (value: "${MAPBOX_TOKEN}"). Mapbox API calls will fail.`; + const warning = + "BreathClean Dev Warning: MAPBOX_TOKEN is empty. Mapbox API calls will fail."; if (process.env.NODE_ENV !== "production") { console.warn(warning); } @@ -59,6 +63,7 @@ const RouteContent = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedRouteIndex, setSelectedRouteIndex] = useState(0); + const [routeName, setRouteName] = useState(""); // Parse query parameters useEffect(() => { @@ -88,9 +93,7 @@ const RouteContent = () => { // Reverse geocode to get address from coordinates const reverseGeocode = async (lng: number, lat: number): Promise => { if (!MAPBOX_TOKEN) { - console.error( - `Aborting reverseGeocode: MAPBOX_TOKEN is missing (value: "${MAPBOX_TOKEN}").` - ); + console.error("Aborting reverseGeocode: MAPBOX_TOKEN is missing."); return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; } @@ -100,11 +103,10 @@ const RouteContent = () => { ); if (!response.ok) { - const errorText = await response.text(); - const errorMsg = `Geocoding API Error: ${response.status} ${response.statusText}`; - console.error(`${errorMsg} | Body: ${errorText}`); - // Return clear error string to caller instead of fallback coordinates - return `${errorMsg}`; + const errorBody = await response.text(); + throw new Error( + `Geocoding HTTP error: ${response.status} ${response.statusText} | Body: ${errorBody}` + ); } const data = await response.json(); @@ -113,8 +115,9 @@ const RouteContent = () => { } return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; } catch (error) { - // Network/Runtime errors: Log and return fallback coordinates - console.error("Geocoding network/runtime error:", error); + // Only swallow network/runtime errors so we return coordinates as fallback + // ideally we might want to surface this, but the UI expects a string + console.error("Geocoding failed:", error); return `${lat.toFixed(4)}, ${lng.toFixed(4)}`; } }; @@ -125,9 +128,7 @@ const RouteContent = () => { if (!source || !destination) return; if (!MAPBOX_TOKEN) { setError("Mapbox configuration error: Missing Token"); - console.error( - `Aborting fetchRoutes: MAPBOX_TOKEN is missing (value: "${MAPBOX_TOKEN}").` - ); + console.error("Aborting fetchRoutes: MAPBOX_TOKEN is missing."); return; } @@ -143,20 +144,11 @@ const RouteContent = () => { const response = await fetch(url); - // Check for HTTP errors before parsing JSON if (!response.ok) { - let errorDetails = response.statusText; - try { - const errorClone = response.clone(); - const errorJson = await errorClone.json(); - errorDetails = JSON.stringify(errorJson); - } catch { - errorDetails = await response.text(); - } - + const errorText = await response.text(); console.error( `FetchRoutes HTTP error: ${response.status} ${response.statusText}`, - errorDetails + errorText ); setError( `Route fetch failed: ${response.status} ${response.statusText}` @@ -230,13 +222,74 @@ const RouteContent = () => { setSelectedRouteIndex(index); }; + // Save route function + const saveRoute = async () => { + if (!source || !destination || routes.length === 0) return; + + const nameToSave = routeName.trim() || "Best Route"; + + try { + const payload = { + name: nameToSave, + from: { + address: sourceAddress, + location: { + type: "Point", + coordinates: [source.lng, source.lat], + }, + }, + to: { + address: destAddress, + location: { + type: "Point", + coordinates: [destination.lng, destination.lat], + }, + }, + routes: [ + { + distance: routes[selectedRouteIndex].distance, + duration: routes[selectedRouteIndex].duration, + routeGeometry: routes[selectedRouteIndex].geometry, + lastComputedScore: + routes[selectedRouteIndex].aqiScore || + Math.floor(Math.random() * 100), + lastComputedAt: new Date(), + travelMode: selectedMode, + }, + ], + isFavorite: false, + }; + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/saved-routes`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + } + ); + + const data = await response.json(); + console.log("Save route response:", data); + + if (!response.ok) { + console.error("Save route failed:", data); + toast.error(data.message || "Failed to save route"); + return; + } + + toast.success("Route saved successfully!"); + } catch (error) { + console.error("Save route error:", error); + toast.error("An error occurred while saving the route"); + } + }; + return (
- {!MAPBOX_TOKEN && process.env.NODE_ENV !== "production" && ( -
- DEV WARNING: MAPBOX_TOKEN is missing! -
- )}
{ destAddress={destAddress} selectedMode={selectedMode} onModeChange={handleModeChange} + routeName={routeName} + onRouteNameChange={setRouteName} /> { selectedRouteIndex={selectedRouteIndex} onRouteSelect={handleRouteSelect} /> + + + + {/* Save Route Button - Bottom Right */} {!isLoading && !error && routes.length > 0 && ( - + )} -
); diff --git a/client/app/layout.tsx b/client/app/layout.tsx index f58e296..9a08148 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { Outfit } from "next/font/google"; -import "./globals.css"; +import { toast, Toaster } from "sonner"; -// import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; const outfit = Outfit({ variable: "--font-outfit", @@ -51,7 +51,7 @@ export default function RootLayout({ {children} - {/* */} + ); diff --git a/client/components/routes/RouteDiscoveryPanel.tsx b/client/components/routes/RouteDiscoveryPanel.tsx index 4e9d237..6c4fe76 100644 --- a/client/components/routes/RouteDiscoveryPanel.tsx +++ b/client/components/routes/RouteDiscoveryPanel.tsx @@ -4,7 +4,16 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Activity, Bike, Car, CircleDot, MapPin, User } from "lucide-react"; +import { + Activity, + Bike, + Car, + CircleDot, + Info, + MapPin, + Tag, + User, +} from "lucide-react"; type TravelMode = "walking" | "driving" | "cycling"; @@ -13,7 +22,8 @@ type RouteDiscoveryPanelProps = { destAddress: string; selectedMode: TravelMode; onModeChange: (mode: TravelMode) => void; - onAvoidBusyRoadsChange?: (avoid: boolean) => void; + routeName: string; + onRouteNameChange: (name: string) => void; }; export default function RouteDiscoveryPanel({ @@ -21,18 +31,10 @@ export default function RouteDiscoveryPanel({ destAddress, selectedMode, onModeChange, - onAvoidBusyRoadsChange, + routeName, + onRouteNameChange, }: RouteDiscoveryPanelProps) { const router = useRouter(); - const [avoidBusyRoads, setAvoidBusyRoads] = useState(false); - - const toggleAvoidBusyRoads = () => { - const newState = !avoidBusyRoads; - setAvoidBusyRoads(newState); - if (onAvoidBusyRoadsChange) { - onAvoidBusyRoadsChange(newState); - } - }; const handleChangeRoute = () => { router.push("/home"); @@ -52,9 +54,39 @@ export default function RouteDiscoveryPanel({
{/* From/To Inputs */} -
- {/* Dashed line connecting inputs */} -
+ {/* Inputs Section */} +
+ {/* Route Name Input */} +
+
+ +
+ +
+ This name will be used when you save the route to your + favorites. +
+
+
+
+ + onRouteNameChange(e.target.value)} + /> +
+
+ + {/* Dashed line connecting inputs (Adjusted top) */} +
- - {/* Filter Preferences */} -
- -
- - Avoid Busy Roads - - -
-
diff --git a/client/components/saved-routes/RouteInsightsPanel.tsx b/client/components/saved-routes/RouteInsightsPanel.tsx index 000057d..33fcd5d 100644 --- a/client/components/saved-routes/RouteInsightsPanel.tsx +++ b/client/components/saved-routes/RouteInsightsPanel.tsx @@ -8,6 +8,7 @@ import { Route, Ruler, ShieldCheck, + Trash2, Wind, } from "lucide-react"; @@ -18,6 +19,7 @@ import type { ISavedRoute } from "./types"; interface RouteInsightsPanelProps { route: ISavedRoute; subRouteIndex: number; + onDelete: (routeId: string) => void; } function getAqiBadge(score: number | null | undefined) { @@ -46,6 +48,7 @@ function formatDistance(km: number) { export default function RouteInsightsPanel({ route, subRouteIndex, + onDelete, }: RouteInsightsPanelProps) { const subRoute = route.routes[subRouteIndex]; const aqi = getAqiBadge(subRoute.lastComputedScore); @@ -242,6 +245,13 @@ export default function RouteInsightsPanel({ Start Route +
); diff --git a/client/components/saved-routes/SavedRouteCard.tsx b/client/components/saved-routes/SavedRouteCard.tsx index c5501a3..d427c81 100644 --- a/client/components/saved-routes/SavedRouteCard.tsx +++ b/client/components/saved-routes/SavedRouteCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Clock, MapPin, Star } from "lucide-react"; +import { Clock, MapPin, Star, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -12,6 +12,7 @@ interface SavedRouteCardProps { selectedSubRouteIndex: number; onSelect: (routeId: string) => void; onSubRouteSelect: (index: number) => void; + onDelete: (routeId: string) => void; } function getAqiColor(score: number | null | undefined) { @@ -47,6 +48,7 @@ export default function SavedRouteCard({ selectedSubRouteIndex, onSelect, onSubRouteSelect, + onDelete, }: SavedRouteCardProps) { const bestIdx = route.routes.reduce( (best, r, i) => { @@ -76,6 +78,16 @@ export default function SavedRouteCard({ )}
+
{route.routes.length} route{route.routes.length > 1 ? "s" : ""} diff --git a/client/components/saved-routes/SavedRoutesList.tsx b/client/components/saved-routes/SavedRoutesList.tsx index fdf24aa..64cac49 100644 --- a/client/components/saved-routes/SavedRoutesList.tsx +++ b/client/components/saved-routes/SavedRoutesList.tsx @@ -11,6 +11,7 @@ interface SavedRoutesListProps { selectedSubRouteIndex: number; onSelectRoute: (routeId: string) => void; onSelectSubRoute: (index: number) => void; + onDeleteRoute: (routeId: string) => void; } export default function SavedRoutesList({ @@ -19,6 +20,7 @@ export default function SavedRoutesList({ selectedSubRouteIndex, onSelectRoute, onSelectSubRoute, + onDeleteRoute, }: SavedRoutesListProps) { return (
@@ -43,6 +45,7 @@ export default function SavedRoutesList({ } onSelect={onSelectRoute} onSubRouteSelect={onSelectSubRoute} + onDelete={onDeleteRoute} /> ))}
diff --git a/client/components/saved-routes/SavedRoutesView.tsx b/client/components/saved-routes/SavedRoutesView.tsx index fc0ea14..9695c21 100644 --- a/client/components/saved-routes/SavedRoutesView.tsx +++ b/client/components/saved-routes/SavedRoutesView.tsx @@ -2,6 +2,10 @@ import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { toast } from "sonner"; + import EmptyState from "./EmptyState"; import RouteInsightsPanel from "./RouteInsightsPanel"; import RouteMap from "./RouteMap"; @@ -12,9 +16,13 @@ interface SavedRoutesViewProps { routes: ISavedRoute[]; } -export default function SavedRoutesView({ routes }: SavedRoutesViewProps) { +export default function SavedRoutesView({ + routes: initialRoutes, +}: SavedRoutesViewProps) { + const router = useRouter(); + const [routes, setRoutes] = useState(initialRoutes); const [selectedRouteId, setSelectedRouteId] = useState( - routes.length > 0 ? routes[0]._id : null + initialRoutes.length > 0 ? initialRoutes[0]._id : null ); const [selectedSubRouteIndex, setSelectedSubRouteIndex] = useState(0); @@ -23,6 +31,44 @@ export default function SavedRoutesView({ routes }: SavedRoutesViewProps) { setSelectedSubRouteIndex(0); }; + const handleDeleteRoute = async (routeId: string) => { + // Optimistic update + const previousRoutes = [...routes]; + setRoutes((prev) => prev.filter((r) => r._id !== routeId)); + + // If the deleted route was selected, select the first available one + if (selectedRouteId === routeId) { + const remaining = routes.filter((r) => r._id !== routeId); + setSelectedRouteId(remaining.length > 0 ? remaining[0]._id : null); + } + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/saved-routes/${routeId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + } + ); + + if (!res.ok) { + throw new Error("Failed to delete route"); + } + + toast.success("Route deleted successfully"); + router.refresh(); // Sync with server + } catch (error) { + console.error("Delete route error:", error); + toast.error("Failed to delete route"); + // Revert optimistic update + setRoutes(previousRoutes); + if (selectedRouteId === routeId) setSelectedRouteId(routeId); + } + }; + const selectedRoute = routes.find((r) => r._id === selectedRouteId) ?? null; return ( @@ -39,6 +85,7 @@ export default function SavedRoutesView({ routes }: SavedRoutesViewProps) { selectedSubRouteIndex={selectedSubRouteIndex} onSelectRoute={handleSelectRoute} onSelectSubRoute={setSelectedSubRouteIndex} + onDeleteRoute={handleDeleteRoute} /> {/* Center - map */} @@ -56,6 +103,7 @@ export default function SavedRoutesView({ routes }: SavedRoutesViewProps) {
)} diff --git a/client/components/ui/sonner.tsx b/client/components/ui/sonner.tsx new file mode 100644 index 0000000..246069f --- /dev/null +++ b/client/components/ui/sonner.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTheme } from "next-themes"; + +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/server/src/Schema/route.schema.ts b/server/src/Schema/route.schema.ts index f0936c9..4cacbbc 100644 --- a/server/src/Schema/route.schema.ts +++ b/server/src/Schema/route.schema.ts @@ -113,6 +113,11 @@ const routeOptionSchema = new Schema( type: Date, default: null, }, + travelMode: { + type: String, + enum: ["walking", "cycling", "driving"], + required: true, + }, }, { _id: false } ); diff --git a/server/src/controllers/savedRoutes.controllers.ts b/server/src/controllers/savedRoutes.controllers.ts index ede97af..09c9ba6 100644 --- a/server/src/controllers/savedRoutes.controllers.ts +++ b/server/src/controllers/savedRoutes.controllers.ts @@ -27,7 +27,6 @@ export const fetchSavedRoutes = async ( export const saveRoute = async (req: Request, res: Response): Promise => { try { const userId = req.userId; - const { name, from, to, routes, isFavorite } = req.body; if (!from || !to || !routes || routes.length === 0) { diff --git a/server/src/index.ts b/server/src/index.ts index aa79541..55e48a6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,8 +16,8 @@ app.use( credentials: true, }) ); -app.use(express.json({ limit: "16kb" })); -app.use(express.urlencoded({ extended: true, limit: "16kb" })); +app.use(express.json({ limit: "50mb" })); +app.use(express.urlencoded({ extended: true, limit: "50mb" })); app.use(cookieParser()); app.use("/api/v1/auth", authRoutes); From 24f4a943cee27d5a451c1d29851e71704bf1a369 Mon Sep 17 00:00:00 2001 From: Gurudas Bhardwaj Date: Sat, 14 Feb 2026 23:38:36 +0530 Subject: [PATCH 11/30] feat: Add user profile page with detailed card, display saved routes, and introduce a route discovery panel. --- .../home/routes/(from)/(to)/page.tsx | 35 ++--- client/app/(private)/profile/page.tsx | 20 ++- client/app/layout.tsx | 2 +- client/components/home/HomeMap.tsx | 7 +- client/components/profile/ProfileCard.tsx | 2 + .../profile/SavedRouteItemClient.tsx | 18 +++ client/components/profile/SavedRoutes.tsx | 136 ++++++++++++------ .../components/routes/RouteDiscoveryPanel.tsx | 47 ++++-- .../saved-routes/SavedRoutesView.tsx | 2 +- 9 files changed, 179 insertions(+), 90 deletions(-) create mode 100644 client/components/profile/SavedRouteItemClient.tsx diff --git a/client/app/(private)/home/routes/(from)/(to)/page.tsx b/client/app/(private)/home/routes/(from)/(to)/page.tsx index 56444fa..4f64c4e 100644 --- a/client/app/(private)/home/routes/(from)/(to)/page.tsx +++ b/client/app/(private)/home/routes/(from)/(to)/page.tsx @@ -245,18 +245,14 @@ const RouteContent = () => { coordinates: [destination.lng, destination.lat], }, }, - routes: [ - { - distance: routes[selectedRouteIndex].distance, - duration: routes[selectedRouteIndex].duration, - routeGeometry: routes[selectedRouteIndex].geometry, - lastComputedScore: - routes[selectedRouteIndex].aqiScore || - Math.floor(Math.random() * 100), - lastComputedAt: new Date(), - travelMode: selectedMode, - }, - ], + routes: routes.map((route) => ({ + distance: route.distance / 1000, + duration: route.duration / 60, + routeGeometry: route.geometry, + lastComputedScore: route.aqiScore || Math.floor(Math.random() * 100), + lastComputedAt: new Date(), + travelMode: selectedMode, + })), isFavorite: false, }; @@ -304,6 +300,8 @@ const RouteContent = () => { onModeChange={handleModeChange} routeName={routeName} onRouteNameChange={setRouteName} + onSaveRoute={saveRoute} + canSave={!isLoading && !error && routes.length > 0} /> { /> - - {/* Save Route Button - Bottom Right */} - {!isLoading && !error && routes.length > 0 && ( - - )}

); diff --git a/client/app/(private)/profile/page.tsx b/client/app/(private)/profile/page.tsx index 9622b54..e7429af 100644 --- a/client/app/(private)/profile/page.tsx +++ b/client/app/(private)/profile/page.tsx @@ -1,3 +1,5 @@ +import { Suspense } from "react"; + import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -42,7 +44,23 @@ export default async function ProfilePage() {
- + +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ } + > + +
diff --git a/client/app/layout.tsx b/client/app/layout.tsx index 9a08148..02479ef 100644 --- a/client/app/layout.tsx +++ b/client/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Outfit } from "next/font/google"; -import { toast, Toaster } from "sonner"; +import { Toaster } from "sonner"; import "./globals.css"; diff --git a/client/components/home/HomeMap.tsx b/client/components/home/HomeMap.tsx index 2c54cc6..2ed5b4f 100644 --- a/client/components/home/HomeMap.tsx +++ b/client/components/home/HomeMap.tsx @@ -318,14 +318,15 @@ export default function HomeMap({ className }: HomeMapProps) { initMap(); + const markersInstance = markersRef.current; // Capture ref value + return () => { isCancelled = true; cleanupRef.current(); // Safe Cleanup - const markers = markersRef.current; - if (markers.source) markers.source.remove(); - if (markers.dest) markers.dest.remove(); + if (markersInstance.source) markersInstance.source.remove(); + if (markersInstance.dest) markersInstance.dest.remove(); mapRef.current = null; }; diff --git a/client/components/profile/ProfileCard.tsx b/client/components/profile/ProfileCard.tsx index 8ea2559..a544d58 100644 --- a/client/components/profile/ProfileCard.tsx +++ b/client/components/profile/ProfileCard.tsx @@ -26,6 +26,8 @@ export default function ProfileCard({ user }: { user: UserData }) {
{user.picture ? ( {user.name} + +
+ ); +} diff --git a/client/components/profile/SavedRoutes.tsx b/client/components/profile/SavedRoutes.tsx index 54e0c28..c34436f 100644 --- a/client/components/profile/SavedRoutes.tsx +++ b/client/components/profile/SavedRoutes.tsx @@ -1,75 +1,121 @@ -import { Clock, MapPin, Navigation } from "lucide-react"; +import { cookies } from "next/headers"; +import Link from "next/link"; -const savedRoutes = [ - { - name: "Home to Office", - from: "Connaught Place", - to: "Cyber City, Gurugram", - aqiScore: 62, - lastUsed: "Today", - }, - { - name: "Morning Jog Route", - from: "Lodhi Garden Gate 1", - to: "Lodhi Garden Gate 3", - aqiScore: 45, - lastUsed: "Yesterday", - }, - { - name: "Weekend Market", - from: "Sarojini Nagar Metro", - to: "Sarojini Nagar Market", - aqiScore: 89, - lastUsed: "3 days ago", - }, -]; +import { Clock, Inbox, MapPin } from "lucide-react"; + +import type { ISavedRoute } from "../saved-routes/types"; +import SavedRouteItemClient from "./SavedRouteItemClient"; function getAqiBadge(aqi: number) { - if (aqi <= 50) return { label: "Good", color: "bg-green-100 text-green-700" }; + if (aqi <= 50) + return { label: "Good", color: "bg-emerald-100 text-emerald-700" }; if (aqi <= 100) return { label: "Moderate", color: "bg-yellow-100 text-yellow-700" }; return { label: "Poor", color: "bg-red-100 text-red-700" }; } -export default function SavedRoutes() { +async function getTopRoutes(): Promise { + const cookieStore = await cookies(); + const refreshToken = cookieStore.get("refreshToken"); + + if (!refreshToken) return []; + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1/saved-routes`, + { + headers: { + Cookie: `refreshToken=${refreshToken.value}`, + }, + next: { revalidate: 0 }, // Ensure fresh data + } + ); + + if (!res.ok) return []; + const data = await res.json(); + return data.success && data.routes ? data.routes.slice(0, 3) : []; + } catch (error) { + console.error("Failed to fetch routes on server:", error); + return []; + } +} + +export default async function SavedRoutes() { + const routes = await getTopRoutes(); + + if (routes.length === 0) { + return ( +
+
+
+ +
+

+ No Routes Found +

+

+ You haven't saved any routes yet. Start exploring to find the + cleanest paths for your journey. +

+ + Find a Route + +
+
+ ); + } + return (

Saved Routes

- - {savedRoutes.length} routes + + Showing Top {routes.length}
- {savedRoutes.map((route) => { - const badge = getAqiBadge(route.aqiScore); + {routes.map((route) => { + const aqi = route.routes?.[0]?.lastComputedScore ?? 0; + const badge = getAqiBadge(aqi); + + const date = new Date(route.updatedAt); + const lastUsed = date.toLocaleDateString("en-IN", { + day: "numeric", + month: "short", + }); + return ( -
-
- -
+
-

{route.name}

-

+

+ {route.name || "Untitled Route"} +

+

- {route.from} → {route.to} + {route.from.address.split(",")[0]} →{" "} + {route.to.address.split(",")[0]}

-
+
- AQI {route.aqiScore} · {badge.label} + AQI {aqi} · {badge.label} - + - {route.lastUsed} + {lastUsed}
-
+ ); })}
diff --git a/client/components/routes/RouteDiscoveryPanel.tsx b/client/components/routes/RouteDiscoveryPanel.tsx index 6c4fe76..120755e 100644 --- a/client/components/routes/RouteDiscoveryPanel.tsx +++ b/client/components/routes/RouteDiscoveryPanel.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState } from "react"; - import { useRouter } from "next/navigation"; import { - Activity, Bike, + Bookmark, Car, + ChevronLeft, CircleDot, Info, MapPin, @@ -24,6 +23,8 @@ type RouteDiscoveryPanelProps = { onModeChange: (mode: TravelMode) => void; routeName: string; onRouteNameChange: (name: string) => void; + onSaveRoute: () => void; + canSave: boolean; }; export default function RouteDiscoveryPanel({ @@ -33,6 +34,8 @@ export default function RouteDiscoveryPanel({ onModeChange, routeName, onRouteNameChange, + onSaveRoute, + canSave, }: RouteDiscoveryPanelProps) { const router = useRouter(); @@ -43,13 +46,24 @@ export default function RouteDiscoveryPanel({ return (