refactor(app): move site routes into a (frontend) route group
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
This commit is contained in:
1 parent
eb648f3f76
commit
2437ffce42
21 files changed
+57
-26
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// Deadlines and offer days are what gets searched, and what this page is
|
||||
// genuinely best at — the countdowns are live.
|
||||
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
||||
description:
|
||||
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
||||
alternates: { canonical: absoluteUrl('/admissions') },
|
||||
};
|
||||
|
||||
export default function AdmissionsPage() {
|
||||
return <AdmissionsView />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Runtime proxy for /api/* → the FastAPI backend.
|
||||
*
|
||||
* This replaces the old next.config.js `rewrites()` proxy, whose destination
|
||||
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
|
||||
* time. Because one frontend image is promoted staging→prod, a baked hostname
|
||||
* forced every environment to name the backend identically; a mismatch (e.g.
|
||||
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
|
||||
*
|
||||
* A route handler reads process.env.FASTAPI_URL on each request, so the same
|
||||
* image adapts to whatever the backend is called in each environment.
|
||||
*/
|
||||
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
|
||||
function backendBase(): string {
|
||||
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||
}
|
||||
|
||||
// Hop-by-hop / length headers must not be copied across a proxy — undici has
|
||||
// already decoded the body, so a stale content-encoding/length corrupts it.
|
||||
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
||||
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/*
|
||||
* API paths this public proxy must not forward.
|
||||
*
|
||||
* Matched on the first segment, exactly — a prefix match would take
|
||||
* /api/flagship down with /api/flags.
|
||||
*
|
||||
* `flags` is here because GET /api/flags names every unreleased feature the
|
||||
* codebase knows about, along with whether it is on. Publishing that defeats
|
||||
* the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
|
||||
* the Docker network, which never transits this route.
|
||||
*
|
||||
* Anything else internal-only belongs here too.
|
||||
*/
|
||||
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);
|
||||
|
||||
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await ctx.params;
|
||||
if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
|
||||
return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete('host');
|
||||
headers.delete('connection');
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method: req.method,
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
cache: 'no-store',
|
||||
};
|
||||
if (METHODS_WITH_BODY.has(req.method)) {
|
||||
init.body = req.body;
|
||||
init.duplex = 'half';
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, init);
|
||||
} catch (err) {
|
||||
// e.g. DNS failure or connection refused — surface a clean 502 instead of
|
||||
// an opaque proxy crash so callers can degrade gracefully.
|
||||
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
handler as GET,
|
||||
handler as HEAD,
|
||||
handler as POST,
|
||||
handler as PUT,
|
||||
handler as PATCH,
|
||||
handler as DELETE,
|
||||
handler as OPTIONS,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Compare Page (SSR)
|
||||
* Side-by-side comparison of schools with metrics
|
||||
*/
|
||||
|
||||
import { fetchComparison, fetchMetrics } from '@/lib/api';
|
||||
import { ComparisonView } from '@/components/ComparisonView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface ComparePageProps {
|
||||
searchParams: Promise<{
|
||||
urns?: string;
|
||||
metric?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexability depends on the query string, so this cannot be a static export.
|
||||
*
|
||||
* Bare /compare is the landing page for the "compare schools" head term and
|
||||
* stays indexable. /compare?urns=… is an unbounded parameter space — 25,193
|
||||
* schools make ~317 million pairs — so it goes noindex. It stays `follow` and
|
||||
* keeps a canonical to the bare path, so the links out to each school page
|
||||
* still count.
|
||||
*/
|
||||
export async function generateMetadata(
|
||||
{ searchParams }: ComparePageProps,
|
||||
): Promise<Metadata> {
|
||||
const { urns } = await searchParams;
|
||||
|
||||
const base: Metadata = {
|
||||
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||
// schools" is how a site competes with itself; this one takes the tool
|
||||
// phrasing instead.
|
||||
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
||||
description:
|
||||
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
||||
keywords:
|
||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||
alternates: { canonical: absoluteUrl('/compare') },
|
||||
};
|
||||
|
||||
if (!urns) return base;
|
||||
|
||||
return { ...base, robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
||||
|
||||
const urns = urnsParam?.split(',').map(Number).filter(Boolean) || [];
|
||||
const selectedMetric = metricParam || 'rwm_expected_pct';
|
||||
|
||||
try {
|
||||
// Fetch comparison + metrics in parallel — they are independent.
|
||||
const [comparisonResponse, metricsResponse] = await Promise.all([
|
||||
urns.length > 0
|
||||
? fetchComparison(urnsParam!).catch((error) => {
|
||||
console.error('Failed to fetch comparison:', error);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
fetchMetrics(),
|
||||
]);
|
||||
|
||||
const metricsArray = metricsResponse?.metrics || [];
|
||||
|
||||
return (
|
||||
<ComparisonView
|
||||
initialData={comparisonResponse?.comparison ?? null}
|
||||
initialNationalAverages={comparisonResponse?.national_averages}
|
||||
initialBenchmarks={comparisonResponse?.benchmarks}
|
||||
initialUrns={urns}
|
||||
metrics={metricsArray}
|
||||
selectedMetric={selectedMetric}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for compare page:', error);
|
||||
|
||||
// Return error state with empty metrics
|
||||
return (
|
||||
<ComparisonView
|
||||
initialData={null}
|
||||
initialUrns={urns}
|
||||
metrics={[]}
|
||||
selectedMetric={selectedMetric}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
/*
|
||||
* schoolcompare — "Find the right school. For their future."
|
||||
*
|
||||
* Implements the schoolcompare brand guideline. Colour has four jobs and they
|
||||
* never borrow each other's hues:
|
||||
* brand (deep teal) identity, navigation, headings, links, primary buttons
|
||||
* action (coral) the one decisive action on a page — search, submit
|
||||
* status (green / valence against the England average. Never a CTA.
|
||||
* terracotta)
|
||||
* phase (desaturated) category chips, deliberately subordinate.
|
||||
*
|
||||
* Two deviations from the supplied swatches, both forced by contrast, both
|
||||
* confined to text and fills. Coral #F97360 carrying a white label measures
|
||||
* 2.75:1 where 4.5 is the floor, so --action is a darkened #BE3C27; the
|
||||
* original coral survives as --coral for tints, borders and illustration.
|
||||
* Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries any label while
|
||||
* --mustard stays exact for fills, rings and badges.
|
||||
*
|
||||
* Every value below clears WCAG AA on all three light grounds — Warm White,
|
||||
* white cards and Sand. Sand is the binding one; several values are a step
|
||||
* darker than they would need to be on white alone.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* ── Ground ─────────────────────────────────────────────────────── */
|
||||
--bg-primary: #FAFAF8; /* Warm White */
|
||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||
--bg-card: #FFFFFF;
|
||||
/* For gradients that have to fade to the card colour. A hardcoded white
|
||||
ramp reads as a bright band against a dark card. */
|
||||
--bg-card-rgb: 255, 255, 255;
|
||||
--surface-inverse: #0F766E;
|
||||
|
||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||
--text-primary: #1C2731; /* Ink */
|
||||
--text-secondary: #4A5560;
|
||||
--text-muted: #5F6A75; /* 4.8:1 on Sand, the worst ground */
|
||||
--text-inverse: #FFFFFF;
|
||||
|
||||
/* ── Line ───────────────────────────────────────────────────────── */
|
||||
--border: #E5E7EB; /* Light Grey */
|
||||
--border-strong: #D3D7DD;
|
||||
|
||||
/* ── Brand: deep teal ───────────────────────────────────────────── */
|
||||
--brand: #0F766E; /* exact brand value; 4.8:1 on Sand */
|
||||
--brand-strong: #0C5F58;
|
||||
--brand-stronger: #0A4D47;
|
||||
--brand-bg: rgba(15, 118, 110, 0.10);
|
||||
--brand-on: #FFFFFF;
|
||||
|
||||
/* ── Action: coral. One per screen, and only for the decisive one. ── */
|
||||
--action: #BE3C27; /* darkened coral — 5.4:1 under a white label */
|
||||
--action-strong: #A33320;
|
||||
--action-stronger: #8C2B1B;
|
||||
/* Tint of --action, not of --coral, so a coral ghost button's label and
|
||||
ground share a hue. Unused today; the dark value was already correct. */
|
||||
--action-bg: rgba(190, 60, 39, 0.14);
|
||||
--action-on: #FFFFFF;
|
||||
|
||||
/* ── Palette accents: exact brand values, for fills and tints only ── */
|
||||
--coral: #F97360;
|
||||
--mustard: #F2C94C;
|
||||
--mustard-ink: #806200; /* any label that has to sit on or beside mustard */
|
||||
--sage: #A7D7C5;
|
||||
--sky: #C7EBF5;
|
||||
--lavender: #DDD8F5;
|
||||
--sand: #F5EFE6;
|
||||
|
||||
/* ── Status: above / at / below the England average ─────────────── */
|
||||
--status-above: #36743F;
|
||||
--status-above-bg: rgba(54, 116, 63, 0.12);
|
||||
--status-below: #A9481F;
|
||||
/* The tint is the solid hue at low alpha, exactly as every other pair in
|
||||
this file is. It briefly carried the raw --coral tuple instead, which
|
||||
put a coral-pink wash behind terracotta text — the one mismatched pair
|
||||
in the palette. Alpha matches --status-above-bg so the two chips read
|
||||
as equal weight; at 0.15 the darker terracotta tint drops the label to
|
||||
4.45:1 on Warm White. */
|
||||
--status-below-bg: rgba(169, 72, 31, 0.12);
|
||||
--status-at: #5F6A75;
|
||||
--status-at-bg: rgba(95, 106, 117, 0.10);
|
||||
|
||||
/* ── Charts ─────────────────────────────────────────────────────── */
|
||||
--chart-1: #0F766E;
|
||||
--chart-2: #36743F;
|
||||
--chart-3: #806200;
|
||||
--chart-4: #A9481F;
|
||||
--chart-5: #2F6F8F;
|
||||
--chart-6: #5F4FA8;
|
||||
--chart-grid: #E5E7EB;
|
||||
--chart-reference: #5F6A75;
|
||||
|
||||
/* ── Series: up to 8 schools compared at once ───────────────────────
|
||||
Categorical, so wider than the four-hue rule — but every step is held
|
||||
to the same mid-dark tone and clears AA on the card, so they read as
|
||||
one family and work as legend text as well as chart lines. */
|
||||
--series-1: #0F766E;
|
||||
--series-2: #36743F;
|
||||
--series-3: #806200;
|
||||
--series-4: #A9481F;
|
||||
--series-5: #2F6F8F;
|
||||
--series-6: #5F4FA8;
|
||||
--series-7: #0E7A86;
|
||||
--series-8: #8A4A6B;
|
||||
|
||||
/* ── Destination measures ───────────────────────────────────────────
|
||||
Education is one hue in three steps (school-like -> college-like) so the
|
||||
education destinations read as one family; apprenticeship and employment
|
||||
are separate hues. The absence is neutral and HATCHED, never a colour:
|
||||
"activity not captured" covers independent schools, moving abroad and
|
||||
training DfE holds no data on, so rendering it as a bad outcome would be
|
||||
a factual error. The hatch is also the secondary encoding that rescues
|
||||
the neutral/blue pair, which separates at only dE 7.6 as flat fills.
|
||||
Every other adjacent pair clears dE 10.9 under protanopia. */
|
||||
--dest-sixthform: #0F766E;
|
||||
--dest-sfcollege: #4A9E96;
|
||||
--dest-fecollege: #7CBFB8;
|
||||
--dest-apprentice: #806200;
|
||||
--dest-employment: #2F6F8F;
|
||||
--dest-none: #6B7580;
|
||||
--dest-none-hatch: rgba(107, 117, 128, 0.34);
|
||||
|
||||
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
||||
--phase-primary: #0F766E;
|
||||
--phase-primary-bg: rgba(167, 215, 197, 0.40);
|
||||
--phase-primary-text: #0C5F58;
|
||||
--phase-secondary: #2F6F8F;
|
||||
--phase-secondary-bg: rgba(199, 235, 245, 0.55);
|
||||
--phase-secondary-text: #2A6180;
|
||||
--phase-all-through: #36743F;
|
||||
--phase-all-through-bg: rgba(54, 116, 63, 0.12);
|
||||
--phase-all-through-text: #2F6738;
|
||||
--phase-post16: #806200;
|
||||
--phase-post16-bg: rgba(242, 201, 76, 0.26);
|
||||
--phase-post16-text: #6E5500;
|
||||
--phase-nursery: #5F4FA8;
|
||||
--phase-nursery-bg: rgba(221, 216, 245, 0.55);
|
||||
--phase-nursery-text: #544396;
|
||||
|
||||
/* ── Sunken surface: the footer, and anything meant to read as a deep
|
||||
teal anchor band. Stays teal in both themes on purpose — it is the
|
||||
"brand in action" card from the guideline. ─────────────────── */
|
||||
--surface-sunken: #0C5F58;
|
||||
--on-sunken: #FFFFFF;
|
||||
--on-sunken-muted: #C9E4DB;
|
||||
--on-sunken-faint: #A9D6CA;
|
||||
--on-sunken-link: #C7EBF5;
|
||||
|
||||
/* ── Medals: rankings podium ────────────────────────────────────── */
|
||||
--medal-gold: #806200;
|
||||
--medal-silver: #6B7580;
|
||||
--medal-bronze: #8A5A2B;
|
||||
|
||||
/* ── Channels ───────────────────────────────────────────────────────
|
||||
CSS can't interpolate a hex token into rgba(), so any tint that needs
|
||||
an arbitrary alpha reads the raw channels from here. Keep these in
|
||||
sync with the hex tokens above — they are the same colours. */
|
||||
--brand-rgb: 15, 118, 110;
|
||||
--action-rgb: 190, 60, 39;
|
||||
--coral-rgb: 249, 115, 96;
|
||||
--sage-rgb: 167, 215, 197;
|
||||
--status-above-rgb: 54, 116, 63;
|
||||
--status-below-rgb: 169, 72, 31;
|
||||
--ink-rgb: 28, 39, 49;
|
||||
--text-inverse-rgb: 255, 255, 255;
|
||||
--shadow-rgb: 28, 39, 49;
|
||||
--muted-rgb: 95, 106, 117;
|
||||
--phase-secondary-rgb: 47, 111, 143;
|
||||
--medal-silver-rgb: 107, 117, 128;
|
||||
--medal-bronze-rgb: 138, 90, 43;
|
||||
|
||||
/* ── Elevation ──────────────────────────────────────────────────── */
|
||||
--shadow-soft: 0 1px 2px rgba(28, 39, 49, 0.04), 0 2px 8px rgba(28, 39, 49, 0.05);
|
||||
--shadow-medium: 0 4px 18px rgba(28, 39, 49, 0.09);
|
||||
--shadow-strong: 0 10px 34px rgba(28, 39, 49, 0.13);
|
||||
--scrim: rgba(28, 39, 49, 0.5);
|
||||
|
||||
/* ── Type ───────────────────────────────────────────────────────────
|
||||
Manrope for headings and key messaging; Inter for body copy, every
|
||||
control and every figure. next/font expands each variable to the
|
||||
family plus its metric-matched fallback, and the classes sit on
|
||||
<html> so :root can see them — see app/layout.tsx. */
|
||||
--font-display: var(--font-manrope), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-ui: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-prose: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
/* Type scale, straight from the guideline: H1 40/48, H2 32/40, H3 24/32,
|
||||
body large 16/24, body 14/20, small 12/16. */
|
||||
--step--2: 0.75rem;
|
||||
--step--1: 0.875rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.125rem;
|
||||
--step-2: 1.5rem;
|
||||
--step-3: 2rem;
|
||||
--step-4: 2.5rem;
|
||||
--step-5: 3rem;
|
||||
|
||||
/* ── Section rhythm ─────────────────────────────────────────────────
|
||||
The vertical gap between the bands of a page, and the gap between a
|
||||
band's header and its content. Two values, not seven: the landing page
|
||||
previously set its own margin on every band (24 / 32 / 16 / 48px, no
|
||||
scale), which is what made a designed page read as a stack of unrelated
|
||||
strips. Bands must not set their own vertical margins — the page
|
||||
container owns the gap. */
|
||||
--section-gap: 4rem;
|
||||
--section-head-gap: 1.5rem;
|
||||
|
||||
/* ── Hero artwork ───────────────────────────────────────────────────
|
||||
The landing hero is now a supplied raster illustration rather than a
|
||||
drawn SVG, so its colours are in the file, not here. What is left is the
|
||||
ground the artwork sits on and fades into.
|
||||
|
||||
--hero-ground is sampled from the artwork's own copy area, not from
|
||||
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
|
||||
seam straight down the hero.
|
||||
|
||||
The artwork's copy area is not one flat colour: it runs from a peach
|
||||
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
|
||||
the left edge is foliage rather than cream. This value is sampled from
|
||||
the middle of that pale run, where the headline and search actually sit;
|
||||
the scrim is what covers the foliage further down. */
|
||||
--hero-ground: #FEF2E1;
|
||||
--hero-ground-rgb: 254, 242, 225;
|
||||
|
||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||
rounder than the old system's 3/6/10/16. */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 22px;
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--transition-slow: 0.4s ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* Dark theme. Not in the guideline, which is light-only — but the site already
|
||||
* ships one, so removing it would be a visible regression. Built from the same
|
||||
* tokens: teal and coral are lifted independently so each still clears AA on
|
||||
* the dark ground, and the ground itself keeps the Ink hue rather than going
|
||||
* to black.
|
||||
*
|
||||
* Tokens only. Never style a component from inside this block, or the two
|
||||
* themes drift apart the first time someone edits one of them.
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-primary: #111A20;
|
||||
--bg-secondary: #16222A;
|
||||
--bg-card: #18242C;
|
||||
--bg-card-rgb: 24, 36, 44;
|
||||
--surface-inverse: #E9EEF0;
|
||||
|
||||
--text-primary: #E9EEF0;
|
||||
--text-secondary: #B4C2C7;
|
||||
--text-muted: #8B9AA1; /* 5.5:1 on the card */
|
||||
--text-inverse: #111A20;
|
||||
|
||||
--border: #26343D;
|
||||
--border-strong: #35454F;
|
||||
|
||||
--brand: #5FC7BB;
|
||||
--brand-strong: #7BD6CC;
|
||||
--brand-stronger: #9BE3DB;
|
||||
--brand-bg: rgba(95, 199, 187, 0.14);
|
||||
--brand-on: #0A1418;
|
||||
|
||||
--action: #F08A72;
|
||||
--action-strong: #F5A492;
|
||||
--action-stronger: #F8BFB1;
|
||||
--action-bg: rgba(240, 138, 114, 0.16);
|
||||
--action-on: #241009;
|
||||
|
||||
--coral: #F08A72;
|
||||
--mustard: #F2C94C;
|
||||
--mustard-ink: #EFC658;
|
||||
--sage: #8FCBB7;
|
||||
--sky: #9CD4E6;
|
||||
--lavender: #C2BBEA;
|
||||
--sand: #1B2730;
|
||||
|
||||
--status-above: #7FCB8A;
|
||||
--status-above-bg: rgba(127, 203, 138, 0.14);
|
||||
--status-below: #EFA184;
|
||||
--status-below-bg: rgba(239, 161, 132, 0.14);
|
||||
--status-at: #8B9AA1;
|
||||
--status-at-bg: rgba(139, 154, 161, 0.14);
|
||||
|
||||
--chart-1: #5FC7BB;
|
||||
--chart-2: #7FCB8A;
|
||||
--chart-3: #EFC658;
|
||||
--chart-4: #EFA184;
|
||||
--chart-5: #8FB4D9;
|
||||
--chart-6: #B6A9DD;
|
||||
--chart-grid: #26343D;
|
||||
--chart-reference: #8B9AA1;
|
||||
|
||||
--series-1: #5FC7BB;
|
||||
--series-2: #7FCB8A;
|
||||
--series-3: #EFC658;
|
||||
--series-4: #EFA184;
|
||||
--series-5: #8FB4D9;
|
||||
--series-6: #B6A9DD;
|
||||
--series-7: #6FD0DC;
|
||||
--series-8: #D99BB8;
|
||||
|
||||
/* Destinations. Not a naive inversion: the education ramp reverses
|
||||
direction so its darkest step stays the one furthest from the
|
||||
school, and each step is re-checked against the dark card. */
|
||||
--dest-sixthform: #5FC7BB;
|
||||
--dest-sfcollege: #3E9B92;
|
||||
--dest-fecollege: #2A716B;
|
||||
--dest-apprentice: #EFC658;
|
||||
--dest-employment: #8FB4D9;
|
||||
--dest-none: #8B9AA1;
|
||||
--dest-none-hatch: rgba(139, 154, 161, 0.34);
|
||||
|
||||
--phase-primary: #5FC7BB;
|
||||
--phase-primary-bg: rgba(95, 199, 187, 0.16);
|
||||
--phase-primary-text: #8ADACF;
|
||||
--phase-secondary: #8FB4D9;
|
||||
--phase-secondary-bg: rgba(143, 180, 217, 0.16);
|
||||
--phase-secondary-text: #AAC8E4;
|
||||
--phase-all-through: #7FCB8A;
|
||||
--phase-all-through-bg: rgba(127, 203, 138, 0.16);
|
||||
--phase-all-through-text: #9AD8A3;
|
||||
--phase-post16: #EFC658;
|
||||
--phase-post16-bg: rgba(242, 201, 76, 0.16);
|
||||
--phase-post16-text: #F2D179;
|
||||
--phase-nursery: #B6A9DD;
|
||||
--phase-nursery-bg: rgba(182, 169, 221, 0.16);
|
||||
--phase-nursery-text: #C8BEE6;
|
||||
|
||||
--surface-sunken: #0C5F58;
|
||||
--on-sunken: #FFFFFF;
|
||||
--on-sunken-muted: #C9E4DB;
|
||||
--on-sunken-faint: #A9D6CA;
|
||||
--on-sunken-link: #C7EBF5;
|
||||
|
||||
--medal-gold: #EFC658;
|
||||
--medal-silver: #A9B6BC;
|
||||
--medal-bronze: #C99070;
|
||||
|
||||
--brand-rgb: 95, 199, 187;
|
||||
--action-rgb: 240, 138, 114;
|
||||
--coral-rgb: 240, 138, 114;
|
||||
--sage-rgb: 143, 203, 183;
|
||||
--status-above-rgb: 127, 203, 138;
|
||||
--status-below-rgb: 239, 161, 132;
|
||||
--ink-rgb: 233, 238, 240;
|
||||
--text-inverse-rgb: 17, 26, 32;
|
||||
--shadow-rgb: 0, 0, 0;
|
||||
--muted-rgb: 139, 154, 161;
|
||||
--phase-secondary-rgb: 143, 180, 217;
|
||||
--medal-silver-rgb: 169, 182, 188;
|
||||
--medal-bronze-rgb: 201, 144, 112;
|
||||
|
||||
/* The artwork is a fixed, bright raster — it cannot be re-graded by
|
||||
token the way the drawn version was. The dark theme instead dims it
|
||||
in CSS (see .heroArt in HomeView.module.css) and fades it into this
|
||||
ground, which is the panel colour rather than the artwork's cream. */
|
||||
--hero-ground: #16222A;
|
||||
--hero-ground-rgb: 22, 34, 42;
|
||||
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||
--scrim: rgba(4, 8, 10, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
/* dvh (dynamic viewport) accounts for iOS Safari's collapsing toolbar;
|
||||
fall back to vh on older engines that don't recognise dvh. */
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
/* Suppress the iOS Safari grey tap flash — explicit :active states
|
||||
below carry the press feedback instead. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Form controls don't inherit font or colour from their parent — the UA
|
||||
supplies its own. Without this they render in the system font at pure
|
||||
black, which is nearly invisible against --text-primary in light mode and
|
||||
completely invisible on the dark ground. */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Every digit that could line up in a column does. This is a data product;
|
||||
proportional numerals in a results table read as amateur. */
|
||||
/* The interface is mostly figures, so tabular is the default here rather
|
||||
than something each component has to remember. Running prose opts back out
|
||||
below — proportional figures read better in a sentence. */
|
||||
.main,
|
||||
table,
|
||||
[data-numeric] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Prose gets the serif; the interface never does. */
|
||||
.prose {
|
||||
font-family: var(--font-prose);
|
||||
font-variant-numeric: normal;
|
||||
font-size: var(--step-0);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Provide a baseline press feedback for the most common interactive
|
||||
elements — replaces the suppressed default tap highlight. Buttons and
|
||||
.btn-* classes carry their own :active states already; this handles
|
||||
plain anchors used as inline links and bare button elements. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
a:active,
|
||||
button:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve space for the fixed mobile bottom tab bar (56px + safe-area inset). */
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip link — visible only on focus for keyboard users */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: 1rem;
|
||||
z-index: 10000;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-inverse);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
transition: top 0.15s ease;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Focus indicators — brand hue, visible on both grounds */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Shared button classes — use these across all components
|
||||
================================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary: brand fill — main CTAs (Search, Compare now) */
|
||||
.btn-primary {
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background: var(--brand-stronger);
|
||||
border-color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* Action: coral fill — the one decisive action on a page, and only that.
|
||||
*
|
||||
* The guideline gives coral to "calls to action, highlights, important
|
||||
* buttons" while its own UI-components sheet draws the primary button in
|
||||
* teal. Both are honoured by making this a scarce, separate class: teal is
|
||||
* the default primary, coral marks the single action a page exists for —
|
||||
* the search submit. Use it more than once per screen and it stops meaning
|
||||
* anything.
|
||||
*/
|
||||
.btn-action {
|
||||
background: var(--action);
|
||||
color: var(--action-on);
|
||||
border-color: var(--action);
|
||||
}
|
||||
.btn-action:hover:not(:disabled) {
|
||||
background: var(--action-strong);
|
||||
border-color: var(--action-strong);
|
||||
}
|
||||
.btn-action:active:not(:disabled) {
|
||||
background: var(--action-stronger);
|
||||
border-color: var(--action-stronger);
|
||||
}
|
||||
|
||||
/* Secondary: brand outline — supporting actions (Add to compare) */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
/* Tertiary: quiet — low-emphasis (View, Clear) */
|
||||
.btn-tertiary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-tertiary:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active toggle state — the compare button once a school is on the list */
|
||||
.btn-active {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
border: 1px solid var(--brand);
|
||||
}
|
||||
.btn-active:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Small variant */
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text
|
||||
* and a #0078A8 link blue that is the most saturated colour anywhere on the
|
||||
* site and belongs to no part of this system. Pull the map chrome onto the
|
||||
* tokens. This matters most in dark mode, where Leaflet's white attribution
|
||||
* bar would otherwise sit on a near-black page.
|
||||
*
|
||||
* The tiles themselves stay as OSM renders them; only the chrome is ours.
|
||||
*
|
||||
* Every selector here is prefixed with `html` on purpose. leaflet.css is
|
||||
* imported from a client component, so its chunk loads AFTER globals.css; at
|
||||
* equal specificity the later sheet wins and these overrides lose silently.
|
||||
* The `html` prefix takes them to 0,1,1 so load order stops mattering.
|
||||
*/
|
||||
html .leaflet-container {
|
||||
background: var(--bg-secondary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
html .leaflet-control-attribution {
|
||||
background: rgba(var(--text-inverse-rgb), 0.82);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .leaflet-control-attribution a {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
html .leaflet-bar a {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
|
||||
html .leaflet-bar a:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* The popup, which leaflet.css paints `background: white; color: #333` on both
|
||||
* the card and its tip. The content LeafletMapInner binds into it is themed —
|
||||
* the school name and the headline figure are `var(--text-primary)` — so in
|
||||
* dark mode that was #E9EEF0 on #FFFFFF, a contrast ratio of 1.17:1. The name
|
||||
* and the number were the two least readable things on the page.
|
||||
*
|
||||
* Moving the surface onto --bg-card fixes every foreground at once rather than
|
||||
* one at a time: the muted phase line goes 2.90:1 -> 5.45:1, the vs-national
|
||||
* delta 1.94:1 -> 8.14:1, the Ofsted badge 1.74:1 -> 9.11:1. In light mode
|
||||
* --bg-card is #FFFFFF, so the popup looks as it always did.
|
||||
*/
|
||||
html .leaflet-popup-content-wrapper,
|
||||
html .leaflet-popup-tip {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Leaflet's own selector is `.leaflet-container a.leaflet-popup-close-button`
|
||||
at 0,2,1 — an `html` prefix alone would lose to it. */
|
||||
html .leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .leaflet-container a.leaflet-popup-close-button:hover,
|
||||
html .leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Main content column */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.5rem;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
/* Tighten the rhythm rather than abandon it — the ratio between the
|
||||
section gap and the header gap stays the same. */
|
||||
:root {
|
||||
--section-gap: 2.75rem;
|
||||
--section-head-gap: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
||||
zero so state changes still register as changes. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Manrope, Inter } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { RouteTrail } from '@/components/RouteTrail';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
import './globals.css';
|
||||
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
// modern personality". It never sets body copy or a control.
|
||||
const manrope = Manrope({
|
||||
subsets: ['latin'],
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
// Inter carries body copy, every control and every figure — chosen in the
|
||||
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'schoolcompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'Compare Schools Side by Side | schoolcompare',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
metadataBase: new URL(SITE_URL),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
url: SITE_URL,
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
// var(--font-inter), and a custom property's var() references resolve on
|
||||
// the element that declares it. With the classes on <body>, those two are
|
||||
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
||||
// value and every font-family referencing it silently falls back — which
|
||||
// once shipped the whole site in Times. Nothing throws and no unit test
|
||||
// fails, so the e2e typeface assertion is the only guard.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
{/* data-domains: the tracker only fires on the production hostnames,
|
||||
so staging (same image, different host) never pollutes Umami */}
|
||||
<Script
|
||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||
data-performance="true"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
{/* Heatmap / session recorder — same data-domains guard so staging
|
||||
(same image, different host) never feeds the heatmap. */}
|
||||
<Script
|
||||
src="https://analytics.schoolcompare.co.uk/recorder.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{/* Records every route so funnel attribution has a previous page to
|
||||
name. document.referrer cannot: a soft navigation creates no
|
||||
document, so the browser never updates it. */}
|
||||
<RouteTrail />
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
<main id="main-content" className="main">
|
||||
{children}
|
||||
</main>
|
||||
<ComparisonToast />
|
||||
<Footer />
|
||||
</ComparisonProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Home Page (SSR)
|
||||
* Main landing page with school search and browsing
|
||||
*/
|
||||
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
|
||||
interface HomePageProps {
|
||||
searchParams: Promise<{
|
||||
search?: string;
|
||||
local_authority?: string;
|
||||
school_type?: string;
|
||||
phase?: string;
|
||||
page?: string;
|
||||
postcode?: string;
|
||||
radius?: string;
|
||||
sort?: string;
|
||||
gender?: string;
|
||||
admissions_policy?: string;
|
||||
has_sixth_form?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/*
|
||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||
* bookmark and the search result all read. `absolute` opts out of the root
|
||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
/*
|
||||
* Intent in the title, differentiator in the description.
|
||||
*
|
||||
* These queries are owned by the DfE's own "Compare school performance"
|
||||
* service, and the old title — brand first, then a near-paraphrase of that
|
||||
* service's name — gave a searcher no reason to pick us over it. It drew
|
||||
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
||||
* neighbourhood, so the ranking was never the problem.
|
||||
*
|
||||
* The title now matches what people type. The description carries the one
|
||||
* fact gov.uk does not publish: how close you had to live to get a place.
|
||||
*/
|
||||
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||
// This page reads eleven search params. They filter a result set; they do
|
||||
// not make a new document. Collapsing every combination onto "/" stops the
|
||||
// homepage competing with itself for its own head terms.
|
||||
alternates: { canonical: absoluteUrl('/') },
|
||||
};
|
||||
|
||||
// The page reads searchParams, which makes rendering dynamic by default.
|
||||
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
||||
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
||||
|
||||
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
// Await search params (Next.js 15 requirement)
|
||||
const params = await searchParams;
|
||||
|
||||
// Server-read: no flag value reaches the browser bundle. Threaded down to
|
||||
// both FilterBar instances via HomeView.
|
||||
const flags = await getFlags();
|
||||
const autosuggest = flags.school_autosuggest === true;
|
||||
|
||||
// Parse search params
|
||||
const page = parseInt(params.page || '1');
|
||||
const radius = params.radius ? parseFloat(params.radius) : undefined;
|
||||
|
||||
// Check if user has performed a search
|
||||
const hasSearchParams = !!(
|
||||
params.search ||
|
||||
params.local_authority ||
|
||||
params.school_type ||
|
||||
params.phase ||
|
||||
params.postcode ||
|
||||
params.gender ||
|
||||
params.admissions_policy ||
|
||||
params.has_sixth_form
|
||||
);
|
||||
|
||||
// Fetch data on server with error handling
|
||||
try {
|
||||
const [filtersData, dataInfo] = await Promise.all([fetchFilters(), fetchDataInfo().catch(() => null)]);
|
||||
|
||||
// Only fetch schools if there are search parameters
|
||||
let schoolsData;
|
||||
if (hasSearchParams) {
|
||||
schoolsData = await fetchSchools({
|
||||
search: params.search,
|
||||
local_authority: params.local_authority,
|
||||
school_type: params.school_type,
|
||||
phase: params.phase,
|
||||
postcode: params.postcode,
|
||||
radius,
|
||||
page,
|
||||
page_size: 50,
|
||||
gender: params.gender,
|
||||
admissions_policy: params.admissions_policy,
|
||||
has_sixth_form: params.has_sixth_form,
|
||||
});
|
||||
} else {
|
||||
// Empty state by default
|
||||
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||
// endpoint returns, and reading it silently yielded null on every request.
|
||||
const total = dataInfo?.unique_schools ?? null;
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
autosuggest={autosuggest}
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={total}
|
||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for home page:', error);
|
||||
|
||||
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
return (
|
||||
<HomeView
|
||||
autosuggest={autosuggest}
|
||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={null}
|
||||
localAuthorityCount={0}
|
||||
earliestYearLabel={null}
|
||||
latestYearLabel={null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Rankings Page (SSR)
|
||||
* Display top-ranked schools by various metrics
|
||||
*/
|
||||
|
||||
import { fetchRankings, fetchFilters, fetchMetrics } from '@/lib/api';
|
||||
import { RankingsView } from '@/components/RankingsView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
interface RankingsPageProps {
|
||||
searchParams: Promise<{
|
||||
metric?: string;
|
||||
local_authority?: string;
|
||||
year?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||
// phrase parents actually search, and it spikes each results day.
|
||||
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
||||
description:
|
||||
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||
// now. W3 replaces them with real indexable paths.
|
||||
alternates: { canonical: absoluteUrl('/rankings') },
|
||||
};
|
||||
|
||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||
// can still use Next.js's per-call revalidate cache.
|
||||
|
||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||
|
||||
const phase = phaseParam || 'primary';
|
||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||
|
||||
// Fetch rankings data with error handling
|
||||
try {
|
||||
const [rankingsResponse, filtersResponse, metricsResponse] = await Promise.all([
|
||||
fetchRankings({
|
||||
metric,
|
||||
local_authority,
|
||||
year,
|
||||
limit: 100,
|
||||
phase,
|
||||
}),
|
||||
fetchFilters(),
|
||||
fetchMetrics(),
|
||||
]);
|
||||
|
||||
// Metrics is already an array
|
||||
const metricsArray = metricsResponse?.metrics || [];
|
||||
|
||||
return (
|
||||
<RankingsView
|
||||
rankings={rankingsResponse?.rankings || []}
|
||||
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
metrics={metricsArray}
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for rankings page:', error);
|
||||
|
||||
// Return error state with empty data
|
||||
return (
|
||||
<RankingsView
|
||||
rankings={[]}
|
||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||
metrics={[]}
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Individual School Page (SSR)
|
||||
* Dynamic route for school details with full SEO optimization
|
||||
* URL format: /school/138267-school-name-here
|
||||
*/
|
||||
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
computeSchoolFlags, buildNavItems,
|
||||
computeSecondaryFlags, buildSecondaryNavItems,
|
||||
} from '@/lib/schoolSections';
|
||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||
import type { NationalAverages } from '@/lib/types';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
* Enumerate every school for static generation at build time.
|
||||
*
|
||||
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
|
||||
* (or when the API can't be reached), we return an empty list and the route
|
||||
* falls back to ISR on first request — `dynamicParams = true` covers it.
|
||||
*/
|
||||
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
|
||||
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
|
||||
|
||||
const params: Array<{ slug: string }> = [];
|
||||
const PAGE_SIZE = 500;
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
try {
|
||||
do {
|
||||
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
|
||||
for (const s of res.schools) {
|
||||
const path = schoolUrl(s.urn, s.school_name);
|
||||
const slug = path.replace('/school/', '');
|
||||
params.push({ slug });
|
||||
}
|
||||
totalPages = res.total_pages || 1;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
|
||||
return params;
|
||||
}
|
||||
|
||||
interface SchoolPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SchoolPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchSchoolDetails(urn);
|
||||
const { school_info } = data;
|
||||
|
||||
const canonicalPath = schoolUrl(urn, school_info.school_name);
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| (data.yearly_data ?? []).some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
const la = school_info.local_authority ? ` in ${school_info.local_authority}` : '';
|
||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||
const description = isAllThrough
|
||||
? `View KS2 SATs and GCSE results for ${school_info.school_name}${la}. All-through school covering primary and secondary education.`
|
||||
: isSecondary
|
||||
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${la}.`
|
||||
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${la}. Compare reading, writing, and maths results.`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: isAllThrough
|
||||
? `${school_info.school_name}, KS2 results, GCSE results, all-through school, ${school_info.local_authority}, SATs, Attainment 8`
|
||||
: isSecondary
|
||||
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
|
||||
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: absoluteUrl(canonicalPath),
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
alternates: {
|
||||
canonical: absoluteUrl(canonicalPath),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: 'School Not Found',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ISR: regenerate at most once a week per slug. School data updates annually,
|
||||
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const { slug } = await params;
|
||||
const urn = parseSchoolSlug(slug);
|
||||
|
||||
// Validate URN format
|
||||
if (!urn || urn < 100000 || urn > 999999) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch school data. National averages feed the England-comparison deltas
|
||||
// across most sections; fetching them here rather than in a client effect
|
||||
// keeps those sections server-renderable and puts the deltas in the initial
|
||||
// HTML. They are supplementary, so they degrade to null rather than 404ing
|
||||
// the page.
|
||||
let data;
|
||||
let nationalAvg: NationalAverages | null = null;
|
||||
try {
|
||||
[data, nationalAvg] = await Promise.all([
|
||||
fetchSchoolDetails(urn),
|
||||
fetchNationalAverages().catch(() => null),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch school ${urn}:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
if (slug !== canonicalSlug) {
|
||||
redirect(`/school/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const phaseStr = (school_info.phase ?? '').toLowerCase();
|
||||
const isAllThrough = phaseStr === 'all-through';
|
||||
// All-through schools go to PrimarySchoolSections (renders both KS2 + KS4).
|
||||
// SecondarySchoolSections is KS4-only, so all-through schools would lose SATs data.
|
||||
const isSecondary = !isAllThrough && (
|
||||
phaseStr.includes('secondary')
|
||||
|| yearly_data.some((d: any) => d.attainment_8_score != null)
|
||||
);
|
||||
|
||||
// Section list is computed on the server so the client shell never needs to
|
||||
// derive it -- and so it can never disagree with what the sections render.
|
||||
const sectionInput = {
|
||||
schoolInfo: school_info, yearlyData: yearly_data,
|
||||
absenceData: absence_data, census: census ?? null,
|
||||
deprivation: deprivation ?? null, finance: finance ?? null,
|
||||
destinations: destinations ?? null,
|
||||
};
|
||||
const primaryFlags = computeSchoolFlags(sectionInput);
|
||||
const secondaryFlags = computeSecondaryFlags(sectionInput);
|
||||
const navInput = {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
admissionDistance: admission_distance ?? null,
|
||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
name: school_info.school_name,
|
||||
identifier: school_info.urn.toString(),
|
||||
...(school_info.address && {
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: school_info.address,
|
||||
addressLocality: school_info.local_authority || undefined,
|
||||
postalCode: school_info.postcode || undefined,
|
||||
addressCountry: 'GB',
|
||||
},
|
||||
}),
|
||||
...(school_info.latitude && school_info.longitude && {
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: school_info.latitude,
|
||||
longitude: school_info.longitude,
|
||||
},
|
||||
}),
|
||||
...(school_info.school_type && {
|
||||
additionalType: school_info.school_type,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
{isSecondary ? (
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
census={census ?? null}
|
||||
navItems={secondaryNavItems}
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
destinations={destinations ?? null}
|
||||
flags={secondaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
) : (
|
||||
<SchoolDetailShell
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
census={census ?? null}
|
||||
navItems={primaryNavItems}
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
schoolInfo={school_info}
|
||||
yearlyData={yearly_data}
|
||||
absenceData={absence_data}
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
flags={primaryFlags}
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Phase variants of an authority page.
|
||||
*
|
||||
* The spec called for these; the plan built the bare authority route and
|
||||
* dropped them. Nothing caught it, because the sitemap is written from the
|
||||
* place registry — which was right about them all along — while the routes
|
||||
* were written by hand. 302 authority phase URLs were submitted to Google and
|
||||
* every one 404'd, and every authority page linked to a phase page in the
|
||||
* *town* namespace, which is a different set of schools entirely.
|
||||
*
|
||||
* "Primary schools in Kent" is the query these serve, and it is a real one:
|
||||
* admissions are authority-run, so the authority is the unit a parent thinks
|
||||
* in when they have not settled on a town.
|
||||
*/
|
||||
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<{ la: 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);
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la, phase } = await params;
|
||||
if (!isPhase(phase)) return { title: 'Place Not Found' };
|
||||
const detail = await fetchPlace('authority', la, phase);
|
||||
if (!detail || detail.schools.length === 0) return { title: 'Place Not Found' };
|
||||
|
||||
const word = phase === 'secondary' ? 'Secondary' : 'Primary';
|
||||
const { name } = detail.place;
|
||||
return {
|
||||
// "Local Authority" stays in the title for the same reason it is on the
|
||||
// bare authority page: 67 town names collide with an authority name, and
|
||||
// a reader landing on both needs to know which set each covers.
|
||||
title: { absolute: `${word} Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`Every ${phase} school in the ${name} local authority, with results, Ofsted `
|
||||
+ `grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}/${phase}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPhasePage({ params }: Props) {
|
||||
const { la, phase } = await params;
|
||||
if (!isPhase(phase)) notFound();
|
||||
const detail = await fetchPlace('authority', la, 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={[]} />;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Local authority pages.
|
||||
*
|
||||
* A separate namespace from /schools/[place] because 67 town names collide
|
||||
* with an authority name and neither set contains the other — Bedford the
|
||||
* town holds 104 schools, Bedford the authority 86, because postal towns
|
||||
* cross authority boundaries. The title says "Local Authority" so a reader
|
||||
* landing on both knows which set each covers.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ la: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ la: string }>> {
|
||||
// Gated like every other prerender in this app. There are only ~154
|
||||
// authorities, but "few enough to always build" still means the API must be
|
||||
// reachable at build time, and in CI it is not — the build fails with
|
||||
// ECONNREFUSED rather than degrading. The catch is the same fallback the
|
||||
// school route uses.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'authority')
|
||||
.map((p) => ({ la: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools in ${name} — Local Authority | schoolcompare` },
|
||||
description:
|
||||
`All ${count} schools in the ${name} local authority, with SATs and GCSE results, `
|
||||
+ `Ofsted grades and the authority average against England.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/authority/${la}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AuthorityPage({ params }: Props) {
|
||||
const { la } = await params;
|
||||
const detail = await fetchPlace('authority', la);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Postcode district pages.
|
||||
*
|
||||
* No phase variants: nobody searches "primary schools in SW11", so the
|
||||
* variants would be pages without demand. These exist to catch
|
||||
* "schools near <postcode>" and to give London districts a geographic page
|
||||
* where the GIAS town field cannot.
|
||||
*/
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchPlace, fetchPlaces } from '@/lib/places';
|
||||
import { fetchNationalAverages } from '@/lib/api';
|
||||
import { PlaceView } from '@/components/places/PlaceView';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
|
||||
interface Props { params: Promise<{ outcode: string }> }
|
||||
|
||||
export const revalidate = 604800;
|
||||
export const dynamicParams = true;
|
||||
|
||||
export async function generateStaticParams(): Promise<Array<{ outcode: string }>> {
|
||||
// 1,760 of these; same CI budget argument as the town routes.
|
||||
if (process.env.PRERENDER_PLACES !== '1') return [];
|
||||
try {
|
||||
return (await fetchPlaces())
|
||||
.filter((p) => p.kind === 'outcode')
|
||||
.map((p) => ({ outcode: p.slug }));
|
||||
} catch (error) {
|
||||
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) return { title: 'Place Not Found' };
|
||||
|
||||
const { name, count } = detail.place;
|
||||
return {
|
||||
title: { absolute: `Schools near ${name} | schoolcompare` },
|
||||
description:
|
||||
`${count} schools in the ${name} postcode district, with results, Ofsted grades `
|
||||
+ `and how close you had to live to get a place.`,
|
||||
alternates: { canonical: absoluteUrl(`/schools/near/${outcode}`) },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OutcodePage({ params }: Props) {
|
||||
const { outcode } = await params;
|
||||
const detail = await fetchPlace('outcode', outcode);
|
||||
if (!detail) notFound();
|
||||
|
||||
const national = await fetchNationalAverages().catch(() => null);
|
||||
return (
|
||||
<PlaceView
|
||||
detail={detail}
|
||||
englandAverage={national?.primary?.rwm_expected_pct ?? null}
|
||||
neighbours={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
return proxySitemap('/sitemap.xml');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxySitemap } from '@/lib/sitemapProxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Children are /sitemaps/static.xml and /sitemaps/schools-{n}.xml. The name is
|
||||
* validated here rather than passed through, so this route cannot be used to
|
||||
* reach arbitrary backend paths.
|
||||
*/
|
||||
const CHILD = /^(static|schools-\d+|places-\d+|outcodes-\d+)\.xml$/;
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ parts: string[] }> },
|
||||
) {
|
||||
const { parts } = await params;
|
||||
const name = parts.join('/');
|
||||
if (!CHILD.test(name)) {
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
return proxySitemap(`/sitemaps/${name}`);
|
||||
}
|
||||
Reference in new issue
Block a user