2026-02-02 20:34:35 +00:00
/ * *
* HomeView Component
* Client - side home page view with search and filtering
* /
'use client' ;
2026-03-28 22:36:00 +00:00
import { useState , useEffect , useRef } from 'react' ;
2026-02-02 20:34:35 +00:00
import { useSearchParams } from 'next/navigation' ;
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-03-28 22:36:00 +00:00
import { fetchSchools , fetchLAaverages } from '@/lib/api' ;
2026-02-04 10:05:31 +00:00
import type { SchoolsResponse , Filters , School } from '@/lib/types' ;
2026-03-29 12:41:28 +01:00
import { schoolUrl } from '@/lib/utils' ;
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-02-02 20:34:35 +00:00
}
2026-03-23 21:31:28 +00:00
export function HomeView ( { initialSchools , filters , totalSchools } : HomeViewProps ) {
2026-02-02 20:34:35 +00:00
const searchParams = useSearchParams ( ) ;
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-23 21:31:28 +00:00
const [ sortOrder , setSortOrder ] = useState < string > ( '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 > > ( { } ) ;
const prevSearchParamsRef = useRef ( searchParams . toString ( ) ) ;
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' ) || '' ;
const hasSecondaryResults = allSchools . some ( s = > s . attainment_8_score != null ) ;
const isSecondaryView = currentPhase . toLowerCase ( ) . includes ( 'secondary' ) || hasSecondaryResults ;
// Reset pagination state when search params change
useEffect ( ( ) = > {
const newParamsStr = searchParams . toString ( ) ;
if ( newParamsStr !== prevSearchParamsRef . current ) {
prevSearchParamsRef . current = newParamsStr ;
setAllSchools ( initialSchools . schools ) ;
setCurrentPage ( initialSchools . page ) ;
setHasMore ( initialSchools . total_pages > 1 ) ;
}
} , [ 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-03-28 22:36:00 +00:00
// Fetch LA averages when secondary schools are visible
useEffect ( ( ) = > {
if ( ! isSecondaryView ) return ;
fetchLAaverages ( { cache : 'force-cache' } )
. then ( data = > setLaAverages ( data . secondary . attainment_8_by_la ) )
. catch ( ( ) = > { } ) ;
} , [ isSecondaryView ] ) ;
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 ;
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 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-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-03-29 20:14:42 +01:00
< h1 className = { styles . heroTitle } > Find Local Schools < / h1 >
< p className = { styles . heroDescription } > Compare school results ( SATs and GCSE ) , for thousands of schools across England < / 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 } >
feat: add secondary school support with KS4 data and metric tooltips
- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
/api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
tab hidden for secondary; admissions says Year 7 instead of Year 3;
history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:59:40 +00:00
{ totalSchools && < p className = { styles . discoveryCount } > < strong > { totalSchools . toLocaleString ( ) } + < / strong > primary and secondary schools across England < / p > }
2026-03-23 21:31:28 +00:00
< p className = { styles . discoveryHints } > Try searching for a school name , or enter a postcode to find schools near you . < / p >
< div className = { styles . quickSearches } >
< span className = { styles . quickSearchLabel } > Quick searches : < / span >
{ [ 'Manchester' , 'Bristol' , 'Leeds' , 'Birmingham' ] . map ( city = > (
< a key = { city } href = { ` /?search= ${ city } ` } className = { styles . quickSearchChip } > { city } < / a >
) ) }
< / div >
< / div >
) }
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' && (
< select value = { sortOrder } onChange = { e = > setSortOrder ( e . target . value ) } className = { styles . sortSelect } >
< option value = "default" > Sort : Relevance < / option >
{ ! isSecondaryView && < option value = "rwm_desc" > Highest R , W & amp ; M % < / option > }
{ ! isSecondaryView && < option value = "rwm_asc" > Lowest R , W & amp ; M % < / option > }
{ isSecondaryView && < option value = "att8_desc" > Highest Attainment 8 < / option > }
{ isSecondaryView && < 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 >
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
schools = { initialSchools . schools }
center = { initialSchools . location_info ? . coordinates }
2026-03-05 09:33:47 +00:00
onMarkerClick = { setSelectedMapSchool }
2026-02-04 10:05:31 +00:00
/ >
< / div >
< div className = { styles . compactList } >
{ initialSchools . schools . 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 }
onAddToCompare = { addSchool }
isInCompare = { selectedSchools . some ( s = > s . urn === school . urn ) }
/ >
< / 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 }
onAddToCompare = { addSchool }
isInCompare = { selectedSchools . some ( s = > s . urn === selectedMapSchool . urn ) }
/ >
< / 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 }
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 ) }
/ >
)
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 ;
}
function CompactSchoolItem ( { school , onAddToCompare , isInCompare } : CompactSchoolItemProps ) {
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 >
< div className = { styles . compactItemMeta } >
{ school . school_type && < span > { school . school_type } < / span > }
{ school . local_authority && < span > { school . local_authority } < / span > }
< / div >
< div className = { styles . compactItemStats } >
< span className = { styles . compactStat } >
feat: add secondary school support with KS4 data and metric tooltips
- Backend: replace INNER JOIN ks2 with UNION ALL (ks2 + ks4) so primary
and secondary schools both appear in the main DataFrame
- Backend: add /api/national-averages endpoint computing means from live
data, replacing the hardcoded NATIONAL_AVG constant on the frontend
- Backend: add phase filter param to /api/schools; return phases from
/api/filters; fix hardcoded "phase": "Primary" in school detail endpoint
- Backend: add KS4 metric definitions (Attainment 8, Progress 8, EBacc,
English & Maths pass rates) to METRIC_DEFINITIONS and RANKING_COLUMNS
- Frontend: SchoolDetailView is now phase-aware — secondary schools show
a GCSE Results section (Att8, P8, E&M, EBacc) instead of SATs; phonics
tab hidden for secondary; admissions says Year 7 instead of Year 3;
history table shows KS4 columns; chart datasets switch for secondary
- Frontend: new MetricTooltip component (CSS-only ⓘ icon) backed by
METRIC_EXPLANATIONS — added to RWM, GPS, SEN, EAL, IDACI, progress
scores and all KS4 metrics throughout SchoolDetailView and SchoolCard
- Frontend: METRIC_EXPLANATIONS extended with KS4 terms (Attainment 8,
Progress 8, EBacc) and previously missing terms (SEN, EHCP, EAL, IDACI)
- Frontend: SchoolCard expands "RWM" to "Reading, Writing & Maths" and
shows Attainment 8 / English & Maths Grade 4+ for secondary schools
- Frontend: FilterBar adds Phase dropdown (Primary / Secondary / All-through)
- Frontend: HomeView hero copy updated; compact list shows phase-aware metric
- Global metadata updated to remove "primary only" framing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:59:40 +00:00
< strong >
{ school . attainment_8_score != null
? school . attainment_8_score . toFixed ( 1 )
: school . rwm_expected_pct !== null
? ` ${ school . rwm_expected_pct } % `
: '-' }
< / strong > { ' ' }
{ school . attainment_8_score != null ? 'Att 8' : 'RWM' }
2026-02-04 10:05:31 +00:00
< / span >
< span className = { styles . compactStat } >
< strong > { school . total_pupils || '-' } < / strong > pupils
< / span >
< / 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 >
) ;
}