6045114ca2
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 17s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
MOB-06: On phones the home hero stacked eyebrow tag, h1, description
paragraph, search input, button, "Schools near me" and three explore
chips before the user could see the deadlines strip. Hide the eyebrow
and the descriptive paragraph at ≤640px (the h1 already names the
product; the search input is the primary action) and move the
"Start exploring" chips to render after the admissions deadlines —
time-sensitive info now leads, generic discovery follows. Result on
390×844: heading → search → Schools near me → first deadline chip
all fit above the fold.
MOB-11: The school-detail hero took ~2 viewports before the first real
metric. At ≤768px, switch .meta back to row+wrap so the short pills
("Manchester" / "Voluntary aided") flow 2-per-row instead of stacking
3 full rows, and hide the .headerDetails block (headteacher / website /
pupil count / trust) — secondary info that lives in the Pupils &
Inclusion section anyway. Reclaims ~70px of hero so the Ofsted card
and the headline metric surface within a single viewport.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
796 lines
36 KiB
TypeScript
796 lines
36 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 { 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 styles from './HomeView.module.css';
|
||
|
||
interface HomeViewProps {
|
||
initialSchools: SchoolsResponse;
|
||
filters: Filters;
|
||
totalSchools?: number | null;
|
||
}
|
||
|
||
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 formatCountdownDate(month: number, day: number): string {
|
||
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 target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||
}
|
||
|
||
interface CountdownChipData {
|
||
type: 'deadline' | 'offer';
|
||
track: string;
|
||
milestone: string;
|
||
month: number;
|
||
day: number;
|
||
}
|
||
|
||
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 },
|
||
];
|
||
|
||
export function HomeView({ initialSchools, filters, totalSchools }: 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);
|
||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||
);
|
||
|
||
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(() => {});
|
||
}, []);
|
||
|
||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||
useEffect(() => {
|
||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||
setSortedChips(withDays);
|
||
}, []);
|
||
|
||
const handleLoadMore = async () => {
|
||
if (isLoadingMore || !hasMore) return;
|
||
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) {
|
||
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');
|
||
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||
} else {
|
||
setGeoState('error');
|
||
setGeoError('No postcode found near your location. Try entering one above.');
|
||
}
|
||
} catch {
|
||
setGeoState('error');
|
||
setGeoError('Could not look up your location. Please try again.');
|
||
}
|
||
},
|
||
(err) => {
|
||
setGeoState('error');
|
||
if (err.code === err.PERMISSION_DENIED) {
|
||
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||
} else {
|
||
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;
|
||
});
|
||
|
||
return (
|
||
<div className={styles.homeView}>
|
||
{/* Combined Hero + Search and Filters */}
|
||
{!isSearchActive && (
|
||
<div className={styles.heroSection}>
|
||
<span className={styles.heroEyebrow}>
|
||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||
Updated with 2024/25 results
|
||
</span>
|
||
<h1 className={styles.heroTitle}>
|
||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||
</h1>
|
||
<p className={styles.heroDescription}>
|
||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<FilterBar
|
||
filters={filters}
|
||
isHero={!isSearchActive}
|
||
resultFilters={initialSchools.result_filters}
|
||
/>
|
||
|
||
{/* Discovery section shown on landing page before any search */}
|
||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||
<div className={styles.discoverySection}>
|
||
<div className={styles.nearMeRow}>
|
||
<button
|
||
className={styles.nearMeBtn}
|
||
onClick={handleNearMe}
|
||
disabled={geoState === 'requesting'}
|
||
>
|
||
{geoState === 'requesting' ? (
|
||
<>
|
||
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||
Locating you…
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||
<circle cx="12" cy="9" r="2.5"/>
|
||
</svg>
|
||
Schools near me
|
||
</>
|
||
)}
|
||
</button>
|
||
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Admissions countdown strip — only on landing page */}
|
||
{!isSearchActive && (
|
||
<section className={styles.admissionsStrip}>
|
||
<div className={styles.stripHeader}>
|
||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||
</div>
|
||
<div
|
||
className={styles.countdownRail}
|
||
style={{
|
||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||
transition: 'opacity 0.2s ease',
|
||
}}
|
||
>
|
||
{sortedChips.map(({ chip, days }) => {
|
||
const isUrgent = days !== null && days <= 14;
|
||
const chipClass = [
|
||
styles.countdownChip,
|
||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||
isUrgent ? styles.countdownChipUrgent : '',
|
||
].filter(Boolean).join(' ');
|
||
const trackClass = [
|
||
styles.chipTrack,
|
||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||
].join(' ');
|
||
return (
|
||
<div key={chip.milestone} className={chipClass}>
|
||
<span className={trackClass}>
|
||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||
{chip.track}
|
||
</span>
|
||
<div>
|
||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||
</div>
|
||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||
<div className={styles.chipDate}>
|
||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Secondary discovery — moved below deadlines so the admissions
|
||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||
<div className={styles.exploringRow}>
|
||
<span className={styles.exploringLabel}>Start exploring</span>
|
||
<div className={styles.exploringChips}>
|
||
<a href="/rankings" className={styles.exploringChip}>
|
||
<span className={styles.chipDot} aria-hidden="true" />
|
||
Top-rated primary schools
|
||
</a>
|
||
<a href="/rankings" className={styles.exploringChip}>
|
||
<span className={styles.chipDot} aria-hidden="true" />
|
||
Top-rated secondary schools
|
||
</a>
|
||
<a href="/compare" className={styles.exploringChip}>
|
||
<span className={styles.chipDot} aria-hidden="true" />
|
||
Start a comparison
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* How it works — only on landing page */}
|
||
{!isSearchActive && (
|
||
<section className={styles.howItWorks}>
|
||
<div className={styles.hiwHeader}>
|
||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||
</div>
|
||
<div className={styles.hiwGrid}>
|
||
|
||
{/* Card 1 — Performance */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
{/* Primary: mini cascade */}
|
||
<div className={styles.hiwPhaseBlock}>
|
||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||
<div className={styles.miniCascade}>
|
||
{[
|
||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
||
].map(({ subj, exp, exc, nat }) => (
|
||
<div key={subj} className={styles.miniCascadeCol}>
|
||
<div className={styles.miniSubj}>{subj}</div>
|
||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||
<div className={styles.miniTrack}>
|
||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||
</div>
|
||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
||
<div className={styles.miniTrack}>
|
||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* Secondary: Attainment 8 */}
|
||
<div className={styles.hiwPhaseBlock}>
|
||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||
<div className={styles.att8Row}>
|
||
<div className={styles.att8BarWrap}>
|
||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
||
<div className={styles.att8Track}>
|
||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
||
</div>
|
||
</div>
|
||
<div className={styles.att8Score}>
|
||
<div className={styles.att8Value}>62.4</div>
|
||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Performance</div>
|
||
<div className={styles.hiwTitle}>Results against the national average</div>
|
||
<p className={styles.hiwDesc}>For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 2 — Ofsted */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
<div className={styles.ofstedPreview}>
|
||
<div className={styles.ofstedHead}>
|
||
<span className={styles.ofstedBullet} />
|
||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||
</div>
|
||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Judgement</div>
|
||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 3 — Compare */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
<div className={styles.comparePreview}>
|
||
<div className={styles.compareHead}>
|
||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||
</div>
|
||
{[
|
||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
||
].map(({ label, a, b, aHi }) => (
|
||
<div key={label} className={styles.compareRow}>
|
||
<span className={styles.compareRowLabel}>{label}</span>
|
||
<span className={`${styles.compareRowVal} ${aHi ? styles.compareRowValHi : ''}`}>{a}</span>
|
||
<span className={styles.compareRowVal}>{b}</span>
|
||
</div>
|
||
))}
|
||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Compare</div>
|
||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Editorial — only on landing page */}
|
||
{!isSearchActive && (
|
||
<section className={styles.editorial}>
|
||
<div className={styles.editorialGrid}>
|
||
<div className={styles.editorialText}>
|
||
<div className={styles.editorialKicker}>About school data</div>
|
||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||
<p>
|
||
School performance data in England is rich but fragmented. The Department for Education publishes
|
||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||
demographics — each in its own table, each with its own jargon.
|
||
</p>
|
||
<p>
|
||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||
40-page inspection report.
|
||
</p>
|
||
</div>
|
||
<div className={styles.factbox}>
|
||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Schools covered</span>
|
||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Local authorities</span>
|
||
<span className={styles.factVal}>{filters.local_authorities.length > 0 ? filters.local_authorities.length : 152}</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Phases</span>
|
||
<span className={styles.factVal}>Primary & Secondary</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Latest results year</span>
|
||
<span className={styles.factVal}>2024/25</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Historical data</span>
|
||
<span className={styles.factVal}>2016–2025</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Metrics per school</span>
|
||
<span className={styles.factVal}>40+</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Results Section */}
|
||
<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 & 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={addSchool}
|
||
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={addSchool}
|
||
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={addSchool}
|
||
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={addSchool}
|
||
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(--accent-teal, #2d7d7d)', fontWeight: 600 }
|
||
: rwmDelta <= -2
|
||
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
|
||
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
|
||
|
||
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(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
|
||
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#3c8c3c' } :
|
||
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: '#b8920e' } :
|
||
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
|
||
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
|
||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||
{ background: '#e0e0e0', color: '#666' }),
|
||
}}
|
||
>
|
||
{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>
|
||
);
|
||
}
|