diff --git a/src/app/about/page.test.tsx b/src/app/about/page.test.tsx new file mode 100644 index 0000000..0da2b04 --- /dev/null +++ b/src/app/about/page.test.tsx @@ -0,0 +1,14 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import AboutPage from "@/app/about/page"; + +afterEach(cleanup); + +describe("About page", () => { + it("lists the top contributors from the dataset", () => { + render(); + + expect(screen.getByText("Top contributors")).toBeTruthy(); + }); +}); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 33a2f8f..c18b3eb 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -7,6 +7,7 @@ import { PLACE_TYPES } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageContainer } from "@/components/layout/page-container"; +import { ContributionLeaderboard } from "@/components/map/contribution-leaderboard"; export const metadata: Metadata = { title: "About", @@ -30,7 +31,8 @@ const principles = [ ]; export default function AboutPage() { - const total = getPlaces().length; + const places = getPlaces(); + const total = places.length; return ( @@ -70,6 +72,10 @@ export default function AboutPage() {

+
+ +
+

Who maintains it diff --git a/src/components/home/hero.test.tsx b/src/components/home/hero.test.tsx new file mode 100644 index 0000000..357478c --- /dev/null +++ b/src/components/home/hero.test.tsx @@ -0,0 +1,18 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Hero } from "@/components/home/hero"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("@/components/home/map-preview", () => ({ MapPreview: () => null })); +vi.mock("@/components/home/hero-particles", () => ({ HeroParticles: () => null })); + +afterEach(cleanup); + +describe("Hero", () => { + it("offers a jump-to-city control", () => { + render(); + + expect(screen.getByRole("combobox", { name: "Jump to a city" })).toBeTruthy(); + }); +}); diff --git a/src/components/home/hero.tsx b/src/components/home/hero.tsx index 3b8389c..82b8247 100644 --- a/src/components/home/hero.tsx +++ b/src/components/home/hero.tsx @@ -6,11 +6,13 @@ import { site } from "@/lib/site"; import { Button } from "@/components/ui/button"; import { MapPreview } from "@/components/home/map-preview"; import { HeroParticles } from "@/components/home/hero-particles"; +import { CitySwitcher } from "@/components/home/city-switcher"; export function Hero() { const places = getPlaces(); const total = places.length; - const cityCount = getCities(places).length; + const cities = getCities(places); + const cityCount = cities.length; const stats = `${total} places · ${PLACE_TYPES.length} categories · ${cityCount} cities · 100% open data`; return ( @@ -48,6 +50,10 @@ export function Hero() { +
+ +
+

{stats}

diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 465f1ef..d99411a 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -112,7 +112,7 @@ function boundsToBbox(bounds: L.LatLngBounds): [number, number, number, number] * render exactly as the plain PlaceMarker below; only overlapping points * change appearance, and clicking a cluster zooms into it. */ -function ClusteredMarkers({ places }: { places: Place[] }) { +function ClusteredMarkers({ places, home }: { places: Place[]; home: LatLng | null }) { const map = useMap(); const [bbox, setBbox] = useState<[number, number, number, number]>(() => boundsToBbox(map.getBounds()), @@ -220,7 +220,7 @@ function ClusteredMarkers({ places }: { places: Place[] }) { }} > - + ); @@ -238,6 +238,8 @@ export interface MapViewport { interface MapViewProps { places: Place[]; userLocation?: LatLng | null; + /** The visitor's saved home; pin popups show how far each place is from it. */ + home?: LatLng | null; focusId?: string | null; /** Bounding box to fly to, e.g. the selected city's pins. */ focusBounds?: Bounds | null; @@ -470,6 +472,7 @@ function MapResizeHandler() { export default function MapView({ places, userLocation, + home = null, focusId, focusBounds, interactive = true, @@ -578,7 +581,7 @@ export default function MapView({ )} - + ); diff --git a/src/components/map/places-map.test.tsx b/src/components/map/places-map.test.tsx index 6d63009..5b50544 100644 --- a/src/components/map/places-map.test.tsx +++ b/src/components/map/places-map.test.tsx @@ -302,7 +302,14 @@ describe("PlacesMap panel mounting", () => { describe("PlacesMap filter presets", () => { it("narrows the results to the preset's type and sets it in the URL", () => { - render(); + render( + , + ); expect(screen.getByText("City Library")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Airports" })); diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index dc6dc11..cd3705e 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -403,6 +403,7 @@ export function PlacesMap({ { expect(screen.getByRole("button", { name: "Open GitHub issue" })).toBeTruthy(); }); }); + +describe("PinPopup distance from home", () => { + it("shows a straight-line distance when a home is set", () => { + render(); + + expect(screen.getByText(/away/)).toBeTruthy(); + expect(screen.getByText(/not a real route/)).toBeTruthy(); + }); + + it("shows nothing extra without a home", () => { + render(); + + expect(screen.queryByText(/not a real route/)).toBeNull(); + }); +}); diff --git a/src/components/pins/pin-popup.tsx b/src/components/pins/pin-popup.tsx index 382078f..0e4960c 100644 --- a/src/components/pins/pin-popup.tsx +++ b/src/components/pins/pin-popup.tsx @@ -8,16 +8,20 @@ import type { Place } from "@/lib/types"; import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types"; import { directionsUrl, PLACE_TYPE_COLORS } from "@/lib/map"; import { buildShareUrl } from "@/lib/share"; +import type { LatLng } from "@/lib/geo"; import { DirectionsModal } from "@/components/map/directions-modal"; import { ReportIssueDialog } from "@/components/map/report-issue-dialog"; +import { RouteEtaCard } from "@/components/map/route-eta-card"; import { ExamCentreCountdown } from "@/components/pins/exam-centre-countdown"; import { VerifiedBadge } from "@/components/pins/verified-badge"; interface PinPopupProps { place: Place; + /** The signed-in visitor's saved home, for a distance estimate. */ + home?: LatLng | null; } -export function PinPopup({ place }: PinPopupProps) { +export function PinPopup({ place, home = null }: PinPopupProps) { const [directionsOpen, setDirectionsOpen] = React.useState(false); const [reportOpen, setReportOpen] = React.useState(false); @@ -68,6 +72,8 @@ export function PinPopup({ place }: PinPopupProps) {

{place.address}

)} + + {/* Exam centre: the validity pill in the header carries the reconfirm date */} {place.exam && (