2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* HomeView Component
|
|
|
|
|
|
* Client-side home page view with search and filtering
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
2026-03-30 10:10:28 +01:00
|
|
|
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { FilterBar } from './FilterBar';
|
2026-03-23 22:32:33 +00:00
|
|
|
|
import { SchoolRow } from './SchoolRow';
|
2026-03-28 22:36:00 +00:00
|
|
|
|
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
2026-02-04 10:05:31 +00:00
|
|
|
|
import { SchoolMap } from './SchoolMap';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { EmptyState } from './EmptyState';
|
2026-08-14 21:35:10 +01:00
|
|
|
|
import { HeroIllustration } from './Illustration';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
2026-04-13 14:17:34 +01:00
|
|
|
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
2026-02-04 10:05:31 +00:00
|
|
|
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
2026-05-19 22:04:22 +01:00
|
|
|
|
import { track } from '@/lib/analytics';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import styles from './HomeView.module.css';
|
|
|
|
|
|
|
|
|
|
|
|
interface HomeViewProps {
|
|
|
|
|
|
initialSchools: SchoolsResponse;
|
|
|
|
|
|
filters: Filters;
|
2026-03-23 21:31:28 +00:00
|
|
|
|
totalSchools?: number | null;
|
2026-06-02 13:46:45 +01:00
|
|
|
|
// 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;
|
2026-08-26 20:38:52 +01:00
|
|
|
|
/** Server-read feature flag, threaded to both FilterBar instances. */
|
|
|
|
|
|
autosuggest?: boolean;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:50:34 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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';
|
2026-09-30 21:30:33 +01:00
|
|
|
|
|
2026-04-15 17:00:21 +01:00
|
|
|
|
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);
|
2026-04-16 12:28:31 +01:00
|
|
|
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
2026-04-15 17:00:21 +01:00
|
|
|
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
function nextOccurrence(month: number, day: number): Date {
|
2026-04-15 17:00:21 +01:00
|
|
|
|
const today = new Date();
|
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
const y = today.getFullYear();
|
2026-08-14 17:15:09 +01:00
|
|
|
|
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' });
|
2026-04-15 17:00:21 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CountdownChipData {
|
|
|
|
|
|
type: 'deadline' | 'offer';
|
|
|
|
|
|
track: string;
|
|
|
|
|
|
milestone: string;
|
|
|
|
|
|
month: number;
|
|
|
|
|
|
day: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 17:00:21 +01:00
|
|
|
|
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 },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-07 18:05:49 +01:00
|
|
|
|
/* ── 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 18:35:37 +01:00
|
|
|
|
/* 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 18:05:49 +01:00
|
|
|
|
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,
|
2026-08-14 17:15:09 +01:00
|
|
|
|
* 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
|
2026-09-30 11:56:54 +01:00
|
|
|
|
* four previously did not: "up to three schools" contradicted the basket limit
|
2026-09-30 12:04:39 +01:00
|
|
|
|
* 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.
|
2026-08-07 18:05:49 +01:00
|
|
|
|
*/
|
|
|
|
|
|
const VALUE_PROPS: ValueProp[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
icon: <ShieldCheckIcon />,
|
|
|
|
|
|
tintClass: styles.propIconTrust,
|
2026-08-14 22:59:12 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* "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',
|
2026-08-07 18:05:49 +01:00
|
|
|
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
icon: <BarsIcon />,
|
|
|
|
|
|
tintClass: styles.propIconCompare,
|
|
|
|
|
|
title: 'Easy to compare',
|
2026-09-30 11:56:54 +01:00
|
|
|
|
body: 'Up to five primary and five secondary schools side by side, on the measures that matter.',
|
2026-08-07 18:05:49 +01:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
icon: <HeartIcon />,
|
|
|
|
|
|
tintClass: styles.propIconContext,
|
|
|
|
|
|
title: 'Beyond the numbers',
|
2026-09-24 22:04:27 +01:00
|
|
|
|
body: 'Not just results: SEN support, pupil premium and attendance too.',
|
2026-08-07 18:05:49 +01:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
icon: <CalendarIcon />,
|
|
|
|
|
|
tintClass: styles.propIconDeadline,
|
|
|
|
|
|
title: 'Never miss a deadline',
|
|
|
|
|
|
body: 'Application and offer dates for both primary and secondary.',
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-30 21:50:34 +01:00
|
|
|
|
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
|
2026-02-02 20:34:35 +00:00
|
|
|
|
const searchParams = useSearchParams();
|
2026-03-30 10:10:28 +01:00
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const pathname = usePathname();
|
2026-03-23 21:31:28 +00:00
|
|
|
|
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
2026-09-30 21:30:33 +01:00
|
|
|
|
/*
|
2026-09-30 21:50:34 +01:00
|
|
|
|
* 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".
|
2026-09-30 21:30:33 +01:00
|
|
|
|
*/
|
2026-09-30 21:50:34 +01:00
|
|
|
|
const [chosenView, setChosenView] = useState<'list' | 'map' | null>(null);
|
2026-03-05 09:33:47 +00:00
|
|
|
|
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
2026-03-30 10:10:28 +01:00
|
|
|
|
const sortOrder = searchParams.get('sort') || 'default';
|
2026-03-28 22:36:00 +00:00
|
|
|
|
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>>({});
|
2026-04-13 14:17:34 +01:00
|
|
|
|
const [nationalAvgRwm, setNationalAvgRwm] = useState<number | null>(null);
|
2026-03-30 09:13:14 +01:00
|
|
|
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
|
|
|
|
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
2026-09-30 18:35:37 +01:00
|
|
|
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const resultsRef = useRef<HTMLElement>(null);
|
2026-09-30 21:30:33 +01:00
|
|
|
|
const mapListRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
2026-04-15 22:45:46 +01:00
|
|
|
|
const mapParamsRef = useRef<string>('');
|
2026-09-15 10:17:18 +01:00
|
|
|
|
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]);
|
2026-04-14 21:02:18 +01:00
|
|
|
|
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
|
|
|
|
|
const [geoError, setGeoError] = useState<string | null>(null);
|
2026-08-14 17:15:09 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
});
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
|
|
|
|
|
const isLocationSearch = !!searchParams.get('postcode');
|
2026-03-05 13:00:34 +00:00
|
|
|
|
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
2026-03-28 22:36:00 +00:00
|
|
|
|
const currentPhase = searchParams.get('phase') || '';
|
2026-03-30 14:07:30 +01:00
|
|
|
|
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;
|
2026-09-30 21:50:34 +01:00
|
|
|
|
const resultsView: 'list' | 'map' = chosenView ?? (isLocationSearch ? DEFAULT_LOCATION_VIEW : 'list');
|
2026-03-28 22:36:00 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-04-15 22:45:46 +01:00
|
|
|
|
// Reset pagination and map cache when search params change
|
2026-03-28 22:36:00 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const newParamsStr = searchParams.toString();
|
|
|
|
|
|
if (newParamsStr !== prevSearchParamsRef.current) {
|
|
|
|
|
|
prevSearchParamsRef.current = newParamsStr;
|
2026-04-15 22:45:46 +01:00
|
|
|
|
mapParamsRef.current = ''; // allow map to re-fetch for new search
|
2026-03-28 22:36:00 +00:00
|
|
|
|
setAllSchools(initialSchools.schools);
|
|
|
|
|
|
setCurrentPage(initialSchools.page);
|
|
|
|
|
|
setHasMore(initialSchools.total_pages > 1);
|
2026-03-30 09:13:14 +01:00
|
|
|
|
setMapSchools([]);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
}, [searchParams, initialSchools]);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-03-05 09:33:47 +00:00
|
|
|
|
// Close bottom sheet if we change views or search
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSelectedMapSchool(null);
|
|
|
|
|
|
}, [resultsView, searchParams]);
|
|
|
|
|
|
|
2026-04-15 22:45:46 +01:00
|
|
|
|
// 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.
|
2026-03-30 09:13:14 +01:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (resultsView !== 'map' || !isLocationSearch) return;
|
2026-04-15 22:45:46 +01:00
|
|
|
|
const paramsKey = searchParams.toString();
|
|
|
|
|
|
if (paramsKey === mapParamsRef.current) return;
|
2026-09-15 10:17:18 +01:00
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
const scope = searchScope.current;
|
|
|
|
|
|
const current = () => !controller.signal.aborted && searchScope.current === scope;
|
2026-03-30 09:13:14 +01:00
|
|
|
|
setIsLoadingMap(true);
|
|
|
|
|
|
const params: Record<string, any> = {};
|
|
|
|
|
|
searchParams.forEach((value, key) => { params[key] = value; });
|
|
|
|
|
|
params.page = 1;
|
|
|
|
|
|
params.page_size = 500;
|
2026-09-15 10:17:18 +01:00
|
|
|
|
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]);
|
2026-03-30 09:13:14 +01:00
|
|
|
|
|
2026-03-30 14:07:30 +01:00
|
|
|
|
// Fetch LA averages when secondary or mixed schools are visible
|
2026-03-28 22:36:00 +00:00
|
|
|
|
useEffect(() => {
|
2026-03-30 14:07:30 +01:00
|
|
|
|
if (!isSecondaryView && !isMixedView) return;
|
2026-03-28 22:36:00 +00:00
|
|
|
|
fetchLAaverages({ cache: 'force-cache' })
|
|
|
|
|
|
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
|
|
|
|
|
|
.catch(() => {});
|
2026-03-30 14:07:30 +01:00
|
|
|
|
}, [isSecondaryView, isMixedView]);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
|
2026-04-13 14:17:34 +01:00
|
|
|
|
// Fetch national averages (supplementary — never blocks render)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchNationalAverages()
|
|
|
|
|
|
.then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null))
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-28 22:36:00 +00:00
|
|
|
|
const handleLoadMore = async () => {
|
|
|
|
|
|
if (isLoadingMore || !hasMore) return;
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('results_load_more', { next_page: currentPage + 1 });
|
2026-03-28 22:36:00 +00:00
|
|
|
|
setIsLoadingMore(true);
|
2026-09-15 10:17:18 +01:00
|
|
|
|
const scope = searchScope.current;
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
loadMoreController.current?.abort();
|
|
|
|
|
|
loadMoreController.current = controller;
|
|
|
|
|
|
const current = () => !controller.signal.aborted && searchScope.current === scope;
|
2026-03-28 22:36:00 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const params: Record<string, any> = {};
|
|
|
|
|
|
searchParams.forEach((value, key) => { params[key] = value; });
|
|
|
|
|
|
params.page = currentPage + 1;
|
2026-03-30 09:08:15 +01:00
|
|
|
|
params.page_size = initialSchools.page_size;
|
2026-09-15 10:17:18 +01:00
|
|
|
|
const response = await fetchSchools(params, { cache: 'no-store', signal: controller.signal });
|
|
|
|
|
|
if (!current()) return;
|
2026-03-28 22:36:00 +00:00
|
|
|
|
setAllSchools(prev => [...prev, ...response.schools]);
|
|
|
|
|
|
setCurrentPage(response.page);
|
|
|
|
|
|
setHasMore(response.page < response.total_pages);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// silently ignore
|
|
|
|
|
|
} finally {
|
2026-09-15 10:17:18 +01:00
|
|
|
|
if (current()) setIsLoadingMore(false);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-14 21:02:18 +01:00
|
|
|
|
const handleNearMe = useCallback(() => {
|
|
|
|
|
|
if (!navigator.geolocation) {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'unsupported' });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
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');
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'granted' });
|
|
|
|
|
|
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
|
|
|
|
|
} else {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'no_postcode' });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
setGeoState('error');
|
|
|
|
|
|
setGeoError('No postcode found near your location. Try entering one above.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'lookup_error' });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
setGeoState('error');
|
|
|
|
|
|
setGeoError('Could not look up your location. Please try again.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
(err) => {
|
|
|
|
|
|
setGeoState('error');
|
|
|
|
|
|
if (err.code === err.PERMISSION_DENIED) {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'denied' });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
|
|
|
|
|
} else {
|
2026-05-19 22:04:22 +01:00
|
|
|
|
track('near_me_used', { outcome: 'error' });
|
2026-04-14 21:02:18 +01:00
|
|
|
|
setGeoError('Could not get your location. Please try again or enter a postcode.');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ timeout: 10000, maximumAge: 60000 }
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [router]);
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
const sortSchools = (list: School[]) => [...list].sort((a, b) => {
|
2026-03-23 21:31:28 +00:00
|
|
|
|
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);
|
2026-03-28 22:36:00 +00:00
|
|
|
|
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);
|
2026-03-23 21:31:28 +00:00
|
|
|
|
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
|
|
|
|
|
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
});
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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;
|
2026-03-23 21:31:28 +00:00
|
|
|
|
|
2026-05-19 22:04:22 +01:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
|
2026-05-19 22:04:22 +01:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-09-30 18:35:37 +01:00
|
|
|
|
const hasViewSwitch = isLocationSearch && initialSchools.schools.length > 0;
|
2026-09-30 21:30:33 +01:00
|
|
|
|
const compareUrns = useMemo(() => selectedSchools.map(s => s.urn), [selectedSchools]);
|
2026-09-30 21:50:34 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}, []);
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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]);
|
2026-09-30 18:35:37 +01:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 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;
|
2026-09-30 21:50:34 +01:00
|
|
|
|
setChosenView(view);
|
2026-09-30 18:35:37 +01:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
const isMapView = initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch;
|
|
|
|
|
|
|
2026-10-01 09:03:50 +01:00
|
|
|
|
// 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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-01 10:18:26 +01:00
|
|
|
|
const schoolCount = `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''}`;
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// 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">
|
2026-10-01 10:18:26 +01:00
|
|
|
|
{/* Beside the map the heading shares one line with the sort, so it
|
|
|
|
|
|
drops the postcode, which the search box just above already shows. */}
|
|
|
|
|
|
{isMapView && radiusMiles != null
|
|
|
|
|
|
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'}`
|
|
|
|
|
|
: isLocationSearch && initialSchools.location_info
|
|
|
|
|
|
? `${schoolCount} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
|
|
|
|
|
|
: `${schoolCount} found`
|
2026-09-30 21:30:33 +01:00
|
|
|
|
}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
return (
|
2026-08-14 17:15:09 +01:00
|
|
|
|
/* 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. */
|
2026-09-30 21:30:33 +01:00
|
|
|
|
<div
|
|
|
|
|
|
className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}
|
|
|
|
|
|
style={toolbarHeight ? { '--toolbar-h': `${toolbarHeight}px` } as React.CSSProperties : undefined}
|
|
|
|
|
|
>
|
2026-08-07 18:05:49 +01:00
|
|
|
|
{/* 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,
|
2026-08-14 17:15:09 +01:00
|
|
|
|
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. */}
|
2026-08-07 18:05:49 +01:00
|
|
|
|
{!isSearchActive ? (
|
2026-08-14 17:15:09 +01:00
|
|
|
|
<div className={styles.heroGroup}>
|
2026-08-07 18:05:49 +01:00
|
|
|
|
<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
|
2026-07-02 21:56:21 +01:00
|
|
|
|
</span>
|
2026-08-07 18:05:49 +01:00
|
|
|
|
<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
|
2026-09-24 22:04:27 +01:00
|
|
|
|
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>}, side by
|
|
|
|
|
|
side in one place.
|
2026-08-07 18:05:49 +01:00
|
|
|
|
</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<FilterBar
|
|
|
|
|
|
filters={filters}
|
|
|
|
|
|
isHero
|
|
|
|
|
|
resultFilters={initialSchools.result_filters}
|
|
|
|
|
|
onNearMe={handleNearMe}
|
|
|
|
|
|
geoState={geoState}
|
|
|
|
|
|
geoError={geoError}
|
2026-08-26 20:38:52 +01:00
|
|
|
|
autosuggest={autosuggest}
|
2026-08-07 18:05:49 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
{/*
|
2026-08-14 21:35:10 +01:00
|
|
|
|
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.
|
2026-08-14 17:15:09 +01:00
|
|
|
|
*/}
|
2026-08-07 18:05:49 +01:00
|
|
|
|
<div className={styles.heroArt}>
|
2026-08-14 21:35:10 +01:00
|
|
|
|
<HeroIllustration />
|
2026-08-07 18:05:49 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-08-14 17:15:09 +01:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2026-08-07 18:05:49 +01:00
|
|
|
|
) : (
|
2026-09-30 18:35:37 +01:00
|
|
|
|
/* 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}
|
2026-10-01 15:27:14 +01:00
|
|
|
|
viewSwitch={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>
|
|
|
|
|
|
)}
|
2026-09-30 18:35:37 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-03-23 21:31:28 +00:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
{/* Next admissions deadline — one bar, not four cards. */}
|
2026-08-07 18:05:49 +01:00
|
|
|
|
{!isSearchActive && (
|
2026-08-14 17:15:09 +01:00
|
|
|
|
<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]}
|
2026-04-15 17:00:21 +01:00
|
|
|
|
</span>
|
2026-08-14 17:15:09 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-15 17:00:21 +01:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-02 13:46:45 +01:00
|
|
|
|
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
|
|
|
|
|
{!isSearchActive && howItWorks}
|
|
|
|
|
|
{!isSearchActive && editorial}
|
2026-04-14 21:02:18 +01:00
|
|
|
|
|
2026-08-14 17:15:09 +01:00
|
|
|
|
{/* 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) && (
|
2026-09-30 18:35:37 +01:00
|
|
|
|
<section
|
|
|
|
|
|
ref={resultsRef}
|
|
|
|
|
|
className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''} ${hasViewSwitch ? styles.hasViewFab : ''}`}
|
|
|
|
|
|
>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
|
|
|
|
|
<div className={styles.sectionHeader}>
|
|
|
|
|
|
<h2>Featured Schools</h2>
|
|
|
|
|
|
<p className={styles.sectionDescription}>
|
|
|
|
|
|
Explore schools from across England
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
{hasSearch && !isMapView && resultsHeader}
|
2026-03-23 21:31:28 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
{isSearchActive && !isMapView && (
|
2026-03-23 21:31:28 +00:00
|
|
|
|
<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>}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-05 13:00:34 +00:00
|
|
|
|
{initialSchools.schools.length === 0 && isSearchActive ? (
|
2026-02-02 20:34:35 +00:00
|
|
|
|
<EmptyState
|
2026-03-05 13:00:34 +00:00
|
|
|
|
title="No schools found"
|
|
|
|
|
|
message="Try adjusting your search criteria or filters to find schools."
|
|
|
|
|
|
action={{
|
|
|
|
|
|
label: 'Clear all filters',
|
|
|
|
|
|
onClick: () => {
|
|
|
|
|
|
window.location.href = '/';
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
/>
|
2026-09-30 21:30:33 +01:00
|
|
|
|
) : isMapView ? (
|
|
|
|
|
|
/* Map View Layout: the list beside the map, as one card. */
|
2026-02-04 10:05:31 +00:00
|
|
|
|
<div className={styles.mapViewContainer}>
|
2026-09-30 21:30:33 +01:00
|
|
|
|
<div className={styles.mapListPane}>
|
|
|
|
|
|
{resultsHeader}
|
|
|
|
|
|
<div ref={mapListRef} className={styles.compactList}>
|
2026-10-01 09:03:50 +01:00
|
|
|
|
{/* The list view's own rows, so both views show the same thing.
|
|
|
|
|
|
Clicking a row (not its links or buttons) picks its pin. */}
|
2026-09-30 21:50:34 +01:00
|
|
|
|
{listPaneShown && mapListSchools.map((school) => (
|
2026-10-01 09:03:50 +01:00
|
|
|
|
<div
|
2026-09-30 21:30:33 +01:00
|
|
|
|
key={school.urn}
|
2026-10-01 09:03:50 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-10-01 09:09:47 +01:00
|
|
|
|
{/* 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>
|
2026-10-01 09:03:50 +01:00
|
|
|
|
{renderRow(school)}
|
|
|
|
|
|
</div>
|
2026-09-30 21:30:33 +01:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-04 10:05:31 +00:00
|
|
|
|
<div className={styles.mapContainer}>
|
|
|
|
|
|
<SchoolMap
|
2026-09-30 21:30:33 +01:00
|
|
|
|
schools={mapResultSchools}
|
2026-02-04 10:05:31 +00:00
|
|
|
|
center={initialSchools.location_info?.coordinates}
|
2026-03-30 09:13:14 +01:00
|
|
|
|
referencePoint={initialSchools.location_info?.coordinates}
|
2026-09-30 21:30:33 +01:00
|
|
|
|
radiusMiles={radiusMiles}
|
|
|
|
|
|
selectedUrn={selectedMapSchool?.urn ?? null}
|
2026-03-05 09:33:47 +00:00
|
|
|
|
onMarkerClick={setSelectedMapSchool}
|
2026-09-30 21:30:33 +01:00
|
|
|
|
onDeselect={clearMapSelection}
|
|
|
|
|
|
onAddToCompare={addSchoolFromSearch}
|
|
|
|
|
|
compareUrns={compareUrns}
|
2026-04-13 14:17:34 +01:00
|
|
|
|
nationalAvgRwm={nationalAvgRwm}
|
2026-04-13 14:29:55 +01:00
|
|
|
|
laAverages={laAverages}
|
2026-02-04 10:05:31 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
) : (
|
2026-02-04 10:05:31 +00:00
|
|
|
|
/* List View Layout */
|
2026-02-02 20:34:35 +00:00
|
|
|
|
<>
|
2026-03-23 22:32:33 +00:00
|
|
|
|
<div className={styles.schoolList}>
|
2026-10-01 09:03:50 +01:00
|
|
|
|
{sortedSchools.map(renderRow)}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-28 22:36:00 +00:00
|
|
|
|
{(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>
|
2026-02-02 20:34:35 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-08-14 17:15:09 +01:00
|
|
|
|
)}
|
2026-09-30 18:35:37 +01:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2026-10-01 09:03:50 +01:00
|
|
|
|
{renderRow(selectedMapSchool)}
|
2026-09-30 18:35:37 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|