/** * Town and locality pages. * * A place below the five-school threshold is not in the registry, so * fetchPlace returns null and the request 404s rather than rendering a page * with nothing to say. */ import { notFound, redirect } from 'next/navigation'; import type { Metadata } from 'next'; import { fetchPlace, fetchPlaces, authoritySlug } from '@/lib/places'; import { fetchNationalAverages } from '@/lib/api'; import { PlaceView } from '@/components/places/PlaceView'; import { absoluteUrl } from '@/lib/site'; interface Props { params: Promise<{ place: string }> } // ISR: place aggregates change only when the pipeline runs. export const revalidate = 604800; export const dynamicParams = true; export async function generateStaticParams(): Promise> { // Off by default: ~2,000 place routes cannot be built in CI on every deploy. // Matches the PRERENDER_SCHOOLS gate on the school route. if (process.env.PRERENDER_PLACES !== '1') return []; try { return (await fetchPlaces()) .filter((p) => p.kind === 'town' || p.kind === 'locality') .map((p) => ({ place: p.slug })); } catch (error) { console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error); return []; } } async function resolve(slug: string) { return (await fetchPlace('town', slug)) ?? (await fetchPlace('locality', slug)); } /** Other towns in the same authority — the cheapest honest definition of * "nearby", and enough to stop each place page being a dead end. */ async function neighboursOf(detail: { place: { slug: string; parent_authority: string | null } }) { if (!detail.place.parent_authority) return []; const all = await fetchPlaces(); return all .filter((p) => p.kind === 'town' && p.slug !== detail.place.slug) .slice(0, 12); } export async function generateMetadata({ params }: Props): Promise { const { place: slug } = await params; const detail = await resolve(slug); if (!detail) return { title: 'Place Not Found' }; const { name, count } = detail.place; return { // absolute: the root layout's template appends '| schoolcompare' to a // plain string, and this title already carries it. Without this every // place title read '... | schoolcompare | schoolcompare'. title: { absolute: `Schools in ${name} — Compare ${count} Schools | schoolcompare` }, description: `Every school in ${name}, with SATs and GCSE results, Ofsted grades, the local ` + `average against England, and how close you had to live to get a place.`, alternates: { canonical: absoluteUrl(`/schools/${slug}`) }, }; } export default async function PlacePage({ params }: Props) { const { place: slug } = await params; const detail = await resolve(slug); if (!detail) notFound(); // Global constraint: no page without a local average. A place with too few // schools carrying results has nothing to say that a list does not, so it // defers to its authority rather than publishing a thin page. if (detail.averages.rwm_expected_pct == null && detail.averages.attainment_8_score == null) { // The API's own slug, which is null when that authority is itself under // the threshold and has no page. Re-slugifying the name here would send // the reader to a 404 instead of telling them the place has no page. const target = detail.place.authorities?.[0]?.slug ?? (detail.place.parent_authority ? authoritySlug(detail.place.parent_authority) : null); if (target) redirect(`/schools/authority/${target}`); notFound(); } const national = await fetchNationalAverages().catch(() => null); // NationalAverages is nested by phase — { primary: {...}, secondary: {...} } // — not flat. Reading it flat silently yields undefined and the page renders // with no comparison, which is the one thing that makes it not a list. return ( ); }