Payload's admin panel ships its own root layout rendering html/body. Next allows multiple root layouts only when no app/layout.tsx exists, so the site's routes move into their own group. Route groups are invisible to routing: every public URL is unchanged, verified against the build's route table. The metadata file conventions deliberately stay at the app/ root. Moving them into the group renamed /icon.png to /icon-4usi79.png (likewise apple-icon and opengraph-image) and dropped /robots.txt altogether, which would have broken the /icon.png cache-control rule, the outputFileTracingIncludes entry for the share card, and robots.txt. darkThemeSafety reads app/globals.css off disk rather than importing it, so it needed its own path fix — a grep for import specifiers misses it, and it fails as an unrunnable suite rather than a failed assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
/**
|
|
* 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<Array<{ place: string }>> {
|
|
// 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<Metadata> {
|
|
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 (
|
|
<PlaceView
|
|
detail={detail}
|
|
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
|
neighbours={await neighboursOf(detail)}
|
|
/>
|
|
);
|
|
}
|