diff --git a/src/components/map/compare-places-sheet.tsx b/src/components/map/compare-places-sheet.tsx index 119998e..7360fc2 100644 --- a/src/components/map/compare-places-sheet.tsx +++ b/src/components/map/compare-places-sheet.tsx @@ -4,7 +4,7 @@ import { ExternalLink } from "lucide-react"; import type { Place } from "@/lib/types"; import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types"; -import { PLACE_TYPE_COLORS } from "@/lib/map"; +import { CategoryIcon } from "@/components/map/category-icon"; import { formatDistance, haversineKm } from "@/lib/geo"; import { VerifiedBadge } from "@/components/pins/verified-badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -73,11 +73,7 @@ export function ComparePlacesSheet({
- + {PLACE_TYPE_LABELS[place.type]}

diff --git a/src/components/map/map-panel.tsx b/src/components/map/map-panel.tsx index c850aaa..1b535b7 100644 --- a/src/components/map/map-panel.tsx +++ b/src/components/map/map-panel.tsx @@ -15,7 +15,11 @@ import { NearMeButton } from "@/components/map/near-me-button"; import { MyPlacesPanel } from "@/components/map/my-places-panel"; import { SuggestPlaceDialog } from "@/components/map/suggest-place-dialog"; import { EmbedWidgetDialog } from "@/components/map/embed-widget"; -import { ResultsList, type ResultRow } from "@/components/map/results-list"; +import { + ResultsList, + type CompareControls, + type ResultRow, +} from "@/components/map/results-list"; import type { PlaceFilters } from "@/components/map/filters"; import type { LatLng } from "@/lib/geo"; import type { UserHome, UserPlaceRow } from "@/lib/user-places"; @@ -55,6 +59,8 @@ interface MapPanelProps { onResetFilters: () => void; resultsToggle?: { label: string; onClick: () => void } | null; onSelectPlace: (place: Place) => void; + onShowDetails?: (place: Place) => void; + compare?: CompareControls; focusId?: string | null; onLocated: (loc: LatLng) => void; @@ -88,6 +94,8 @@ export function MapPanel({ onResetFilters, resultsToggle, onSelectPlace, + onShowDetails, + compare, focusId, onLocated, onShare, @@ -222,6 +230,8 @@ export function MapPanel({ onSuggest: () => setSuggestOpen(true), }} onSelect={onSelectPlace} + onDetails={onShowDetails} + compare={compare} focusId={focusId} toggle={resultsToggle} /> diff --git a/src/components/map/place-detail-drawer.tsx b/src/components/map/place-detail-drawer.tsx index 3eddbeb..5b9b9bd 100644 --- a/src/components/map/place-detail-drawer.tsx +++ b/src/components/map/place-detail-drawer.tsx @@ -4,7 +4,9 @@ import { ExternalLink } from "lucide-react"; import type { Place } from "@/lib/types"; import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types"; -import { PLACE_TYPE_COLORS } from "@/lib/map"; +import { CategoryIcon } from "@/components/map/category-icon"; +import { PlacePhotoGallery } from "@/components/map/place-photo-gallery"; +import { SharePlaceCard } from "@/components/map/share-place-card"; import { VerifiedBadge } from "@/components/pins/verified-badge"; import { Button } from "@/components/ui/button"; import { @@ -30,7 +32,7 @@ interface PlaceDetailDrawerProps { export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDrawerProps) { return ( - + {place && ( <> @@ -43,11 +45,7 @@ export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDraw

- + {PLACE_TYPE_LABELS[place.type]}
@@ -72,6 +70,10 @@ export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDraw + + + +

Added by{" "} diff --git a/src/components/map/places-map.test.tsx b/src/components/map/places-map.test.tsx index c3fcb72..1101b7f 100644 --- a/src/components/map/places-map.test.tsx +++ b/src/components/map/places-map.test.tsx @@ -377,3 +377,41 @@ describe("PlacesMap country filter", () => { expect(screen.getByText("Nairobi Library")).toBeTruthy(); }); }); + +describe("PlacesMap details and compare", () => { + function renderMapWithPlaces() { + render( + , + ); + } + + it("opens the detail drawer for a row", () => { + renderMapWithPlaces(); + + fireEvent.click( + screen.getAllByRole("button", { name: "Details for City Library" })[0], + ); + + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByText("Street View preview")).toBeTruthy(); + expect(screen.getByRole("button", { name: /Copy link/ })).toBeTruthy(); + }); + + it("compares two picked places and clears the selection", () => { + renderMapWithPlaces(); + + fireEvent.click(screen.getAllByRole("checkbox", { name: "Compare City Library" })[0]); + fireEvent.click( + screen.getAllByRole("checkbox", { name: "Compare Thane Library" })[0], + ); + fireEvent.click(screen.getAllByRole("button", { name: "Compare (2)" })[0]); + + expect(screen.getByText("Compare places")).toBeTruthy(); + expect(screen.getByText("2 places side by side.")).toBeTruthy(); + }); +}); diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index a12fe35..bb15bf6 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -31,7 +31,9 @@ import { import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { MapErrorBoundary } from "@/components/map/map-error-boundary"; +import { ComparePlacesSheet } from "@/components/map/compare-places-sheet"; import { MapPanel } from "@/components/map/map-panel"; +import { PlaceDetailDrawer } from "@/components/map/place-detail-drawer"; import { MapSheet, SHEET_SNAP_POINTS } from "@/components/map/map-sheet"; import { NearMeFab } from "@/components/map/near-me-fab"; import { useMediaQuery } from "@/lib/use-media-query"; @@ -55,6 +57,9 @@ const MapView = dynamic(() => import("@/components/map/map-view"), { ), }); +/** Most places the compare sheet shows side by side. */ +const COMPARE_MAX = 3; + interface PlacesMapProps { places: Place[]; /** Distinct city slugs in `places`, in display order (from `getCities`). */ @@ -388,6 +393,35 @@ export function PlacesMap({ setFilters(next); } + // Detail drawer and side-by-side compare (picked from the results list). + const [detailPlace, setDetailPlace] = React.useState(null); + const [compareIds, setCompareIds] = React.useState([]); + const [compareOpen, setCompareOpen] = React.useState(false); + const comparePlaces = React.useMemo( + () => + compareIds + .map((id) => places.find((place) => place.id === id)) + .filter((place): place is Place => place !== undefined), + [compareIds, places], + ); + const compare = { + selectedIds: compareIds, + max: COMPARE_MAX, + onToggle: (place: Place) => + setCompareIds((ids) => + ids.includes(place.id) + ? ids.filter((id) => id !== place.id) + : ids.length < COMPARE_MAX + ? [...ids, place.id] + : ids, + ), + onOpen: () => setCompareOpen(true), + onClear: () => { + setCompareIds([]); + setCompareOpen(false); + }, + }; + function selectPlace(place: Place) { setFocusId(place.id); setSnap(SHEET_SNAP_POINTS[0]); // collapse to peek so pin is visible @@ -407,6 +441,8 @@ export function PlacesMap({ onResetFilters: () => setFilters({ types: [], city: null, country: null, query: "" }), resultsToggle, onSelectPlace: selectPlace, + onShowDetails: setDetailPlace, + compare, focusId, onLocated, onShare: share, @@ -437,6 +473,19 @@ export function PlacesMap({ {isDesktop && } + { + if (!open) setDetailPlace(null); + }} + /> + + {/* Map + mobile overlays */}

diff --git a/src/components/map/results-list.test.tsx b/src/components/map/results-list.test.tsx index 50dff45..ecbbb83 100644 --- a/src/components/map/results-list.test.tsx +++ b/src/components/map/results-list.test.tsx @@ -198,3 +198,94 @@ describe("ResultsList paging", () => { expect(screen.getByText("Place 99")).toBeTruthy(); }); }); + +describe("ResultsList details and compare", () => { + const rowsOf = (n: number): ResultRow[] => + Array.from({ length: n }, (_, i) => ({ + place: { + id: "p" + i, + name: "Place " + i, + type: "library" as const, + city: "mumbai", + lat: 19 + i / 100, + lng: 72.8, + gmaps_link: "https://maps.google.com/?q=19,72.8", + added_by: "test", + }, + })); + const emptyState = { activeFilters: [], onReset: vi.fn(), onSuggest: vi.fn() }; + + it("opens the details for the row's place", () => { + const onDetails = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Details for Place 1" })); + + expect(onDetails).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + }); + + it("shows no details button or checkbox unless asked", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Details for/ })).toBeNull(); + expect(screen.queryByRole("checkbox")).toBeNull(); + }); + + it("needs two picks before Compare is enabled and caps the picks at the maximum", () => { + const onToggle = vi.fn(); + const onOpen = vi.fn(); + const base = { max: 3, onToggle, onOpen, onClear: vi.fn() }; + const rows = rowsOf(4); + const { rerender } = render( + , + ); + + expect( + (screen.getByRole("button", { name: "Compare (1)" }) as HTMLButtonElement).disabled, + ).toBe(true); + + rerender( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Compare (3)" })); + expect(onOpen).toHaveBeenCalled(); + expect( + (screen.getByRole("checkbox", { name: "Compare Place 3" }) as HTMLInputElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole("checkbox", { name: "Compare Place 1" }) as HTMLInputElement) + .disabled, + ).toBe(false); + fireEvent.click(screen.getByRole("checkbox", { name: "Compare Place 1" })); + expect(onToggle).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + }); +}); diff --git a/src/components/map/results-list.tsx b/src/components/map/results-list.tsx index d5a0ca8..97bc6bb 100644 --- a/src/components/map/results-list.tsx +++ b/src/components/map/results-list.tsx @@ -1,15 +1,22 @@ "use client"; import * as React from "react"; -import { ExternalLink, MapPinPlus, MapPinX, Navigation, RotateCcw } from "lucide-react"; +import { + ExternalLink, + Info, + MapPinPlus, + MapPinX, + Navigation, + RotateCcw, +} from "lucide-react"; import type { Place } from "@/lib/types"; import { PLACE_TYPE_LABELS } from "@/lib/types"; -import { PLACE_TYPE_COLORS } from "@/lib/map"; import { directionsUrl } from "@/lib/map"; import { formatDistance } from "@/lib/geo"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { CategoryIcon } from "@/components/map/category-icon"; import { VerifiedBadge } from "@/components/pins/verified-badge"; export interface ResultRow { @@ -17,6 +24,16 @@ export interface ResultRow { distanceKm?: number; } +/** Pick 2 or 3 rows to compare side by side. */ +export interface CompareControls { + selectedIds: string[]; + /** Most places that can be compared at once. */ + max: number; + onToggle: (place: Place) => void; + onOpen: () => void; + onClear: () => void; +} + interface ResultsListProps { header: string; rows: ResultRow[]; @@ -26,6 +43,9 @@ interface ResultsListProps { onSuggest: () => void; }; onSelect: (place: Place) => void; + /** Opens the detail drawer for a place; the row shows an info button when set. */ + onDetails?: (place: Place) => void; + compare?: CompareControls; focusId?: string | null; /** Optional expand/collapse control (e.g. "Show all" for the nearest list). */ toggle?: { label: string; onClick: () => void } | null; @@ -42,6 +62,8 @@ export function ResultsList({ rows, emptyState, onSelect, + onDetails, + compare, focusId, toggle, pageSize = RESULTS_PAGE_SIZE, @@ -79,6 +101,27 @@ export function ResultsList({ )}
+ {compare && compare.selectedIds.length > 0 && ( +
+ + +
+ )} + {rows.length === 0 ? ( - + {compare && ( + = compare.max + } + onChange={() => compare.onToggle(place)} + className="size-4 shrink-0 accent-primary" + /> + )} + + {onDetails && ( + + )} {place.gmaps_link && (
- + {PLACE_TYPE_LABELS[place.type]}
{humanizeCity(place.city)}