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 && (