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
148 changes: 148 additions & 0 deletions src/app/city/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<PageContainer>
<p className="kicker">Places in</p>
<h1 className="mt-3 font-heading text-3xl font-bold tracking-tight sm:text-4xl">
{name}
</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
{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.
</p>

{page.places.length <= THIN_CITY_MAX_PLACES && (
<Card className="mt-6 bg-secondary/40">
<CardContent>
<p className="font-medium">{name} is just getting started.</p>
<p className="mt-1 text-sm text-muted-foreground">
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}.
</p>
<Button asChild className="mt-4">
<Link href="/contribute">Add a place in {name}</Link>
</Button>
</CardContent>
</Card>
)}

<div className="mt-8 space-y-8">
{grouped.map(([type, places]) => (
<section key={type} aria-labelledby={`type-${type}`}>
<h2
id={`type-${type}`}
className="font-heading text-lg font-semibold tracking-tight"
>
{PLACE_TYPE_LABELS[type]}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{places.length}
</span>
</h2>
<ul className="mt-3 grid gap-3 sm:grid-cols-2">
{places.map((place) => (
<li key={place.id}>
<Card size="sm" className="h-full">
<CardHeader>
<CardTitle>{place.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{place.address && (
<span className="text-muted-foreground">
{place.address}
</span>
)}
<Link
href={`/map?place=${encodeURIComponent(place.id)}`}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Open on map
</Link>
<a
href={place.gmaps_link}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Google Maps
</a>
</CardContent>
</Card>
</li>
))}
</ul>
</section>
))}
</div>

<div className="mt-10">
<Button asChild variant="outline">
<Link href="/map">Explore all places on the map</Link>
</Button>
</div>
</PageContainer>
);
}
19 changes: 14 additions & 5 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
}));
}
89 changes: 89 additions & 0 deletions src/lib/city-pages.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions src/lib/city-pages.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>`. 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<string, { city: City; places: Place[] }>();
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);
}
Loading