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
64 lines
2.4 KiB
TypeScript
64 lines
2.4 KiB
TypeScript
/**
|
|
* Phase variants of a place page.
|
|
*
|
|
* Phase is part of the query — "primary schools in beccles", "secondary
|
|
* schools in brentwood" — not a filter applied afterwards, so each gets its
|
|
* own indexable path. A place with no schools of the phase has no page: the
|
|
* per-phase threshold, not an error.
|
|
*/
|
|
import { notFound } from 'next/navigation';
|
|
import type { Metadata } from 'next';
|
|
import { fetchPlace } 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; phase: string }> }
|
|
|
|
export const revalidate = 604800;
|
|
export const dynamicParams = true;
|
|
|
|
const PHASES = ['primary', 'secondary'] as const;
|
|
type Phase = (typeof PHASES)[number];
|
|
|
|
const isPhase = (v: string): v is Phase => (PHASES as readonly string[]).includes(v);
|
|
|
|
async function resolve(slug: string, phase: Phase) {
|
|
return (await fetchPlace('town', slug, phase))
|
|
?? (await fetchPlace('locality', slug, phase));
|
|
}
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { place: slug, phase } = await params;
|
|
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
|
const detail = await resolve(slug, phase);
|
|
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
|
|
|
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
|
const { name } = detail.place;
|
|
return {
|
|
// Not "Ranked": the table is alphabetical, so the word would be a claim
|
|
// the page does not keep.
|
|
title: { absolute: `${word} Schools in ${name} | schoolcompare` },
|
|
description:
|
|
`Every ${phase} school in ${name}, with results, Ofsted grades and the local `
|
|
+ `average against England.`,
|
|
alternates: { canonical: absoluteUrl(`/schools/${slug}/${phase}`) },
|
|
};
|
|
}
|
|
|
|
export default async function PlacePhasePage({ params }: Props) {
|
|
const { place: slug, phase } = await params;
|
|
if (!isPhase(phase)) notFound();
|
|
const detail = await resolve(slug, phase);
|
|
if (!detail || detail.schools.length === 0) notFound();
|
|
|
|
const national = await fetchNationalAverages().catch(() => null);
|
|
const englandAverage = phase === 'secondary'
|
|
? national?.secondary?.attainment_8_score ?? null
|
|
: national?.primary?.rwm_expected_pct ?? null;
|
|
|
|
return <PlaceView detail={detail} phase={phase}
|
|
englandAverage={englandAverage} neighbours={[]} />;
|
|
}
|