2026-02-02 20:34:35 +00:00
/**
* HomeView Component
* Client-side home page view with search and filtering
*/
'use client' ;
2026-04-14 21:02:18 +01:00
import React , { useState , useEffect , useRef , useCallback } from 'react' ;
2026-03-30 10:10:28 +01:00
import { useSearchParams , useRouter , usePathname } from 'next/navigation' ;
2026-02-02 20:34:35 +00:00
import { FilterBar } from './FilterBar' ;
2026-03-23 22:32:33 +00:00
import { SchoolRow } from './SchoolRow' ;
2026-03-28 22:36:00 +00:00
import { SecondarySchoolRow } from './SecondarySchoolRow' ;
2026-02-04 10:05:31 +00:00
import { SchoolMap } from './SchoolMap' ;
2026-02-02 20:34:35 +00:00
import { EmptyState } from './EmptyState' ;
import { useComparisonContext } from '@/context/ComparisonContext' ;
2026-04-13 14:17:34 +01:00
import { fetchSchools , fetchLAaverages , fetchNationalAverages } from '@/lib/api' ;
2026-02-04 10:05:31 +00:00
import type { SchoolsResponse , Filters , School } from '@/lib/types' ;
2026-04-13 14:17:34 +01:00
import { schoolUrl , buildOfstedListBadge } from '@/lib/utils' ;
2026-05-19 22:04:22 +01:00
import { track } from '@/lib/analytics' ;
2026-02-02 20:34:35 +00:00
import styles from './HomeView.module.css' ;
interface HomeViewProps {
initialSchools : SchoolsResponse ;
filters : Filters ;
2026-03-23 21:31:28 +00:00
totalSchools? : number | null ;
2026-06-02 13:46:45 +01:00
// Slot props for static markup the server pre-renders so it stays out of
// the client bundle. Server passes null when the landing sections shouldn't
// show (e.g. an active search).
howItWorks? : React.ReactNode ;
editorial? : React.ReactNode ;
2026-02-02 20:34:35 +00:00
}
2026-04-15 17:00:21 +01:00
function daysUntil ( month : number , day : number ) : number {
const today = new Date ();
today . setHours ( 0 , 0 , 0 , 0 );
const y = today . getFullYear ();
let target = new Date ( y , month - 1 , day );
2026-04-16 12:28:31 +01:00
if ( target < today ) target = new Date ( y + 1 , month - 1 , day );
2026-04-15 17:00:21 +01:00
return Math . round (( target . getTime () - today . getTime ()) / 86 _400_000 );
}
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 );
2026-04-16 12:28:31 +01:00
if ( target < today ) target = new Date ( y + 1 , month - 1 , day );
2026-04-15 17:00:21 +01:00
return 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 },
];
2026-06-02 13:46:45 +01:00
export function HomeView ({ initialSchools , filters , totalSchools , howItWorks , editorial } : HomeViewProps ) {
2026-02-02 20:34:35 +00:00
const searchParams = useSearchParams ();
2026-03-30 10:10:28 +01:00
const router = useRouter ();
const pathname = usePathname ();
2026-03-23 21:31:28 +00:00
const { addSchool , removeSchool , selectedSchools } = useComparisonContext ();
2026-02-04 10:05:31 +00:00
const [ resultsView , setResultsView ] = useState < 'list' | 'map' > ( 'list' );
2026-03-05 09:33:47 +00:00
const [ selectedMapSchool , setSelectedMapSchool ] = useState < School | null >( null );
2026-03-30 10:10:28 +01:00
const sortOrder = searchParams . get ( 'sort' ) || 'default' ;
2026-03-28 22:36:00 +00:00
const [ allSchools , setAllSchools ] = useState < School [] >( initialSchools . schools );
const [ currentPage , setCurrentPage ] = useState ( initialSchools . page );
const [ hasMore , setHasMore ] = useState ( initialSchools . total_pages > 1 );
const [ isLoadingMore , setIsLoadingMore ] = useState ( false );
const [ laAverages , setLaAverages ] = useState < Record < string , number >>({});
2026-04-13 14:17:34 +01:00
const [ nationalAvgRwm , setNationalAvgRwm ] = useState < number | null >( null );
2026-03-30 09:13:14 +01:00
const [ mapSchools , setMapSchools ] = useState < School [] >([]);
const [ isLoadingMap , setIsLoadingMap ] = useState ( false );
2026-03-28 22:36:00 +00:00
const prevSearchParamsRef = useRef ( searchParams . toString ());
2026-04-15 22:45:46 +01:00
const mapParamsRef = useRef < string >( '' );
2026-04-14 21:02:18 +01:00
const [ geoState , setGeoState ] = useState < 'idle' | 'requesting' | 'error' > ( 'idle' );
const [ geoError , setGeoError ] = useState < string | null >( null );
2026-04-16 16:06:21 +01:00
const [ sortedChips , setSortedChips ] = useState < Array < { chip : CountdownChipData ; days : number | null }> > (
ADMISSIONS_CHIPS . map ( c => ({ chip : c , days : null }))
);
2026-02-02 20:34:35 +00:00
const hasSearch = searchParams . get ( 'search' ) || searchParams . get ( 'postcode' );
const isLocationSearch = !! searchParams . get ( 'postcode' );
2026-03-05 13:00:34 +00:00
const isSearchActive = !! ( hasSearch || searchParams . get ( 'local_authority' ) || searchParams . get ( 'school_type' ));
2026-03-28 22:36:00 +00:00
const currentPhase = searchParams . get ( 'phase' ) || '' ;
2026-03-30 14:07:30 +01:00
const secondaryCount = allSchools . filter ( s => s . attainment_8_score != null ). length ;
const primaryCount = allSchools . filter ( s => s . rwm_expected_pct != null ). length ;
const isSecondaryView = currentPhase . toLowerCase (). includes ( 'secondary' )
|| ( ! currentPhase && secondaryCount > primaryCount );
const isMixedView = primaryCount > 0 && secondaryCount > 0 && ! currentPhase ;
2026-03-28 22:36:00 +00:00
2026-04-15 22:45:46 +01:00
// Reset pagination and map cache when search params change
2026-03-28 22:36:00 +00:00
useEffect (() => {
const newParamsStr = searchParams . toString ();
if ( newParamsStr !== prevSearchParamsRef . current ) {
prevSearchParamsRef . current = newParamsStr ;
2026-04-15 22:45:46 +01:00
mapParamsRef . current = '' ; // allow map to re-fetch for new search
2026-03-28 22:36:00 +00:00
setAllSchools ( initialSchools . schools );
setCurrentPage ( initialSchools . page );
setHasMore ( initialSchools . total_pages > 1 );
2026-03-30 09:13:14 +01:00
setMapSchools ([]);
2026-03-28 22:36:00 +00:00
}
}, [ searchParams , initialSchools ]);
2026-02-02 20:34:35 +00:00
2026-03-05 09:33:47 +00:00
// Close bottom sheet if we change views or search
useEffect (() => {
setSelectedMapSchool ( null );
}, [ resultsView , searchParams ]);
2026-04-15 22:45:46 +01:00
// Fetch all schools within radius when map view is active.
// Guard with a ref so toggling back to map never re-fetches the same params.
2026-03-30 09:13:14 +01:00
useEffect (() => {
if ( resultsView !== 'map' || ! isLocationSearch ) return ;
2026-04-15 22:45:46 +01:00
const paramsKey = searchParams . toString ();
if ( paramsKey === mapParamsRef . current ) return ;
mapParamsRef . current = paramsKey ;
2026-03-30 09:13:14 +01:00
setIsLoadingMap ( true );
const params : Record < string , any > = {};
searchParams . forEach (( value , key ) => { params [ key ] = value ; });
params . page = 1 ;
params . page_size = 500 ;
fetchSchools ( params , { cache : 'no-store' })
. then ( r => setMapSchools ( r . schools ))
. catch (() => setMapSchools ( initialSchools . schools ))
. finally (() => setIsLoadingMap ( false ));
}, [ resultsView , searchParams ]);
2026-03-30 14:07:30 +01:00
// Fetch LA averages when secondary or mixed schools are visible
2026-03-28 22:36:00 +00:00
useEffect (() => {
2026-03-30 14:07:30 +01:00
if ( ! isSecondaryView && ! isMixedView ) return ;
2026-03-28 22:36:00 +00:00
fetchLAaverages ({ cache : 'force-cache' })
. then ( data => setLaAverages ( data . secondary . attainment_8_by_la ))
. catch (() => {});
2026-03-30 14:07:30 +01:00
}, [ isSecondaryView , isMixedView ]);
2026-03-28 22:36:00 +00:00
2026-04-13 14:17:34 +01:00
// Fetch national averages (supplementary — never blocks render)
useEffect (() => {
fetchNationalAverages ()
. then ( data => setNationalAvgRwm ( data . primary ? . rwm_expected_pct ?? null ))
. catch (() => {});
}, []);
2026-04-16 16:06:21 +01:00
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
2026-04-15 17:00:21 +01:00
useEffect (() => {
2026-04-16 16:06:21 +01:00
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 );
2026-04-15 17:00:21 +01:00
}, []);
2026-03-28 22:36:00 +00:00
const handleLoadMore = async () => {
if ( isLoadingMore || ! hasMore ) return ;
2026-05-19 22:04:22 +01:00
track ( 'results_load_more' , { next_page : currentPage + 1 });
2026-03-28 22:36:00 +00:00
setIsLoadingMore ( true );
try {
const params : Record < string , any > = {};
searchParams . forEach (( value , key ) => { params [ key ] = value ; });
params . page = currentPage + 1 ;
2026-03-30 09:08:15 +01:00
params . page_size = initialSchools . page_size ;
2026-03-28 22:36:00 +00:00
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 );
}
};
2026-04-14 21:02:18 +01:00
const handleNearMe = useCallback (() => {
if ( ! navigator . geolocation ) {
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'unsupported' });
2026-04-14 21:02:18 +01:00
setGeoState ( 'error' );
setGeoError ( 'Geolocation is not supported by your browser. Enter a postcode instead.' );
return ;
}
setGeoState ( 'requesting' );
setGeoError ( null );
navigator . geolocation . getCurrentPosition (
async ( position ) => {
const { latitude , longitude } = position . coords ;
try {
const res = await fetch (
`https://api.postcodes.io/postcodes?lon= ${ longitude } &lat= ${ latitude } &limit=1`
);
const data = await res . json ();
if ( data . result && data . result . length > 0 ) {
const postcode = data . result [ 0 ]. postcode as string ;
setGeoState ( 'idle' );
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'granted' });
track ( 'search_submitted' , { query : postcode , via : 'near_me' , has_postcode : true , filters_active : '' , filters_count : 0 });
2026-04-14 21:02:18 +01:00
router . push ( `/?postcode= ${ encodeURIComponent ( postcode ) } &radius=1` );
} else {
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'no_postcode' });
2026-04-14 21:02:18 +01:00
setGeoState ( 'error' );
setGeoError ( 'No postcode found near your location. Try entering one above.' );
}
} catch {
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'lookup_error' });
2026-04-14 21:02:18 +01:00
setGeoState ( 'error' );
setGeoError ( 'Could not look up your location. Please try again.' );
}
},
( err ) => {
setGeoState ( 'error' );
if ( err . code === err . PERMISSION_DENIED ) {
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'denied' });
2026-04-14 21:02:18 +01:00
setGeoError ( 'Location access was denied. Enter a postcode above to find nearby schools.' );
} else {
2026-05-19 22:04:22 +01:00
track ( 'near_me_used' , { outcome : 'error' });
2026-04-14 21:02:18 +01:00
setGeoError ( 'Could not get your location. Please try again or enter a postcode.' );
}
},
{ timeout : 10000 , maximumAge : 60000 }
);
}, [ router ]);
2026-03-28 22:36:00 +00:00
const sortedSchools = [... allSchools ]. sort (( a , b ) => {
2026-03-23 21:31:28 +00:00
if ( sortOrder === 'rwm_desc' ) return ( b . rwm_expected_pct ?? - Infinity ) - ( a . rwm_expected_pct ?? - Infinity );
if ( sortOrder === 'rwm_asc' ) return ( a . rwm_expected_pct ?? Infinity ) - ( b . rwm_expected_pct ?? Infinity );
2026-03-28 22:36:00 +00:00
if ( sortOrder === 'att8_desc' ) return ( b . attainment_8_score ?? - Infinity ) - ( a . attainment_8_score ?? - Infinity );
if ( sortOrder === 'att8_asc' ) return ( a . attainment_8_score ?? Infinity ) - ( b . attainment_8_score ?? Infinity );
2026-03-23 21:31:28 +00:00
if ( sortOrder === 'distance' ) return ( a . distance ?? Infinity ) - ( b . distance ?? Infinity );
if ( sortOrder === 'name_asc' ) return a . school_name . localeCompare ( b . school_name );
return 0 ;
});
2026-05-19 22:04:22 +01:00
// Empty-results sentinel: track when a search returns nothing.
useEffect (() => {
if ( ! isSearchActive ) return ;
if ( initialSchools . total !== 0 ) return ;
track ( 'empty_results' , {
query_length : ( searchParams . get ( 'search' ) || '' ). length ,
has_postcode : !! searchParams . get ( 'postcode' ),
});
}, [ initialSchools . total , isSearchActive , searchParams ]);
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
// split which surface drives compare adds.
const addSchoolFromSearch = useCallback (( school : School ) => {
addSchool ( school );
track ( 'compare_school_added' , {
urn : school.urn ,
from : 'search' ,
selection_count_after : selectedSchools.length + 1 ,
});
}, [ addSchool , selectedSchools . length ]);
2026-02-02 20:34:35 +00:00
return (
< div className = { styles . homeView }>
2026-02-04 09:54:27 +00:00
{ /* Combined Hero + Search and Filters */ }
2026-03-23 21:31:28 +00:00
{ ! isSearchActive && (
< div className = { styles . heroSection }>
2026-04-14 21:02:18 +01:00
< span className = { styles . heroEyebrow }>
< span className = { styles . heroEyebrowDot } aria-hidden = "true" />
2026-06-18 14:20:17 +01:00
Updated with 2026 / 2027 admissions results
2026-04-14 21:02:18 +01:00
</ span >
< h1 className = { styles . heroTitle }>
2026-04-16 13:09:53 +01:00
Every school in England , < em className = { styles . heroEmph }> compared .</ em >
2026-04-14 21:02:18 +01:00
</ h1 >
< p className = { styles . heroDescription }>
2026-07-02 21:56:21 +01:00
{ /* Full copy for larger screens; a compact merged line (coverage +
freshness, standing in for the hidden eyebrow too) on phones,
where every line above the fold costs. */ }
< span className = { styles . heroDescriptionFull }>
< 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 .
</ span >
< span className = { styles . heroDescriptionCompact }>
< strong > 24 , 000 + English schools </ strong > — SATs , GCSEs , Ofsted & amp ; admissions , side by side . Updated for 2026 / 27 .
</ span >
2026-04-14 21:02:18 +01:00
</ p >
2026-03-23 21:31:28 +00:00
</ div >
)}
2026-02-04 09:54:27 +00:00
< FilterBar
filters = { filters }
2026-03-05 13:00:34 +00:00
isHero = { ! isSearchActive }
2026-03-29 08:57:06 +01:00
resultFilters = { initialSchools . result_filters }
2026-02-04 09:54:27 +00:00
/>
2026-02-02 20:34:35 +00:00
2026-03-23 21:31:28 +00:00
{ /* Discovery section shown on landing page before any search */ }
{ ! isSearchActive && initialSchools . schools . length === 0 && (
< div className = { styles . discoverySection }>
2026-04-14 21:02:18 +01:00
< 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 >
2026-03-23 21:31:28 +00:00
</ div >
)}
2026-04-15 17:00:21 +01:00
{ /* 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 >
2026-04-16 16:06:21 +01:00
< div
className = { styles . countdownRail }
style = {{
opacity : sortedChips [ 0 ] ? . days !== null ? 1 : 0 ,
transition : 'opacity 0.2s ease' ,
}}
>
{ sortedChips . map (({ chip , days }) => {
2026-04-15 17:00:21 +01:00
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 >
2026-04-16 12:02:14 +01:00
< span className = { styles . chipDays }>{ days === 0 ? 'Today' : ( days ?? '—' )}</ span >
{ days !== null && days > 0 && < span className = { styles . chipDaysUnit }> days </ span >}
2026-04-15 17:00:21 +01:00
</ div >
< div className = { styles . chipMilestone }>{ chip . milestone }</ div >
< div className = { styles . chipDate }>
{ days !== null ? formatCountdownDate ( chip . month , chip . day ) : '' }
</ div >
</ div >
);
})}
</ div >
</ section >
)}
2026-05-19 09:20:35 +01:00
{ /* 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 >
)}
2026-06-02 13:46:45 +01:00
{ /* How it works + Editorial — server-rendered slots, only on landing */ }
{ ! isSearchActive && howItWorks }
{ ! isSearchActive && editorial }
2026-04-14 21:02:18 +01:00
2026-02-02 20:34:35 +00:00
{ /* Results Section */ }
2026-02-04 10:05:31 +00:00
< section className = { ` ${ styles . results } ${ resultsView === 'map' && isLocationSearch ? styles . mapViewResults : '' } ` }>
2026-02-02 20:34:35 +00:00
{ ! hasSearch && initialSchools . schools . length > 0 && (
< div className = { styles . sectionHeader }>
< h2 > Featured Schools </ h2 >
< p className = { styles . sectionDescription }>
Explore schools from across England
</ p >
</ div >
)}
2026-03-29 20:46:38 +01:00
{ hasSearch && (
2026-02-02 20:34:35 +00:00
< div className = { styles . resultsHeader }>
2026-03-25 20:28:03 +00:00
< h2 aria-live = "polite" aria-atomic = "true" >
2026-03-29 20:46:38 +01:00
{ 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`
}
2026-02-02 20:34:35 +00:00
</ h2 >
2026-03-29 20:46:38 +01:00
< 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' && (
2026-03-30 10:10:28 +01:00
< 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 }
>
2026-03-29 20:46:38 +01:00
< option value = "default" > Sort : Relevance </ option >
2026-04-07 15:53:52 +01:00
{( ! isSecondaryView || isMixedView ) && < option value = "rwm_desc" > Highest Reading , Writing & amp ; Maths % </ option >}
{( ! isSecondaryView || isMixedView ) && < option value = "rwm_asc" > Lowest Reading , Writing & amp ; Maths % </ option >}
2026-03-30 14:07:30 +01:00
{( isSecondaryView || isMixedView ) && < option value = "att8_desc" > Highest Attainment 8 </ option >}
{( isSecondaryView || isMixedView ) && < option value = "att8_asc" > Lowest Attainment 8 </ option >}
2026-03-29 20:46:38 +01:00
{ isLocationSearch && < option value = "distance" > Nearest first </ option >}
< option value = "name_asc" > Name A – Z </ option >
</ select >
)}
</ div >
2026-03-23 21:31:28 +00:00
</ 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 >}
2026-02-02 20:34:35 +00:00
</ div >
)}
2026-03-05 13:00:34 +00:00
{ initialSchools . schools . length === 0 && isSearchActive ? (
2026-02-02 20:34:35 +00:00
< EmptyState
2026-03-05 13:00:34 +00:00
title = "No schools found"
message = "Try adjusting your search criteria or filters to find schools."
action = {{
label : 'Clear all filters' ,
onClick : () => {
window . location . href = '/' ;
},
}}
2026-02-02 20:34:35 +00:00
/>
2026-03-05 13:00:34 +00:00
) : initialSchools . schools . length > 0 && resultsView === 'map' && isLocationSearch ? (
2026-02-04 10:05:31 +00:00
/* Map View Layout */
< div className = { styles . mapViewContainer }>
< div className = { styles . mapContainer }>
< SchoolMap
2026-03-30 09:13:14 +01:00
schools = { isLoadingMap ? initialSchools.schools : mapSchools }
2026-02-04 10:05:31 +00:00
center = { initialSchools . location_info ? . coordinates }
2026-03-30 09:13:14 +01:00
referencePoint = { initialSchools . location_info ? . coordinates }
2026-03-05 09:33:47 +00:00
onMarkerClick = { setSelectedMapSchool }
2026-04-13 14:17:34 +01:00
nationalAvgRwm = { nationalAvgRwm }
2026-04-13 14:29:55 +01:00
laAverages = { laAverages }
2026-02-04 10:05:31 +00:00
/>
</ div >
< div className = { styles . compactList }>
2026-03-30 09:13:14 +01:00
{( isLoadingMap ? initialSchools.schools : mapSchools ). map (( school ) => (
2026-03-23 21:31:28 +00:00
< div
key = { school . urn }
2026-03-05 09:33:47 +00:00
className = { ` ${ styles . listItemWrapper } ${ selectedMapSchool ? . urn === school . urn ? styles . highlightedItem : '' } ` }
>
< CompactSchoolItem
school = { school }
2026-05-19 22:04:22 +01:00
onAddToCompare = { addSchoolFromSearch }
2026-03-05 09:33:47 +00:00
isInCompare = { selectedSchools . some ( s => s . urn === school . urn )}
2026-04-13 14:17:34 +01:00
nationalAvgRwm = { nationalAvgRwm }
2026-03-05 09:33:47 +00:00
/>
</ div >
2026-02-04 10:05:31 +00:00
))}
</ div >
2026-03-05 09:33:47 +00:00
{ /* 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 }
2026-05-19 22:04:22 +01:00
onAddToCompare = { addSchoolFromSearch }
2026-03-05 09:33:47 +00:00
isInCompare = { selectedSchools . some ( s => s . urn === selectedMapSchool . urn )}
2026-04-13 14:17:34 +01:00
nationalAvgRwm = { nationalAvgRwm }
2026-03-05 09:33:47 +00:00
/>
</ div >
</ div >
)}
2026-02-04 10:05:31 +00:00
</ div >
2026-02-02 20:34:35 +00:00
) : (
2026-02-04 10:05:31 +00:00
/* List View Layout */
2026-02-02 20:34:35 +00:00
<>
2026-03-23 22:32:33 +00:00
< div className = { styles . schoolList }>
2026-03-23 21:31:28 +00:00
{ sortedSchools . map (( school ) => (
2026-03-29 08:57:06 +01:00
school . attainment_8_score != null ? (
2026-03-28 22:36:00 +00:00
< SecondarySchoolRow
key = { school . urn }
school = { school }
isLocationSearch = { isLocationSearch }
2026-05-19 22:04:22 +01:00
onAddToCompare = { addSchoolFromSearch }
2026-03-28 22:36:00 +00:00
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 }
2026-05-19 22:04:22 +01:00
onAddToCompare = { addSchoolFromSearch }
2026-03-28 22:36:00 +00:00
onRemoveFromCompare = { removeSchool }
isInCompare = { selectedSchools . some ( s => s . urn === school . urn )}
2026-04-13 14:17:34 +01:00
nationalAvgRwm = { nationalAvgRwm }
2026-03-28 22:36:00 +00:00
/>
)
2026-02-02 20:34:35 +00:00
))}
</ div >
2026-03-28 22:36:00 +00:00
{( hasMore || allSchools . length < initialSchools . total ) && (
< div className = { styles . loadMoreSection }>
< p className = { styles . loadMoreCount }>
Showing { allSchools . length . toLocaleString ()} of { initialSchools . total . toLocaleString ()} schools
</ p >
{ hasMore && (
< button
onClick = { handleLoadMore }
disabled = { isLoadingMore }
className = { `btn btn-secondary ${ styles . loadMoreButton } ` }
>
{ isLoadingMore ? 'Loading...' : 'Load more schools' }
</ button >
)}
</ div >
2026-02-02 20:34:35 +00:00
)}
</>
)}
</ section >
</ div >
);
}
2026-02-04 10:05:31 +00:00
/* Compact School Item for Map View */
interface CompactSchoolItemProps {
school : School ;
onAddToCompare : ( school : School ) => void ;
isInCompare : boolean ;
2026-04-13 14:17:34 +01:00
nationalAvgRwm? : number | null ;
2026-02-04 10:05:31 +00:00
}
2026-04-13 14:17:34 +01:00
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)' };
2026-02-04 10:05:31 +00:00
return (
< div className = { styles . compactItem }>
< div className = { styles . compactItemContent }>
< div className = { styles . compactItemHeader }>
2026-03-29 14:15:06 +01:00
< a href = { schoolUrl ( school . urn , school . school_name )} className = { styles . compactItemName }>
2026-02-04 10:05:31 +00:00
{ school . school_name }
</ a >
{ school . distance !== undefined && school . distance !== null && (
< span className = { styles . distanceBadge }>
2026-03-23 22:39:50 +00:00
{ school . distance . toFixed ( 1 )} mi
2026-02-04 10:05:31 +00:00
</ span >
)}
</ div >
2026-04-13 14:17:34 +01:00
{ /* 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)' } :
2026-07-02 21:33:51 +01:00
ofstedBadge . cssClass === 'ofsted2' ? { background : 'rgba(60,140,60,0.12)' , color : '#2f7a2f' } :
ofstedBadge . cssClass === 'ofsted3' ? { background : 'var(--accent-gold-bg)' , color : 'var(--accent-gold-text, #7a6800)' } :
2026-04-13 14:17:34 +01:00
ofstedBadge . cssClass === 'ofsted4' ? { background : 'var(--accent-coral-bg)' , color : 'var(--accent-coral, #e07256)' } :
ofstedBadge . cssClass === 'ofstedRc' ? { background : '#5a3a6e' , color : '#fff' } :
2026-04-13 14:26:55 +01:00
ofstedBadge . cssClass === 'ofstedPending' ? { background : '#e0e0e0' , color : '#666' } :
2026-04-13 14:17:34 +01:00
{ background : '#e0e0e0' , color : '#666' }),
}}
>
{ ofstedBadge . label }
</ span >
2026-02-04 10:05:31 +00:00
</ div >
2026-04-13 14:17:34 +01:00
{ /* Headline metric + delta */ }
2026-02-04 10:05:31 +00:00
< div className = { styles . compactItemStats }>
< span className = { styles . compactStat }>
2026-03-28 14:59:40 +00:00
< strong >
2026-04-13 14:17:34 +01:00
{ 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' }
2026-02-04 10:05:31 +00:00
</ span >
2026-04-13 14:17:34 +01:00
{ rwmDelta != null && (
< span style = { deltaStyle }>
{ rwmDelta >= 2
? `+ ${ rwmDelta } pts vs national`
: rwmDelta <= - 2
? ` ${ rwmDelta } pts vs national`
: '≈ national avg' }
</ span >
)}
2026-02-04 10:05:31 +00:00
</ div >
</ div >
< div className = { styles . compactItemActions }>
< button
2026-03-25 20:28:03 +00:00
className = { isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm' }
2026-02-04 10:05:31 +00:00
onClick = {() => onAddToCompare ( school )}
>
2026-03-25 20:28:03 +00:00
{ isInCompare ? '✓ Comparing' : '+ Compare' }
2026-02-04 10:05:31 +00:00
</ button >
2026-03-29 14:15:06 +01:00
< a href = { schoolUrl ( school . urn , school . school_name )} className = "btn btn-tertiary btn-sm" >
2026-03-25 20:28:03 +00:00
View
2026-02-04 10:05:31 +00:00
</ a >
</ div >
</ div >
);
}