PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
- The rows' narrow layout now switches at a 608px list, not 600px. Below 769px the page pads 1rem each side, so a 640px screen gives a 608px list: exactly the old max-width: 640px media query, where 600px left 633-640px screens on the wide layout. - rowContainerGuard.test.ts fails if anything other than HomeView renders SchoolRow or SecondarySchoolRow, or if one of HomeView's row lists loses its `results` container. Outside one the rows silently keep their wide layout on phones. (Checked: HomeView is the only importer today.) - Picking a pin from the list beside the map now works from the keyboard: each row carries a "Show <school> on the map" button, visually hidden until focused, with aria-pressed for the selected school. The row itself cannot be the button, since it holds links and buttons of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
928 lines
38 KiB
TypeScript
928 lines
38 KiB
TypeScript
/**
|
||
* HomeView Component
|
||
* Client-side home page view with search and filtering
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||
import { FilterBar } from './FilterBar';
|
||
import { SchoolRow } from './SchoolRow';
|
||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||
import { SchoolMap } from './SchoolMap';
|
||
import { EmptyState } from './EmptyState';
|
||
import { HeroIllustration } from './Illustration';
|
||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||
import { track } from '@/lib/analytics';
|
||
import styles from './HomeView.module.css';
|
||
|
||
interface HomeViewProps {
|
||
initialSchools: SchoolsResponse;
|
||
filters: Filters;
|
||
totalSchools?: number | null;
|
||
// Slot props for static markup the server pre-renders so it stays out of
|
||
// the client bundle. Server passes null when the landing sections shouldn't
|
||
// show (e.g. an active search).
|
||
howItWorks?: React.ReactNode;
|
||
editorial?: React.ReactNode;
|
||
/** Server-read feature flag, threaded to both FilterBar instances. */
|
||
autosuggest?: boolean;
|
||
}
|
||
|
||
/*
|
||
* The view a postcode search opens in, on every screen size. The map is on
|
||
* trial as the default (2026-09-30); results_view_changed shows how often
|
||
* readers switch away from it. Name searches have no map and always list.
|
||
*/
|
||
const DEFAULT_LOCATION_VIEW: 'list' | 'map' = 'map';
|
||
|
||
function daysUntil(month: number, day: number): number {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const y = today.getFullYear();
|
||
let target = new Date(y, month - 1, day);
|
||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||
}
|
||
|
||
function nextOccurrence(month: number, day: number): Date {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const y = today.getFullYear();
|
||
const target = new Date(y, month - 1, day);
|
||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||
}
|
||
|
||
function formatCountdownDate(month: number, day: number): string {
|
||
return nextOccurrence(month, day)
|
||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||
}
|
||
|
||
function formatShortDate(month: number, day: number): string {
|
||
return nextOccurrence(month, day)
|
||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||
}
|
||
|
||
interface CountdownChipData {
|
||
type: 'deadline' | 'offer';
|
||
track: string;
|
||
milestone: string;
|
||
month: number;
|
||
day: number;
|
||
}
|
||
|
||
interface AdmissionsMilestone {
|
||
chip: CountdownChipData;
|
||
/** Whole days from today. Always present — computed during render. */
|
||
days: number;
|
||
/** "Sat, 31 October 2026" — for the headline bar. */
|
||
dateLabel: string;
|
||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||
shortDate: string;
|
||
}
|
||
|
||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||
];
|
||
|
||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||
local because they exist for this one row and nothing else. */
|
||
|
||
const ICON_PROPS = {
|
||
viewBox: '0 0 24 24',
|
||
width: 21,
|
||
height: 21,
|
||
fill: 'none',
|
||
stroke: 'currentColor',
|
||
strokeWidth: 2,
|
||
strokeLinecap: 'round',
|
||
strokeLinejoin: 'round',
|
||
'aria-hidden': true,
|
||
} as const;
|
||
|
||
function ShieldCheckIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS}>
|
||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||
<path d="m9 12 2 2 4-4" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function BarsIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS}>
|
||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function HeartIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS}>
|
||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CalendarIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS}>
|
||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* List / Map glyphs for the results view switch and its floating twin. */
|
||
function ListIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS} width={18} height={18}>
|
||
<path d="M9 6h11M9 12h11M9 18h11" />
|
||
<circle cx="4.5" cy="6" r="1" />
|
||
<circle cx="4.5" cy="12" r="1" />
|
||
<circle cx="4.5" cy="18" r="1" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function MapIcon() {
|
||
return (
|
||
<svg {...ICON_PROPS} width={18} height={18}>
|
||
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
|
||
<path d="M9 4v14M15 6v14" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
interface ValueProp {
|
||
icon: React.ReactNode;
|
||
tintClass: string;
|
||
title: string;
|
||
body: string;
|
||
}
|
||
|
||
/*
|
||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||
* favourites). That feature does not exist in this product, so it is
|
||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||
* and is backed by the next-deadline bar further down this page.
|
||
*
|
||
* Every claim here must name something the product actually does. Two of the
|
||
* four previously did not: "up to three schools" contradicted the basket limit
|
||
* of five (and the card further down the page, which correctly said five), and
|
||
* "class sizes" described data the codebase has never held — grep for it and
|
||
* this line was the only hit. Both are corrected below against the real
|
||
* fields, which live in components/school/InclusionSection.tsx.
|
||
* The limit is now five per phase: MAX_PER_GROUP in lib/compareLogic.ts.
|
||
*/
|
||
const VALUE_PROPS: ValueProp[] = [
|
||
{
|
||
icon: <ShieldCheckIcon />,
|
||
tintClass: styles.propIconTrust,
|
||
/*
|
||
* "Built on official data", never "Official".
|
||
*
|
||
* The previous title was "Official & trusted", whose grammatical subject
|
||
* is this site — it reads as a claim that schoolcompare is itself an
|
||
* official service. It is not: it is an independent site that republishes
|
||
* official figures. The distinction is the difference between describing
|
||
* the data and describing ourselves, and only the first is true.
|
||
*
|
||
* Everywhere else the word appears ("official DfE figures", "the official
|
||
* figure isn't in") it already qualifies the data, which is correct and
|
||
* should stay.
|
||
*/
|
||
title: 'Built on official data',
|
||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||
},
|
||
{
|
||
icon: <BarsIcon />,
|
||
tintClass: styles.propIconCompare,
|
||
title: 'Easy to compare',
|
||
body: 'Up to five primary and five secondary schools side by side, on the measures that matter.',
|
||
},
|
||
{
|
||
icon: <HeartIcon />,
|
||
tintClass: styles.propIconContext,
|
||
title: 'Beyond the numbers',
|
||
body: 'Not just results: SEN support, pupil premium and attendance too.',
|
||
},
|
||
{
|
||
icon: <CalendarIcon />,
|
||
tintClass: styles.propIconDeadline,
|
||
title: 'Never miss a deadline',
|
||
body: 'Application and offer dates for both primary and secondary.',
|
||
},
|
||
];
|
||
|
||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||
/*
|
||
* The reader's choice, once they make one; until then the default for the
|
||
* kind of search. Derived rather than seeded into state, because a hero
|
||
* search reaches the results by client-side navigation: the same instance
|
||
* gets new props, and a state seeded on the landing page would stay "list".
|
||
*/
|
||
const [chosenView, setChosenView] = useState<'list' | 'map' | null>(null);
|
||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||
const sortOrder = searchParams.get('sort') || 'default';
|
||
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||
const [currentPage, setCurrentPage] = useState(initialSchools.page);
|
||
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
|
||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
|
||
const [nationalAvgRwm, setNationalAvgRwm] = useState<number | null>(null);
|
||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||
const resultsRef = useRef<HTMLElement>(null);
|
||
const mapListRef = useRef<HTMLDivElement>(null);
|
||
|
||
const mapParamsRef = useRef<string>('');
|
||
const loadMoreController = useRef<AbortController | null>(null);
|
||
// Identity changes even for A → B → A, so an old A response stays stale.
|
||
const searchScope = useRef({ key: searchParams.toString() });
|
||
if (searchScope.current.key !== searchParams.toString()) {
|
||
searchScope.current = { key: searchParams.toString() };
|
||
}
|
||
useEffect(() => {
|
||
setIsLoadingMore(false);
|
||
return () => { loadMoreController.current?.abort(); };
|
||
}, [searchParams]);
|
||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||
const [geoError, setGeoError] = useState<string | null>(null);
|
||
/*
|
||
* Computed during render, on the server as well as the client, so the
|
||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||
*
|
||
* The previous version deferred this to an effect to dodge a hydration
|
||
* mismatch, which meant the section had to reserve its own height — and the
|
||
* reservation was a single guessed number for a block whose supporting line
|
||
* wraps to a different height at every width. Measured, it was short at
|
||
* every breakpoint, shifting the page by up to 108px on a phone.
|
||
*
|
||
* The mismatch it was dodging is real but tiny: a server rendering at
|
||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||
*/
|
||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||
chip,
|
||
days: daysUntil(chip.month, chip.day),
|
||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||
shortDate: formatShortDate(chip.month, chip.day),
|
||
}));
|
||
measured.sort((a, b) => a.days - b.days);
|
||
return measured;
|
||
});
|
||
|
||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||
const isLocationSearch = !!searchParams.get('postcode');
|
||
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
||
const currentPhase = searchParams.get('phase') || '';
|
||
const secondaryCount = allSchools.filter(s => s.attainment_8_score != null).length;
|
||
const primaryCount = allSchools.filter(s => s.rwm_expected_pct != null).length;
|
||
const isSecondaryView = currentPhase.toLowerCase().includes('secondary')
|
||
|| (!currentPhase && secondaryCount > primaryCount);
|
||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||
const resultsView: 'list' | 'map' = chosenView ?? (isLocationSearch ? DEFAULT_LOCATION_VIEW : 'list');
|
||
|
||
// The map view fills the screen below the pinned toolbar, whose height
|
||
// depends on how its controls wrap. Measure it rather than guess.
|
||
const [toolbarHeight, setToolbarHeight] = useState<number | null>(null);
|
||
useEffect(() => {
|
||
const el = toolbarRef.current;
|
||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||
const observer = new ResizeObserver(() => setToolbarHeight(Math.round(el.getBoundingClientRect().height)));
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, [isSearchActive]);
|
||
|
||
// Reset pagination and map cache when search params change
|
||
useEffect(() => {
|
||
const newParamsStr = searchParams.toString();
|
||
if (newParamsStr !== prevSearchParamsRef.current) {
|
||
prevSearchParamsRef.current = newParamsStr;
|
||
mapParamsRef.current = ''; // allow map to re-fetch for new search
|
||
setAllSchools(initialSchools.schools);
|
||
setCurrentPage(initialSchools.page);
|
||
setHasMore(initialSchools.total_pages > 1);
|
||
setMapSchools([]);
|
||
}
|
||
}, [searchParams, initialSchools]);
|
||
|
||
// Close bottom sheet if we change views or search
|
||
useEffect(() => {
|
||
setSelectedMapSchool(null);
|
||
}, [resultsView, searchParams]);
|
||
|
||
// Fetch all schools within radius when map view is active.
|
||
// Guard with a ref so toggling back to map never re-fetches the same params.
|
||
useEffect(() => {
|
||
if (resultsView !== 'map' || !isLocationSearch) return;
|
||
const paramsKey = searchParams.toString();
|
||
if (paramsKey === mapParamsRef.current) return;
|
||
const controller = new AbortController();
|
||
const scope = searchScope.current;
|
||
const current = () => !controller.signal.aborted && searchScope.current === scope;
|
||
setIsLoadingMap(true);
|
||
const params: Record<string, any> = {};
|
||
searchParams.forEach((value, key) => { params[key] = value; });
|
||
params.page = 1;
|
||
params.page_size = 500;
|
||
fetchSchools(params, { cache: 'no-store', signal: controller.signal })
|
||
.then(r => {
|
||
if (!current()) return;
|
||
mapParamsRef.current = paramsKey;
|
||
setMapSchools(r.schools);
|
||
})
|
||
.catch(() => {
|
||
if (current()) setMapSchools(initialSchools.schools);
|
||
// No cache marker on failure: opening the map again retries.
|
||
})
|
||
.finally(() => { if (current()) setIsLoadingMap(false); });
|
||
return () => controller.abort();
|
||
}, [resultsView, searchParams, initialSchools.schools]);
|
||
|
||
// Fetch LA averages when secondary or mixed schools are visible
|
||
useEffect(() => {
|
||
if (!isSecondaryView && !isMixedView) return;
|
||
fetchLAaverages({ cache: 'force-cache' })
|
||
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
|
||
.catch(() => {});
|
||
}, [isSecondaryView, isMixedView]);
|
||
|
||
// Fetch national averages (supplementary — never blocks render)
|
||
useEffect(() => {
|
||
fetchNationalAverages()
|
||
.then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const handleLoadMore = async () => {
|
||
if (isLoadingMore || !hasMore) return;
|
||
track('results_load_more', { next_page: currentPage + 1 });
|
||
setIsLoadingMore(true);
|
||
const scope = searchScope.current;
|
||
const controller = new AbortController();
|
||
loadMoreController.current?.abort();
|
||
loadMoreController.current = controller;
|
||
const current = () => !controller.signal.aborted && searchScope.current === scope;
|
||
try {
|
||
const params: Record<string, any> = {};
|
||
searchParams.forEach((value, key) => { params[key] = value; });
|
||
params.page = currentPage + 1;
|
||
params.page_size = initialSchools.page_size;
|
||
const response = await fetchSchools(params, { cache: 'no-store', signal: controller.signal });
|
||
if (!current()) return;
|
||
setAllSchools(prev => [...prev, ...response.schools]);
|
||
setCurrentPage(response.page);
|
||
setHasMore(response.page < response.total_pages);
|
||
} catch {
|
||
// silently ignore
|
||
} finally {
|
||
if (current()) setIsLoadingMore(false);
|
||
}
|
||
};
|
||
|
||
const handleNearMe = useCallback(() => {
|
||
if (!navigator.geolocation) {
|
||
track('near_me_used', { outcome: 'unsupported' });
|
||
setGeoState('error');
|
||
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
|
||
return;
|
||
}
|
||
setGeoState('requesting');
|
||
setGeoError(null);
|
||
navigator.geolocation.getCurrentPosition(
|
||
async (position) => {
|
||
const { latitude, longitude } = position.coords;
|
||
try {
|
||
const res = await fetch(
|
||
`https://api.postcodes.io/postcodes?lon=${longitude}&lat=${latitude}&limit=1`
|
||
);
|
||
const data = await res.json();
|
||
if (data.result && data.result.length > 0) {
|
||
const postcode = data.result[0].postcode as string;
|
||
setGeoState('idle');
|
||
track('near_me_used', { outcome: 'granted' });
|
||
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
|
||
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||
} else {
|
||
track('near_me_used', { outcome: 'no_postcode' });
|
||
setGeoState('error');
|
||
setGeoError('No postcode found near your location. Try entering one above.');
|
||
}
|
||
} catch {
|
||
track('near_me_used', { outcome: 'lookup_error' });
|
||
setGeoState('error');
|
||
setGeoError('Could not look up your location. Please try again.');
|
||
}
|
||
},
|
||
(err) => {
|
||
setGeoState('error');
|
||
if (err.code === err.PERMISSION_DENIED) {
|
||
track('near_me_used', { outcome: 'denied' });
|
||
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||
} else {
|
||
track('near_me_used', { outcome: 'error' });
|
||
setGeoError('Could not get your location. Please try again or enter a postcode.');
|
||
}
|
||
},
|
||
{ timeout: 10000, maximumAge: 60000 }
|
||
);
|
||
}, [router]);
|
||
|
||
const sortSchools = (list: School[]) => [...list].sort((a, b) => {
|
||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
||
if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity);
|
||
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||
return 0;
|
||
});
|
||
const sortedSchools = sortSchools(allSchools);
|
||
// The map gets a stable array (a new one rebuilds every pin and closes the
|
||
// open card); only the list beside it is sorted.
|
||
const mapResultSchools = mapSchools.length > 0 ? mapSchools : initialSchools.schools;
|
||
const mapListSchools = sortSchools(mapResultSchools);
|
||
const radiusMiles = initialSchools.location_info
|
||
? Number((initialSchools.location_info.radius / 1.60934).toFixed(1))
|
||
: undefined;
|
||
|
||
// Empty-results sentinel: track when a search returns nothing.
|
||
useEffect(() => {
|
||
if (!isSearchActive) return;
|
||
if (initialSchools.total !== 0) return;
|
||
track('empty_results', {
|
||
query_length: (searchParams.get('search') || '').length,
|
||
has_postcode: !!searchParams.get('postcode'),
|
||
});
|
||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||
|
||
/*
|
||
* The coverage figure, or nothing at all.
|
||
*
|
||
* This used to be the string "24,000+", hardcoded in three places while the
|
||
* database held 27,230 — and the fact box that was meant to show the live
|
||
* number silently rendered its fallback, because DataInfoResponse declared a
|
||
* `total_schools` field the API has never sent. Now there is one source: if
|
||
* the real count is unavailable the sentence simply omits it rather than
|
||
* inventing a floor.
|
||
*/
|
||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||
|
||
/*
|
||
* The headline milestone is the next thing a parent can actually miss — an
|
||
* application deadline. Offer days are dates you receive something on, so
|
||
* they belong in the supporting line, not in the page's largest numeral.
|
||
* Four equally-sized cards counting down to things 245 days away was the
|
||
* loudest element on the page and asked nothing of anyone.
|
||
*/
|
||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||
|
||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||
// split which surface drives compare adds.
|
||
const addSchoolFromSearch = useCallback((school: School) => {
|
||
addSchool(school);
|
||
track('compare_school_added', {
|
||
urn: school.urn,
|
||
from: 'search',
|
||
selection_count_after: selectedSchools.length + 1,
|
||
});
|
||
}, [addSchool, selectedSchools.length]);
|
||
|
||
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
||
const compareUrns = useMemo(() => selectedSchools.map(s => s.urn), [selectedSchools]);
|
||
|
||
/*
|
||
* Below 769px the map view hides its list pane (HomeView.module.css), and
|
||
* with the map now the default on phones, every postcode search there
|
||
* would otherwise build up to 500 hidden cards. Decided after mount, so
|
||
* the server's HTML and the first client render still agree.
|
||
*/
|
||
const [listPaneShown, setListPaneShown] = useState(true);
|
||
useEffect(() => {
|
||
if (typeof window.matchMedia !== 'function') return;
|
||
const query = window.matchMedia('(min-width: 769px)');
|
||
const update = () => setListPaneShown(query.matches);
|
||
update();
|
||
query.addEventListener('change', update);
|
||
return () => query.removeEventListener('change', update);
|
||
}, []);
|
||
const clearMapSelection = useCallback(() => setSelectedMapSchool(null), []);
|
||
|
||
// A pin chosen on the map brings its card into view in the list beside it.
|
||
useEffect(() => {
|
||
if (!selectedMapSchool) return;
|
||
const card = mapListRef.current?.querySelector<HTMLElement>(`[data-urn="${selectedMapSchool.urn}"]`);
|
||
if (card?.offsetParent) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}, [selectedMapSchool]);
|
||
|
||
/*
|
||
* Switching view from far down the list would otherwise leave the reader
|
||
* looking at whatever now sits at that scroll offset, usually the footer,
|
||
* because the map layout is much shorter than a long list. Bring the top of
|
||
* the results back to just under the pinned toolbar instead.
|
||
*/
|
||
const changeView = (view: 'list' | 'map', via: 'toolbar' | 'floating') => {
|
||
if (view === resultsView) return;
|
||
setChosenView(view);
|
||
track('results_view_changed', { view, via });
|
||
requestAnimationFrame(() => {
|
||
const results = resultsRef.current;
|
||
const toolbar = toolbarRef.current;
|
||
if (!results || !toolbar) return;
|
||
const toolbarBottom = toolbar.getBoundingClientRect().bottom;
|
||
const resultsTop = results.getBoundingClientRect().top;
|
||
if (resultsTop < toolbarBottom) {
|
||
window.scrollTo({ top: window.scrollY + resultsTop - toolbarBottom - 12 });
|
||
}
|
||
});
|
||
};
|
||
|
||
const isMapView = initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch;
|
||
|
||
// One school as a results row: the list view, the list beside the map and
|
||
// the phone's bottom sheet all draw the same thing.
|
||
const renderRow = (school: School) => (
|
||
school.attainment_8_score != null ? (
|
||
<SecondarySchoolRow
|
||
key={school.urn}
|
||
school={school}
|
||
isLocationSearch={isLocationSearch}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
onRemoveFromCompare={removeSchool}
|
||
isInCompare={compareUrns.includes(school.urn)}
|
||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||
/>
|
||
) : (
|
||
<SchoolRow
|
||
key={school.urn}
|
||
school={school}
|
||
isLocationSearch={isLocationSearch}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
onRemoveFromCompare={removeSchool}
|
||
isInCompare={compareUrns.includes(school.urn)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
)
|
||
);
|
||
|
||
// The count and the sort. Above the list in list view; at the top of the
|
||
// list pane, beside the map, in map view.
|
||
const resultsHeader = (
|
||
<div className={styles.resultsHeader}>
|
||
<h2 aria-live="polite" aria-atomic="true">
|
||
{isLocationSearch && initialSchools.location_info
|
||
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
||
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
|
||
}
|
||
</h2>
|
||
<div className={styles.resultsHeaderActions}>
|
||
<select
|
||
value={sortOrder}
|
||
onChange={e => {
|
||
const params = new URLSearchParams(searchParams);
|
||
if (e.target.value === 'default') {
|
||
params.delete('sort');
|
||
} else {
|
||
params.set('sort', e.target.value);
|
||
}
|
||
router.push(`${pathname}?${params.toString()}`);
|
||
}}
|
||
className={styles.sortSelect}
|
||
>
|
||
<option value="default">Sort: Relevance</option>
|
||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||
{(isSecondaryView || isMixedView) && <option value="att8_asc">Lowest Attainment 8</option>}
|
||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||
<option value="name_asc">Name A–Z</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||
CSS); the search arrangement is a filter bar directly above its results
|
||
and wants none of it. */
|
||
<div
|
||
className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}
|
||
style={toolbarHeight ? { '--toolbar-h': `${toolbarHeight}px` } as React.CSSProperties : undefined}
|
||
>
|
||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||
the brand landscape bleeding to the panel edge on the right. The
|
||
search lives inside the panel here and above the results elsewhere,
|
||
which is why FilterBar is rendered in two places rather than moved.
|
||
The hero and the four value props are one group, so they sit closer
|
||
together than the page's section gap. */}
|
||
{!isSearchActive ? (
|
||
<div className={styles.heroGroup}>
|
||
<section className={styles.heroPanel}>
|
||
<div className={styles.heroContent}>
|
||
<span className={styles.heroEyebrow}>
|
||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||
Updated for the 2026/27 admissions round
|
||
</span>
|
||
<h1 className={styles.heroTitle}>
|
||
Find the school where your child will{' '}
|
||
<em className={styles.heroEmph}>flourish.</em>
|
||
</h1>
|
||
<p className={styles.heroDescription}>
|
||
Compare every school in England with clarity and confidence.
|
||
{/* The coverage sentence is the detail a first-time visitor needs
|
||
but a returning one does not, so it drops away on phones where
|
||
every line above the fold costs. */}
|
||
<span className={styles.heroDescriptionFull}>
|
||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>}, side by
|
||
side in one place.
|
||
</span>
|
||
</p>
|
||
|
||
<FilterBar
|
||
filters={filters}
|
||
isHero
|
||
resultFilters={initialSchools.result_filters}
|
||
onNearMe={handleNearMe}
|
||
geoState={geoState}
|
||
geoError={geoError}
|
||
autosuggest={autosuggest}
|
||
/>
|
||
</div>
|
||
|
||
{/*
|
||
Rendered after the content on purpose. On desktop it is absolutely
|
||
positioned behind the panel, so order does not matter; below the
|
||
one-column breakpoint it returns to normal flow, where DOM order is
|
||
what puts the band under the search rather than above the headline.
|
||
*/}
|
||
<div className={styles.heroArt}>
|
||
<HeroIllustration />
|
||
</div>
|
||
</section>
|
||
|
||
{/* Why this site, in four lines. These titles are labels on a list,
|
||
not section headings — as <h2> they outranked the page's real
|
||
headings in the document outline and gave a screen-reader user
|
||
four false landmarks before any content. */}
|
||
<ul className={styles.valueProps}>
|
||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||
<li key={title} className={styles.valueProp}>
|
||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||
<div>
|
||
<p className={styles.propTitle}>{title}</p>
|
||
<p className={styles.propBody}>{body}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : (
|
||
/* Pinned under the header, so the search, the filters and the List/Map
|
||
switch stay in reach however far down the results a reader is. */
|
||
<div ref={toolbarRef} className={styles.resultsToolbar}>
|
||
<FilterBar
|
||
filters={filters}
|
||
isHero={false}
|
||
resultFilters={initialSchools.result_filters}
|
||
onNearMe={handleNearMe}
|
||
geoState={geoState}
|
||
geoError={geoError}
|
||
autosuggest={autosuggest}
|
||
/>
|
||
{hasViewSwitch && (
|
||
<div className={styles.viewSwitch} role="group" aria-label="Results view">
|
||
<button
|
||
type="button"
|
||
className={styles.viewSwitchBtn}
|
||
aria-pressed={resultsView === 'list'}
|
||
onClick={() => changeView('list', 'toolbar')}
|
||
>
|
||
<ListIcon />
|
||
List
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.viewSwitchBtn}
|
||
aria-pressed={resultsView === 'map'}
|
||
onClick={() => changeView('map', 'toolbar')}
|
||
>
|
||
<MapIcon />
|
||
Map
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Next admissions deadline — one bar, not four cards. */}
|
||
{!isSearchActive && (
|
||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||
{nextDeadline && (
|
||
<>
|
||
<a
|
||
href="/admissions"
|
||
className={
|
||
nextDeadline.days <= 14
|
||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||
: styles.nextDeadline
|
||
}
|
||
>
|
||
<span className={styles.nextDeadlineBody}>
|
||
<span className={styles.nextDeadlineKicker}>
|
||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||
</span>
|
||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||
{nextDeadline.dateLabel}
|
||
</span>
|
||
</span>
|
||
<span className={styles.nextDeadlineCount}>
|
||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||
</span>
|
||
{nextDeadline.days !== 0 && (
|
||
<span className={styles.nextDeadlineUnit}>days left</span>
|
||
)}
|
||
</span>
|
||
</a>
|
||
<p className={styles.laterDates} suppressHydrationWarning>
|
||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||
{' '}
|
||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||
</p>
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||
{!isSearchActive && howItWorks}
|
||
{!isSearchActive && editorial}
|
||
|
||
{/* Results Section. Skipped entirely on the landing page when there is
|
||
nothing to list — an empty <section> is still a flex child, so it was
|
||
contributing a full section gap of blank space above the footer. */}
|
||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||
<section
|
||
ref={resultsRef}
|
||
className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''} ${hasViewSwitch ? styles.hasViewFab : ''}`}
|
||
>
|
||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||
<div className={styles.sectionHeader}>
|
||
<h2>Featured Schools</h2>
|
||
<p className={styles.sectionDescription}>
|
||
Explore schools from across England
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{hasSearch && !isMapView && resultsHeader}
|
||
|
||
{isSearchActive && !isMapView && (
|
||
<div className={styles.activeFilters}>
|
||
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
|
||
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
|
||
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
|
||
</div>
|
||
)}
|
||
|
||
{initialSchools.schools.length === 0 && isSearchActive ? (
|
||
<EmptyState
|
||
title="No schools found"
|
||
message="Try adjusting your search criteria or filters to find schools."
|
||
action={{
|
||
label: 'Clear all filters',
|
||
onClick: () => {
|
||
window.location.href = '/';
|
||
},
|
||
}}
|
||
/>
|
||
) : isMapView ? (
|
||
/* Map View Layout: the list beside the map, as one card. */
|
||
<div className={styles.mapViewContainer}>
|
||
<div className={styles.mapListPane}>
|
||
{resultsHeader}
|
||
<div ref={mapListRef} className={styles.compactList}>
|
||
{/* The list view's own rows, so both views show the same thing.
|
||
Clicking a row (not its links or buttons) picks its pin. */}
|
||
{listPaneShown && mapListSchools.map((school) => (
|
||
<div
|
||
key={school.urn}
|
||
data-urn={school.urn}
|
||
className={`${styles.mapRow} ${selectedMapSchool?.urn === school.urn ? styles.mapRowSelected : ''}`}
|
||
onClick={(e) => {
|
||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||
setSelectedMapSchool(school);
|
||
}}
|
||
>
|
||
{/* The keyboard's way to pick the pin: hidden until it has
|
||
focus, since a pointer just clicks the row. The row
|
||
itself cannot be the button, as it holds links and
|
||
buttons of its own. */}
|
||
<button
|
||
type="button"
|
||
className={styles.showOnMap}
|
||
aria-pressed={selectedMapSchool?.urn === school.urn}
|
||
onClick={() => setSelectedMapSchool(school)}
|
||
>
|
||
Show {school.school_name} on the map
|
||
</button>
|
||
{renderRow(school)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className={styles.mapContainer}>
|
||
<SchoolMap
|
||
schools={mapResultSchools}
|
||
center={initialSchools.location_info?.coordinates}
|
||
referencePoint={initialSchools.location_info?.coordinates}
|
||
radiusMiles={radiusMiles}
|
||
selectedUrn={selectedMapSchool?.urn ?? null}
|
||
onMarkerClick={setSelectedMapSchool}
|
||
onDeselect={clearMapSelection}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
compareUrns={compareUrns}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
laAverages={laAverages}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* List View Layout */
|
||
<>
|
||
<div className={styles.schoolList}>
|
||
{sortedSchools.map(renderRow)}
|
||
</div>
|
||
|
||
{(hasMore || allSchools.length < initialSchools.total) && (
|
||
<div className={styles.loadMoreSection}>
|
||
<p className={styles.loadMoreCount}>
|
||
Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
|
||
</p>
|
||
{hasMore && (
|
||
<button
|
||
onClick={handleLoadMore}
|
||
disabled={isLoadingMore}
|
||
className={`btn btn-secondary ${styles.loadMoreButton}`}
|
||
>
|
||
{isLoadingMore ? 'Loading...' : 'Load more schools'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Phones and small tablets: the selected pin's card, and on phones the
|
||
floating List/Map button, stacked above the bottom tab bar. The
|
||
toolbar's switch is hidden at phone width, where this replaces it. */}
|
||
{hasViewSwitch && (
|
||
<div className={styles.mobileDock}>
|
||
<button
|
||
type="button"
|
||
className={styles.viewFab}
|
||
aria-label={resultsView === 'list' ? 'Show map' : 'Show list'}
|
||
onClick={() => changeView(resultsView === 'list' ? 'map' : 'list', 'floating')}
|
||
>
|
||
{resultsView === 'list' ? <><MapIcon />Map</> : <><ListIcon />List</>}
|
||
</button>
|
||
{resultsView === 'map' && selectedMapSchool && (
|
||
<div className={styles.bottomSheet}>
|
||
<button
|
||
className={styles.closeSheetBtn}
|
||
onClick={() => setSelectedMapSchool(null)}
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
{renderRow(selectedMapSchool)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|