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 1144c94..e3d689e 100644 --- a/nexa/src/app/dashboard/page.tsx +++ b/nexa/src/app/dashboard/page.tsx @@ -1,34 +1,15 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { ArrowRight, CheckCircle2, Clock3, ClipboardList } from "lucide-react"; -import { ISSUE_TYPE_LABELS } from "@/lib/constants"; 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 { DeleteReportButton } from "@/components/dashboard/delete-report-button"; - -function formatStatus(status: string): string { - return status - .toLowerCase() - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function statusPillClass(status: string): string { - switch (status) { - case "CONFIRMED": - return "bg-ep-green-light text-ep-green"; - case "RESOLVED": - case "CLOSED": - return "bg-blue-50 text-blue-700"; - case "SUBMITTED": - case "IN_PROGRESS": - return "bg-ep-purple-light text-ep-purple"; - default: - return "bg-muted text-muted-foreground"; - } -} +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(); @@ -51,6 +32,9 @@ export default async function DashboardPage() { description: true, aiDescription: true, address: true, + imageUrl: true, + latitude: true, + longitude: true, createdAt: true, }, }); @@ -61,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 (
@@ -122,6 +129,12 @@ export default async function DashboardPage() {
+ {mapPoints.length > 0 && ( +
+ +
+ )} +
{reports.length === 0 ? (
@@ -133,44 +146,7 @@ export default async function DashboardPage() { ) : (
{reports.map((report) => ( -
-
- - {ISSUE_TYPE_LABELS[report.issueType ?? ""] || - report.issueType || - "Uncategorized"} - -
- - {formatStatus(report.status)} - - -
-
- -

- {report.address || "No location provided"} -

-

- -

- -

- {report.aiDescription || - report.description || - "No description"} -

-
+ ))}
)} 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/report-card.tsx b/nexa/src/components/dashboard/report-card.tsx new file mode 100644 index 0000000..4c57e44 --- /dev/null +++ b/nexa/src/components/dashboard/report-card.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ImageOff, MapPin } from "lucide-react"; +import { ISSUE_TYPE_LABELS, shortenAddress } from "@/lib/constants"; +import { formatFullDateTime, formatRelativeTime } from "@/lib/utils"; +import { DeleteReportButton } from "@/components/dashboard/delete-report-button"; + +export interface DashboardReport { + id: string; + issueType: string | null; + status: string; + description: string | null; + aiDescription: string | null; + address: string | null; + imageUrl: string | null; + createdAt: Date; +} + +interface ReportCardProps { + report: DashboardReport; +} + +function formatStatus(status: string): string { + return status + .toLowerCase() + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function statusPillClass(status: string): string { + switch (status) { + case "CONFIRMED": + return "bg-ep-green-light text-ep-green"; + case "RESOLVED": + case "CLOSED": + return "bg-blue-50 text-blue-700"; + case "SUBMITTED": + case "IN_PROGRESS": + return "bg-ep-purple-light text-ep-purple"; + default: + return "bg-muted text-muted-foreground"; + } +} + +export function ReportCard({ report }: ReportCardProps) { + const [expanded, setExpanded] = useState(false); + + const issueLabel = + ISSUE_TYPE_LABELS[report.issueType ?? ""] || + report.issueType || + "Uncategorized"; + const shortLocation = shortenAddress(report.address); + const summary = + report.aiDescription?.trim() || + report.description?.trim() || + "No description"; + + return ( +
+ + + {expanded && ( +
+
+
+ + Photo + +
+ {report.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`Photo + ) : ( +
+
+ )} +
+
+ +
+ {report.description?.trim() ? ( +
+ + Your description + +

+ {report.description} +

+
+ ) : null} + + {report.aiDescription?.trim() ? ( +
+ + AI summary + +

+ {report.aiDescription} +

+
+ ) : null} + + {!report.description?.trim() && !report.aiDescription?.trim() && ( +

+ No description was provided for this report. +

+ )} + +
+ + Location + + {report.address ? ( +
+
+ ) : ( +

+ No location provided. +

+ )} +
+
+
+
+ )} +
+ ); +} 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 ( +
+
+
+
+
+ ); +} diff --git a/nexa/src/lib/constants.ts b/nexa/src/lib/constants.ts index 15cbb8a..b909bd2 100644 --- a/nexa/src/lib/constants.ts +++ b/nexa/src/lib/constants.ts @@ -11,3 +11,115 @@ export const SEVERITY_COLORS: Record = { medium: "text-yellow-500", high: "text-red-500", }; + +const COUNTRY_NAMES = new Set([ + "United States", + "United States of America", + "USA", + "U.S.A.", + "US", + "U.S.", + "Canada", + "Mexico", + "United Kingdom", + "UK", +]); + +const US_STATE_ABBREVIATIONS: Record = { + Alabama: "AL", + Alaska: "AK", + Arizona: "AZ", + Arkansas: "AR", + California: "CA", + Colorado: "CO", + Connecticut: "CT", + Delaware: "DE", + Florida: "FL", + Georgia: "GA", + Hawaii: "HI", + Idaho: "ID", + Illinois: "IL", + Indiana: "IN", + Iowa: "IA", + Kansas: "KS", + Kentucky: "KY", + Louisiana: "LA", + Maine: "ME", + Maryland: "MD", + Massachusetts: "MA", + Michigan: "MI", + Minnesota: "MN", + Mississippi: "MS", + Missouri: "MO", + Montana: "MT", + Nebraska: "NE", + Nevada: "NV", + "New Hampshire": "NH", + "New Jersey": "NJ", + "New Mexico": "NM", + "New York": "NY", + "North Carolina": "NC", + "North Dakota": "ND", + Ohio: "OH", + Oklahoma: "OK", + Oregon: "OR", + Pennsylvania: "PA", + "Rhode Island": "RI", + "South Carolina": "SC", + "South Dakota": "SD", + Tennessee: "TN", + Texas: "TX", + Utah: "UT", + Vermont: "VT", + Virginia: "VA", + Washington: "WA", + "West Virginia": "WV", + Wisconsin: "WI", + Wyoming: "WY", + "District of Columbia": "DC", +}; + +/** + * Shortens a verbose address (typically from Nominatim/Google geocoding) into a + * compact "City, ST" or "City, Region" label. + * + * Example: + * "Coupa Cafe, 538, Ramona Street, University South, Palo Alto, + * Santa Clara County, California, 94301, United States" + * -> "Palo Alto, CA" + */ +export function shortenAddress(address: string | null | undefined): string { + if (!address) return ""; + + const parts = address + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0]; + + const cleaned = parts.filter((part) => { + if (COUNTRY_NAMES.has(part)) return false; + if (/^\d{4,6}(-\d+)?$/.test(part)) return false; + if (/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i.test(part)) return false; + return true; + }); + + if (cleaned.length === 0) return parts.slice(0, 2).join(", "); + if (cleaned.length === 1) return cleaned[0]; + + const last = cleaned[cleaned.length - 1]; + const stateAbbr = US_STATE_ABBREVIATIONS[last] ?? last; + + if (cleaned.length >= 3) { + const maybeCounty = cleaned[cleaned.length - 2]; + if (/\bCounty\b/i.test(maybeCounty)) { + const city = cleaned[cleaned.length - 3]; + return `${city}, ${stateAbbr}`; + } + } + + const city = cleaned[cleaned.length - 2]; + return `${city}, ${stateAbbr}`; +}