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 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The List/Map switch was a small grey control beside the results heading, and the filters were plain dropdowns labelled "All Phases" and "Advanced". Both scrolled away with the first result. Search, filters and the List/Map switch now share one card pinned under the header. Distance, phase and school type are pill controls in the row; "Advanced" becomes "More filters" and counts only what it hides. The switch is filled brand teal and says which view is on (aria-pressed). On phones the search folds to a one-line summary once made, the filter pills scroll sideways, and a floating Map/List button sits above the tab bar in place of the toolbar switch. The selected pin's card now stacks under that button instead of covering the tab bar. Switching view from far down the list scrolls back to the top of the results, and each switch is tracked as results_view_changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
941 lines
39 KiB
TypeScript
941 lines
39 KiB
TypeScript
/**
|
||
* HomeView Component
|
||
* Client-side home page view with search and filtering
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useEffect, useRef, useCallback } 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 { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||
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;
|
||
}
|
||
|
||
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();
|
||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||
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 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;
|
||
|
||
// 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 sortedSchools = [...allSchools].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;
|
||
});
|
||
|
||
// 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;
|
||
|
||
/*
|
||
* 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;
|
||
setResultsView(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 });
|
||
}
|
||
});
|
||
};
|
||
|
||
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}`}>
|
||
{/* 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 && (
|
||
<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}>
|
||
{resultsView === 'list' && (
|
||
<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>
|
||
)}
|
||
|
||
{isSearchActive && (
|
||
<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 = '/';
|
||
},
|
||
}}
|
||
/>
|
||
) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
|
||
/* Map View Layout */
|
||
<div className={styles.mapViewContainer}>
|
||
<div className={styles.mapContainer}>
|
||
<SchoolMap
|
||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||
center={initialSchools.location_info?.coordinates}
|
||
referencePoint={initialSchools.location_info?.coordinates}
|
||
onMarkerClick={setSelectedMapSchool}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
laAverages={laAverages}
|
||
/>
|
||
</div>
|
||
<div className={styles.compactList}>
|
||
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||
<div
|
||
key={school.urn}
|
||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||
>
|
||
<CompactSchoolItem
|
||
school={school}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
</div>
|
||
) : (
|
||
/* List View Layout */
|
||
<>
|
||
<div className={styles.schoolList}>
|
||
{sortedSchools.map((school) => (
|
||
school.attainment_8_score != null ? (
|
||
<SecondarySchoolRow
|
||
key={school.urn}
|
||
school={school}
|
||
isLocationSearch={isLocationSearch}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
onRemoveFromCompare={removeSchool}
|
||
isInCompare={selectedSchools.some(s => s.urn === 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={selectedSchools.some(s => s.urn === school.urn)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
)
|
||
))}
|
||
</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>
|
||
<CompactSchoolItem
|
||
school={selectedMapSchool}
|
||
onAddToCompare={addSchoolFromSearch}
|
||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* Compact School Item for Map View */
|
||
interface CompactSchoolItemProps {
|
||
school: School;
|
||
onAddToCompare: (school: School) => void;
|
||
isInCompare: boolean;
|
||
nationalAvgRwm?: number | null;
|
||
}
|
||
|
||
function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
|
||
const ofstedBadge = buildOfstedListBadge(school);
|
||
const isSecondary = school.attainment_8_score != null;
|
||
|
||
// vs-national delta for primary schools
|
||
const rwmDelta =
|
||
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
|
||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||
: null;
|
||
|
||
const deltaStyle: React.CSSProperties =
|
||
rwmDelta == null
|
||
? {}
|
||
: rwmDelta >= 2
|
||
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
|
||
: rwmDelta <= -2
|
||
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
|
||
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
|
||
|
||
return (
|
||
<div className={styles.compactItem}>
|
||
<div className={styles.compactItemContent}>
|
||
<div className={styles.compactItemHeader}>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
||
{school.school_name}
|
||
</a>
|
||
{school.distance !== undefined && school.distance !== null && (
|
||
<span className={styles.distanceBadge}>
|
||
{school.distance.toFixed(1)} mi
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* Ofsted badge */}
|
||
<div style={{ marginBottom: '0.25rem' }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
padding: '0.0625rem 0.375rem',
|
||
fontSize: '0.625rem',
|
||
fontWeight: 600,
|
||
borderRadius: '3px',
|
||
whiteSpace: 'nowrap',
|
||
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
|
||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
|
||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
|
||
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
|
||
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
|
||
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
|
||
}}
|
||
>
|
||
{ofstedBadge.label}
|
||
</span>
|
||
</div>
|
||
{/* Headline metric + delta */}
|
||
<div className={styles.compactItemStats}>
|
||
<span className={styles.compactStat}>
|
||
<strong>
|
||
{isSecondary
|
||
? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
|
||
: (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
|
||
</strong>
|
||
{' '}
|
||
{isSecondary ? 'Att 8' : 'RWM'}
|
||
</span>
|
||
{rwmDelta != null && (
|
||
<span style={deltaStyle}>
|
||
{rwmDelta >= 2
|
||
? `+${rwmDelta} pts vs national`
|
||
: rwmDelta <= -2
|
||
? `${rwmDelta} pts vs national`
|
||
: '≈ national avg'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={styles.compactItemActions}>
|
||
<button
|
||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||
onClick={() => onAddToCompare(school)}
|
||
>
|
||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||
</button>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||
View
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|