From 3d4f5d963e4206b10be279d3d3d0c95c86adf7d3 Mon Sep 17 00:00:00 2001 From: aryansk Date: Fri, 14 Aug 2026 19:12:22 +0530 Subject: [PATCH] feat: add per-city landing pages generated from the places dataset Adds a statically generated page for every distinct city in the dataset, grouping that city places by category with the city name in the title, h1, and meta description. Slugs are derived from the existing city field (no second city list); Unicode city names are preserved, and two distinct cities that slugify identically fail the build loudly instead of silently merging. Thin cities get a prompt to contribute more places, each place links to its map deep link and Google Maps, and city routes are added to the sitemap. Closes #127 --- src/app/city/[slug]/page.tsx | 148 +++++++++++++++++++++++++++++++++++ src/app/sitemap.ts | 19 +++-- src/lib/city-pages.test.ts | 89 +++++++++++++++++++++ src/lib/city-pages.ts | 77 ++++++++++++++++++ 4 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 src/app/city/[slug]/page.tsx create mode 100644 src/lib/city-pages.test.ts create mode 100644 src/lib/city-pages.ts 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); +}