From f13466e2f9e7e95f3539d4545fbf697d19512a37 Mon Sep 17 00:00:00 2001 From: David Lu <120457294+davidlu468@users.noreply.github.com> Date: Wed, 20 May 2026 18:31:16 -0700 Subject: [PATCH] feat(dashboard): geographic map with pins for all reports Adds a Leaflet-powered map between the stats row and report list that plots every report with valid coordinates. Pins use the app's purple/ green palette to mirror status pill colors, and clicking a pin opens a popup with issue type, short address, status, and relative time. Uses free OpenStreetMap/CARTO tiles rather than the server-side Google Maps key so the unrestricted API key stays out of the browser bundle. Leaflet is loaded only on the client (dynamic import in useEffect) to avoid SSR access to `window`. Co-authored-by: Cursor --- nexa/package-lock.json | 20 ++- nexa/package.json | 2 + nexa/src/app/dashboard/page.tsx | 36 ++++ nexa/src/app/globals.css | 88 ++++++++++ nexa/src/components/dashboard/reports-map.tsx | 165 ++++++++++++++++++ 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 nexa/src/components/dashboard/reports-map.tsx diff --git a/nexa/package-lock.json b/nexa/package-lock.json index e4725c6..d47226b 100644 --- a/nexa/package-lock.json +++ b/nexa/package-lock.json @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "jose": "^6.2.3", + "leaflet": "^1.9.4", "lucide-react": "^1.8.0", "next": "16.2.4", "openai": "^6.34.0", @@ -33,6 +34,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -3305,6 +3307,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -3339,7 +3351,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -7948,6 +7960,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/nexa/package.json b/nexa/package.json index 68bcf1c..0039d41 100644 --- a/nexa/package.json +++ b/nexa/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "jose": "^6.2.3", + "leaflet": "^1.9.4", "lucide-react": "^1.8.0", "next": "16.2.4", "openai": "^6.34.0", @@ -36,6 +37,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/nexa/src/app/dashboard/page.tsx b/nexa/src/app/dashboard/page.tsx index 2551030..e3d689e 100644 --- a/nexa/src/app/dashboard/page.tsx +++ b/nexa/src/app/dashboard/page.tsx @@ -3,8 +3,13 @@ import { redirect } from "next/navigation"; import { ArrowRight, CheckCircle2, Clock3, ClipboardList } from "lucide-react"; import { getSession } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { ISSUE_TYPE_LABELS, shortenAddress } from "@/lib/constants"; import { formatFullDateTime, formatRelativeTime } from "@/lib/utils"; import { ReportCard } from "@/components/dashboard/report-card"; +import { + ReportsMap, + type ReportMapPoint, +} from "@/components/dashboard/reports-map"; export default async function DashboardPage() { const session = await getSession(); @@ -28,6 +33,8 @@ export default async function DashboardPage() { aiDescription: true, address: true, imageUrl: true, + latitude: true, + longitude: true, createdAt: true, }, }); @@ -38,6 +45,29 @@ export default async function DashboardPage() { ).length; const latestReport = reports[0]; + const mapPoints: ReportMapPoint[] = reports + .filter( + ( + report, + ): report is typeof report & { latitude: number; longitude: number } => + typeof report.latitude === "number" && + Number.isFinite(report.latitude) && + typeof report.longitude === "number" && + Number.isFinite(report.longitude), + ) + .map((report) => ({ + id: report.id, + latitude: report.latitude, + longitude: report.longitude, + issueLabel: + ISSUE_TYPE_LABELS[report.issueType ?? ""] || + report.issueType || + "Uncategorized", + shortLocation: shortenAddress(report.address), + status: report.status, + relativeTime: formatRelativeTime(report.createdAt), + })); + return (
@@ -99,6 +129,12 @@ export default async function DashboardPage() {
+ {mapPoints.length > 0 && ( +
+ +
+ )} +
{reports.length === 0 ? (
diff --git a/nexa/src/app/globals.css b/nexa/src/app/globals.css index 248f576..d9b79d0 100644 --- a/nexa/src/app/globals.css +++ b/nexa/src/app/globals.css @@ -230,3 +230,91 @@ border-radius: 0.75rem; background: var(--card); } + +/* Reports map — Leaflet customizations to match the app aesthetic */ + +.nexa-map-pin { + background: transparent !important; + border: none !important; +} + +.leaflet-container { + font-family: + var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; + background: var(--muted); +} + +.leaflet-popup-content-wrapper { + border-radius: 0.625rem; + border: 1px solid var(--border); + background: var(--card); + color: var(--foreground); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + padding: 0; +} + +.leaflet-popup-content { + margin: 0; + padding: 0.75rem 0.875rem; + font-size: 0.8125rem; + line-height: 1.35; + min-width: 180px; +} + +.leaflet-popup-tip { + background: var(--card); + border: 1px solid var(--border); +} + +.nexa-map-popup__label { + font-family: var(--font-geist-mono); + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-foreground); + margin: 0; +} + +.nexa-map-popup__title { + margin: 0.25rem 0 0; + font-size: 0.9rem; + font-weight: 500; + color: var(--foreground); + line-height: 1.3; +} + +.nexa-map-popup__meta { + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.nexa-map-popup__status { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-family: var(--font-geist-mono); + font-size: 0.6rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nexa-map-popup__status--confirmed { + background: var(--ep-green-light); + color: var(--ep-green); +} + +.nexa-map-popup__status--pending { + background: var(--muted); + color: var(--muted-foreground); +} + +.nexa-map-popup__time { + font-size: 0.7rem; + color: var(--muted-foreground); +} diff --git a/nexa/src/components/dashboard/reports-map.tsx b/nexa/src/components/dashboard/reports-map.tsx new file mode 100644 index 0000000..0bed312 --- /dev/null +++ b/nexa/src/components/dashboard/reports-map.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { LatLngTuple, Map as LeafletMap } from "leaflet"; +import { MapPinned } from "lucide-react"; +import "leaflet/dist/leaflet.css"; + +export interface ReportMapPoint { + id: string; + latitude: number; + longitude: number; + issueLabel: string; + shortLocation: string; + status: string; + relativeTime: string; +} + +interface ReportsMapProps { + points: ReportMapPoint[]; +} + +const CONFIRMED_STATUSES = new Set([ + "CONFIRMED", + "SUBMITTING", + "SUBMITTED", + "ACKNOWLEDGED", + "IN_PROGRESS", + "RESOLVED", + "CLOSED", +]); + +function pinSvg(color: string): string { + return ` + + `; +} + +function formatStatus(status: string): string { + return status + .toLowerCase() + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function ReportsMap({ points }: ReportsMapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (!containerRef.current || points.length === 0) return; + + let cancelled = false; + + (async () => { + const leaflet = await import("leaflet"); + const L = leaflet.default ?? leaflet; + if (cancelled || !containerRef.current) return; + + const map = L.map(containerRef.current, { + scrollWheelZoom: false, + zoomControl: true, + attributionControl: true, + }); + mapRef.current = map; + + L.tileLayer( + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + { + attribution: + '© OpenStreetMap contributors © CARTO', + subdomains: "abcd", + maxZoom: 19, + }, + ).addTo(map); + + const latLngs: LatLngTuple[] = []; + + for (const point of points) { + const isConfirmed = CONFIRMED_STATUSES.has(point.status); + const color = isConfirmed ? "#22c55e" : "#9b87f5"; + + const icon = L.divIcon({ + className: "nexa-map-pin", + html: pinSvg(color), + iconSize: [28, 36], + iconAnchor: [14, 35], + popupAnchor: [0, -30], + }); + + const popupHtml = ` +
+

${escapeHtml(point.issueLabel)}

+

${escapeHtml(point.shortLocation || "Location unavailable")}

+
+ ${escapeHtml(formatStatus(point.status))} + ${escapeHtml(point.relativeTime)} +
+
+ `; + + L.marker([point.latitude, point.longitude], { icon }) + .addTo(map) + .bindPopup(popupHtml, { closeButton: false, offset: [0, -2] }); + + latLngs.push([point.latitude, point.longitude]); + } + + if (latLngs.length === 1) { + map.setView(latLngs[0], 14); + } else { + map.fitBounds(latLngs, { padding: [40, 40], maxZoom: 14 }); + } + })(); + + return () => { + cancelled = true; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, [points]); + + if (points.length === 0) return null; + + return ( +
+
+
+
+
+ ); +}