Files
school_compare/nextjs-app/components/HomeView.tsx
T
TudorandClaude Opus 5 9cda1c1174 feat(home): use the supplied hero artwork instead of a drawn one
Replaces the hand-drawn SVG landscape with the illustration supplied by the
project owner. Master is assets/hero-source.png; everything under
public/brand/hero-* comes from scripts/build-hero-images.js and should never be
hand-edited.

LAYOUT

The artwork is composed as a full-bleed hero: it reserves an empty cream area
down its left side for the headline. The old hero was a two-column grid with
art in the right 0.88fr, which would have cropped that reserved area off and
shrunk the scene into a thumbnail — the one thing the picture is built not to
be. So the panel is now a single layered block: artwork behind, copy on top,
and below the one-column breakpoint the artwork leaves the background and
becomes a band under the search.

Overlaying does not survive down to phone widths — the panel gets too narrow
for the copy to stay inside the cream, so the scrim would have to cover nearly
the whole image and you would be left with a tinted rectangle. Hence the
switch at 860px rather than a single treatment stretched across every width.

CONTRAST

The copy sits on a gradient of --hero-ground, a new token sampled from the
artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen
points darker per channel, which leaves a visible seam straight down the hero.
The scrim exists because the artwork is a fixed image on a fluid panel: past
some width the headline would otherwise land on hillside green.

Measured on rendered pixels, sampling background up to 120px beyond the right
edge of each line, so a longer line still has margin:

  light  title 14.36:1   body 6.52:1
  dark   title 11.01:1   body 6.44:1

The worst light case is hillside green showing through the scrim at 6.52:1.

TWO CROPS

The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and
2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops,
and at the band's extreme that slices a strip through the scene and loses the
schoolhouse — exactly how the drawn hero failed on phones. <picture> switches
crop, not just resolution: the band file is pre-cropped around the school and
is already near 2.6:1, with the subject at ~65% across so squeezing toward
4.9:1 crops the empty sides instead.

WEIGHT

1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about
23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback.
`sizes` describes the real panel box (max-width 1400 minus padding) rather than
100vw, which over-requested a candidate at every width on the LCP element.

DARK THEME

A raster cannot be re-graded token by token the way the drawing was, but it
would reproduce the same failure — a bright illustration is the brightest
object on a near-black page. It is dimmed in CSS to read as dusk, and the
scrim fades it into the dark panel rather than into cream.

TESTS

The two illustration journeys are replaced by three, each covering a failure
that still renders a valid-looking page: the artwork actually loading
(naturalWidth, not src), the crop switching at the breakpoint, a modern format
winning negotiation, and the dark theme dimming it.

public/brand/CREDITS.md records provenance for every asset in that folder. The
hero's licence line is marked unconfirmed — that one needs the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:35:10 +01:00

831 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
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>
);
}
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 MAX_SCHOOLS = 5
* in context/ComparisonProvider.tsx (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.
*/
const VALUE_PROPS: ValueProp[] = [
{
icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust,
title: 'Official & trusted',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
icon: <BarsIcon />,
tintClass: styles.propIconCompare,
title: 'Easy to compare',
body: 'Up to five schools side by side, on the measures that matter.',
},
{
icon: <HeartIcon />,
tintClass: styles.propIconContext,
title: 'Beyond the numbers',
body: 'SEN support, pupil premium and attendance — not just results.',
},
{
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 }: 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 mapParamsRef = useRef<string>('');
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;
mapParamsRef.current = paramsKey;
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' })
.then(r => setMapSchools(r.schools))
.catch(() => setMapSchools(initialSchools.schools))
.finally(() => setIsLoadingMap(false));
}, [resultsView, searchParams]);
// 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);
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' });
setAllSchools(prev => [...prev, ...response.schools]);
setCurrentPage(response.page);
setHasMore(response.page < response.total_pages);
} catch {
// silently ignore
} finally {
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]);
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}
/>
</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>
) : (
<FilterBar
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
)}
{/* 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 className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!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}>
{isLocationSearch && initialSchools.schools.length > 0 && (
<div className={styles.viewToggle}>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'list' ? styles.active : ''}`}
onClick={() => setResultsView('list')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
List
</button>
<button
className={`${styles.viewToggleBtn} ${resultsView === 'map' ? styles.active : ''}`}
onClick={() => setResultsView('map')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
Map
</button>
</div>
)}
{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 &amp; Maths %</option>}
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing &amp; 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>
{/* Mobile Bottom Sheet for Selected Map Pin */}
{selectedMapSchool && (
<div className={styles.bottomSheetWrapper}>
<div className={styles.bottomSheet}>
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
<CompactSchoolItem
school={selectedMapSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.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>
)}
</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>
);
}