Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/components/map/compare-places-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,11 +73,7 @@ export function ComparePlacesSheet({
</CardHeader>
<CardContent className="space-y-2">
<div className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: PLACE_TYPE_COLORS[place.type] }}
/>
<CategoryIcon type={place.type} className="size-3.5" />
{PLACE_TYPE_LABELS[place.type]}
</div>
<p className="text-xs text-muted-foreground">
Expand Down
12 changes: 11 additions & 1 deletion src/components/map/map-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +94,8 @@ export function MapPanel({
onResetFilters,
resultsToggle,
onSelectPlace,
onShowDetails,
compare,
focusId,
onLocated,
onShare,
Expand Down Expand Up @@ -222,6 +230,8 @@ export function MapPanel({
onSuggest: () => setSuggestOpen(true),
}}
onSelect={onSelectPlace}
onDetails={onShowDetails}
compare={compare}
focusId={focusId}
toggle={resultsToggle}
/>
Expand Down
16 changes: 9 additions & 7 deletions src/components/map/place-detail-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,7 +32,7 @@ interface PlaceDetailDrawerProps {
export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDrawerProps) {
return (
<Sheet open={open && place !== null} onOpenChange={onOpenChange}>
<SheetContent side="right">
<SheetContent side="right" className="overflow-y-auto">
{place && (
<>
<SheetHeader>
Expand All @@ -43,11 +45,7 @@ export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDraw

<div className="flex flex-col gap-4 px-4">
<div className="inline-flex w-fit items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: PLACE_TYPE_COLORS[place.type] }}
/>
<CategoryIcon type={place.type} className="size-3.5" />
{PLACE_TYPE_LABELS[place.type]}
</div>

Expand All @@ -72,6 +70,10 @@ export function PlaceDetailDrawer({ place, open, onOpenChange }: PlaceDetailDraw
</a>
</Button>

<PlacePhotoGallery place={place} />

<SharePlaceCard place={place} />

<p className="text-[11px] text-muted-foreground/75">
Added by{" "}
<span className="font-medium text-muted-foreground">
Expand Down
38 changes: 38 additions & 0 deletions src/components/map/places-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,41 @@ describe("PlacesMap country filter", () => {
expect(screen.getByText("Nairobi Library")).toBeTruthy();
});
});

describe("PlacesMap details and compare", () => {
function renderMapWithPlaces() {
render(
<PlacesMap
places={PLACES}
cities={CITIES}
defaultCenter={[19.08, 72.95]}
defaultZoom={11}
/>,
);
}

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();
});
});
49 changes: 49 additions & 0 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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`). */
Expand Down Expand Up @@ -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<Place | null>(null);
const [compareIds, setCompareIds] = React.useState<string[]>([]);
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
Expand All @@ -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,
Expand Down Expand Up @@ -437,6 +473,19 @@ export function PlacesMap({
{isDesktop && <MapPanel {...panelProps} showSearch showNearMe scrollChips />}
</aside>

<PlaceDetailDrawer
place={detailPlace}
open={detailPlace !== null}
onOpenChange={(open) => {
if (!open) setDetailPlace(null);
}}
/>
<ComparePlacesSheet
places={comparePlaces}
open={compareOpen}
onOpenChange={setCompareOpen}
/>

{/* Map + mobile overlays */}
<div className="relative min-w-0 flex-1">
<MapErrorBoundary>
Expand Down
91 changes: 91 additions & 0 deletions src/components/map/results-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ResultsList
header="All places"
rows={rowsOf(2)}
emptyState={emptyState}
onSelect={vi.fn()}
onDetails={onDetails}
/>,
);

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(
<ResultsList
header="All places"
rows={rowsOf(1)}
emptyState={emptyState}
onSelect={vi.fn()}
/>,
);

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(
<ResultsList
header="All places"
rows={rows}
emptyState={emptyState}
onSelect={vi.fn()}
compare={{ ...base, selectedIds: ["p0"] }}
/>,
);

expect(
(screen.getByRole("button", { name: "Compare (1)" }) as HTMLButtonElement).disabled,
).toBe(true);

rerender(
<ResultsList
header="All places"
rows={rows}
emptyState={emptyState}
onSelect={vi.fn()}
compare={{ ...base, selectedIds: ["p0", "p1", "p2"] }}
/>,
);

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" }));
});
});
Loading
Loading