diff --git a/src/app/city/[slug]/page.tsx b/src/app/city/[slug]/page.tsx new file mode 100644 index 0000000..41cae43 --- /dev/null +++ b/src/app/city/[slug]/page.tsx @@ -0,0 +1,148 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { + cityPageSlugs, + cityPages, + placesByType, +} from "@/lib/city-pages"; +import { getPlaces } from "@/lib/places"; +import { humanizeCity, PLACE_TYPE_LABELS } 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"; + +// Every city page is decided at build time from the dataset; a slug that is +// not in it is a 404, never a server-rendered miss. +export const dynamicParams = false; + +const THIN_CITY_MAX_PLACES = 3; + +export function generateStaticParams(): { slug: string }[] { + return cityPageSlugs(getPlaces()); +} + +interface CityPageProps { + params: Promise<{ slug: string }>; +} + +// Next may pass a still-encoded segment (e.g. "%E5%8E%A6%E9%97%A8" for the +// city 厦门), so decode before comparing against dataset slugs. +function decodeSlug(raw: string): string { + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +export async function generateMetadata({ + params, +}: CityPageProps): Promise { + const { slug } = await params; + const page = cityPages(getPlaces()).find( + (candidate) => candidate.slug === decodeSlug(slug), + ); + if (!page) return {}; + const name = humanizeCity(page.city); + return { + title: `${name} — student places`, + description: `Student-important places in ${name}: ${page.places.length} exam centre${page.places.length === 1 ? "" : "s"}, library${page.places.length === 1 ? "" : "ies"} and more, on the crowdsourced StudyMap.`, + }; +} + +export default async function CityPage({ params }: CityPageProps) { + const { slug } = await params; + const page = cityPages(getPlaces()).find( + (candidate) => candidate.slug === decodeSlug(slug), + ); + if (!page) notFound(); + + const name = humanizeCity(page.city); + const grouped = placesByType(page.places); + + return ( + +

Places in

+

+ {name} +

+

+ {page.places.length} place{page.places.length === 1 ? "" : "s"} in{" "} + {name}, from the crowdsourced StudyMap dataset. Open one on the map to + see it in context. +

+ + {page.places.length <= THIN_CITY_MAX_PLACES && ( + + +

{name} is just getting started.

+

+ Know an exam centre, library, or study spot missing from this + list? Add it — it takes a couple of minutes and helps every + student searching for {name}. +

+ +
+
+ )} + +
+ {grouped.map(([type, places]) => ( +
+

+ {PLACE_TYPE_LABELS[type]} + + {places.length} + +

+
    + {places.map((place) => ( +
  • + + + {place.name} + + + {place.address && ( + + {place.address} + + )} + + Open on map + + + Google Maps + + + +
  • + ))} +
+
+ ))} +
+ +
+ +
+
+ ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index d668792..2a7e0fe 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,5 +1,7 @@ import type { MetadataRoute } from "next"; +import { cityPageSlugs } from "@/lib/city-pages"; +import { getPlaces } from "@/lib/places"; import { site } from "@/lib/site"; import { docsPages } from "@/lib/docs-nav"; @@ -9,10 +11,17 @@ const LEGAL_ROUTES = ["/legal/privacy", "/legal/terms", "/legal/disclaimer"]; export default function sitemap(): MetadataRoute.Sitemap { const now = new Date(); - return [...STATIC_ROUTES, ...docsPages.map((page) => page.href), ...LEGAL_ROUTES].map( - (path) => ({ - url: `${site.url}${path}`, - lastModified: now, - }), + const cityRoutes = cityPageSlugs(getPlaces()).map( + ({ slug }) => `/city/${slug}`, ); + + return [ + ...STATIC_ROUTES, + ...docsPages.map((page) => page.href), + ...LEGAL_ROUTES, + ...cityRoutes, + ].map((path) => ({ + url: `${site.url}${path}`, + lastModified: now, + })); } diff --git a/src/lib/city-pages.test.ts b/src/lib/city-pages.test.ts new file mode 100644 index 0000000..ca95705 --- /dev/null +++ b/src/lib/city-pages.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + cityPages, + cityPageSlugs, + cityToSlug, + placesByType, +} from "@/lib/city-pages"; +import type { Place } from "@/lib/types"; + +function place(id: string, city: string, type: Place["type"] = "library"): Place { + return { + id, + name: id, + type, + city, + lat: 19.076, + lng: 72.8777, + gmaps_link: "https://maps.google.com/?q=19.076,72.8777", + added_by: "test", + }; +} + +describe("cityToSlug", () => { + it("leaves canonical underscore slugs unchanged", () => { + expect(cityToSlug("navi_mumbai")).toBe("navi_mumbai"); + expect(cityToSlug("new_delhi")).toBe("new_delhi"); + }); + + it("normalizes spaces, hyphens and case", () => { + expect(cityToSlug("New Delhi")).toBe("new_delhi"); + expect(cityToSlug("San Jose")).toBe("san_jose"); + expect(cityToSlug("Navi-Mumbai")).toBe("navi_mumbai"); + }); + + it("preserves non-ASCII letters instead of collapsing to an empty slug", () => { + expect(cityToSlug("厦门")).toBe("厦门"); + expect(cityToSlug("São Paulo")).toBe("são_paulo"); + }); +}); + +describe("cityPages", () => { + it("groups places by city", () => { + const pages = cityPages([ + place("mum-library-01", "mumbai"), + place("mum-library-02", "mumbai"), + place("del-library-01", "new_delhi"), + ]); + expect(pages).toHaveLength(2); + const mumbai = pages.find((page) => page.city === "mumbai"); + expect(mumbai?.places).toHaveLength(2); + expect(mumbai?.slug).toBe("mumbai"); + }); + + it("throws loudly when two distinct cities collide after slugification", () => { + expect(() => + cityPages([place("a-01", "san_jose"), place("b-01", "San Jose")]), + ).toThrow(/slug collision/i); + }); + + it("does not throw for repeated entries of the same city", () => { + expect(() => + cityPages([place("a-01", "mumbai"), place("a-02", "mumbai")]), + ).not.toThrow(); + }); +}); + +describe("cityPageSlugs", () => { + it("returns one param per distinct city", () => { + const slugs = cityPageSlugs([ + place("mum-library-01", "mumbai"), + place("del-library-01", "new_delhi"), + ]); + expect(slugs).toEqual([{ slug: "mumbai" }, { slug: "new_delhi" }]); + }); +}); + +describe("placesByType", () => { + it("groups by type in canonical order and skips empty types", () => { + const grouped = placesByType([ + place("a-01", "mumbai", "sat_centre"), + place("a-02", "mumbai", "library"), + place("a-03", "mumbai", "sat_centre"), + ]); + expect(grouped.map(([type]) => type)).toEqual(["library", "sat_centre"]); + expect(grouped[0][1]).toHaveLength(1); + expect(grouped[1][1]).toHaveLength(2); + }); +}); diff --git a/src/lib/city-pages.ts b/src/lib/city-pages.ts new file mode 100644 index 0000000..5386616 --- /dev/null +++ b/src/lib/city-pages.ts @@ -0,0 +1,77 @@ +import { PLACE_TYPES } from "@/lib/types"; +import type { City, Place, PlaceType } from "@/lib/types"; + +/** + * A city landing page's data: the URL slug plus every place that belongs to + * it. The dataset stays the single source of truth — no second city list. + */ +export interface CityPage { + /** URL slug for `/city/`. For canonical city values, slug === city. */ + slug: string; + /** The canonical city value from the dataset (first seen, in data order). */ + city: City; + places: Place[]; +} + +/** + * Normalize a city value into a URL-safe slug. City values are already + * lowercase underscore slugs (per the schema), so this is identity for clean + * values; it exists to catch data drift (spaces, hyphens, mixed case) so two + * values that slugify identically are detected instead of silently merging. + * Unicode letters are preserved (e.g. the Chinese city name 厦门 stays itself) + * so no city ever collapses to an empty slug. + */ +export function cityToSlug(city: City): string { + return city + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Group every distinct city into a page. Slugs are derived from the `city` + * field; if two different city values slugify to the same slug, that is a + * data-quality collision and we fail loudly rather than silently merging the + * two cities into one page. + */ +export function cityPages(places: Place[]): CityPage[] { + const bySlug = new Map(); + for (const place of places) { + const slug = cityToSlug(place.city); + const entry = bySlug.get(slug); + if (entry) { + // Collision: the same slug from two distinct city values. + if (entry.city !== place.city) { + throw new Error( + `City slug collision: "${entry.city}" and "${place.city}" both ` + + `slugify to "${slug}". Fix the city values in the dataset so ` + + `they do not collide before building.`, + ); + } + entry.places.push(place); + } else { + bySlug.set(slug, { city: place.city, places: [place] }); + } + } + return Array.from(bySlug, ([slug, { city, places }]) => ({ + slug, + city, + places, + })); +} + +/** Every slug, in dataset order, for `generateStaticParams`. */ +export function cityPageSlugs(places: Place[]): { slug: string }[] { + return cityPages(places).map(({ slug }) => ({ slug })); +} + +/** A city's places grouped by type, in the canonical PLACE_TYPES order. */ +export function placesByType(places: Place[]): [PlaceType, Place[]][] { + return (PLACE_TYPES.map( + (type): [PlaceType, Place[]] => [ + type, + places.filter((place) => place.type === type), + ], + ) as [PlaceType, Place[]][]).filter(([, grouped]) => grouped.length > 0); +}