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-04-20 10:52:43 +01:00
Updated with 2024 / 25 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 } >
< 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 >
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)' } :
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' } :
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 >
) ;
}