/** * 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 { track } from '@/lib/analytics'; import styles from './HomeView.module.css'; interface HomeViewProps { initialSchools: SchoolsResponse; filters: Filters; totalSchools?: number | null; // Slot props for static markup the server pre-renders so it stays out of // the client bundle. Server passes null when the landing sections shouldn't // show (e.g. an active search). howItWorks?: React.ReactNode; editorial?: React.ReactNode; } function daysUntil(month: number, day: number): number { const today = new Date(); today.setHours(0, 0, 0, 0); const y = today.getFullYear(); let target = new Date(y, month - 1, day); if (target < today) target = new Date(y + 1, month - 1, day); return Math.round((target.getTime() - today.getTime()) / 86_400_000); } function 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, howItWorks, editorial }: HomeViewProps) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const { addSchool, removeSchool, selectedSchools } = useComparisonContext(); const [resultsView, setResultsView] = useState<'list' | 'map'>('list'); const [selectedMapSchool, setSelectedMapSchool] = useState(null); const sortOrder = searchParams.get('sort') || 'default'; const [allSchools, setAllSchools] = useState(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>({}); const [nationalAvgRwm, setNationalAvgRwm] = useState(null); const [mapSchools, setMapSchools] = useState([]); const [isLoadingMap, setIsLoadingMap] = useState(false); const prevSearchParamsRef = useRef(searchParams.toString()); const mapParamsRef = useRef(''); const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle'); const [geoError, setGeoError] = useState(null); const [sortedChips, setSortedChips] = useState>( 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 = {}; 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; track('results_load_more', { next_page: currentPage + 1 }); setIsLoadingMore(true); try { const params: Record = {}; searchParams.forEach((value, key) => { params[key] = value; }); params.page = currentPage + 1; params.page_size = initialSchools.page_size; const response = await fetchSchools(params, { cache: 'no-store' }); setAllSchools(prev => [...prev, ...response.schools]); setCurrentPage(response.page); setHasMore(response.page < response.total_pages); } catch { // silently ignore } finally { setIsLoadingMore(false); } }; const handleNearMe = useCallback(() => { if (!navigator.geolocation) { track('near_me_used', { outcome: 'unsupported' }); setGeoState('error'); setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.'); return; } setGeoState('requesting'); setGeoError(null); navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords; try { const res = await fetch( `https://api.postcodes.io/postcodes?lon=${longitude}&lat=${latitude}&limit=1` ); const data = await res.json(); if (data.result && data.result.length > 0) { const postcode = data.result[0].postcode as string; setGeoState('idle'); track('near_me_used', { outcome: 'granted' }); track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 }); router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`); } else { track('near_me_used', { outcome: 'no_postcode' }); setGeoState('error'); setGeoError('No postcode found near your location. Try entering one above.'); } } catch { track('near_me_used', { outcome: 'lookup_error' }); setGeoState('error'); setGeoError('Could not look up your location. Please try again.'); } }, (err) => { setGeoState('error'); if (err.code === err.PERMISSION_DENIED) { track('near_me_used', { outcome: 'denied' }); setGeoError('Location access was denied. Enter a postcode above to find nearby schools.'); } else { track('near_me_used', { outcome: 'error' }); setGeoError('Could not get your location. Please try again or enter a postcode.'); } }, { timeout: 10000, maximumAge: 60000 } ); }, [router]); const sortedSchools = [...allSchools].sort((a, b) => { if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity); if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity); if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity); if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity); if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity); if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name); return 0; }); // Empty-results sentinel: track when a search returns nothing. useEffect(() => { if (!isSearchActive) return; if (initialSchools.total !== 0) return; track('empty_results', { query_length: (searchParams.get('search') || '').length, has_postcode: !!searchParams.get('postcode'), }); }, [initialSchools.total, isSearchActive, searchParams]); // Wrap addSchool with `from: 'search'` attribution so funnel reports can // split which surface drives compare adds. const addSchoolFromSearch = useCallback((school: School) => { addSchool(school); track('compare_school_added', { urn: school.urn, from: 'search', selection_count_after: selectedSchools.length + 1, }); }, [addSchool, selectedSchools.length]); return (
{/* Combined Hero + Search and Filters */} {!isSearchActive && (

Every school in England, compared.

24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.

)} {/* Discovery section shown on landing page before any search */} {!isSearchActive && initialSchools.schools.length === 0 && (
{geoError &&

{geoError}

}
)} {/* Admissions countdown strip — only on landing page */} {!isSearchActive && (
Key admissions deadlines Full admissions guide →
{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 (
{days === 0 ? 'Today' : (days ?? '—')} {days !== null && days > 0 && days}
{chip.milestone}
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
); })}
)} {/* Secondary discovery — moved below deadlines so the admissions countdown (time-sensitive) shows ahead of generic "explore" links. */} {!isSearchActive && initialSchools.schools.length === 0 && ( )} {/* How it works + Editorial — server-rendered slots, only on landing */} {!isSearchActive && howItWorks} {!isSearchActive && editorial} {/* Results Section */}
{!hasSearch && initialSchools.schools.length > 0 && (

Featured Schools

Explore schools from across England

)} {hasSearch && (

{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` }

{isLocationSearch && initialSchools.schools.length > 0 && (
)} {resultsView === 'list' && ( )}
)} {isSearchActive && (
{searchParams.get('search') && Search: {searchParams.get('search')} { e.preventDefault(); }}>×} {searchParams.get('local_authority') && {searchParams.get('local_authority')}} {searchParams.get('school_type') && {searchParams.get('school_type')}}
)} {initialSchools.schools.length === 0 && isSearchActive ? ( { window.location.href = '/'; }, }} /> ) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? ( /* Map View Layout */
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
s.urn === school.urn)} nationalAvgRwm={nationalAvgRwm} />
))}
{/* Mobile Bottom Sheet for Selected Map Pin */} {selectedMapSchool && (
s.urn === selectedMapSchool.urn)} nationalAvgRwm={nationalAvgRwm} />
)}
) : ( /* List View Layout */ <>
{sortedSchools.map((school) => ( school.attainment_8_score != null ? ( s.urn === school.urn)} laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null} /> ) : ( s.urn === school.urn)} nationalAvgRwm={nationalAvgRwm} /> ) ))}
{(hasMore || allSchools.length < initialSchools.total) && (

Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools

{hasMore && ( )}
)} )}
); } /* 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 (
{school.school_name} {school.distance !== undefined && school.distance !== null && ( {school.distance.toFixed(1)} mi )}
{/* Ofsted badge */}
{ofstedBadge.label}
{/* Headline metric + delta */}
{isSecondary ? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-') : (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')} {' '} {isSecondary ? 'Att 8' : 'RWM'} {rwmDelta != null && ( {rwmDelta >= 2 ? `+${rwmDelta} pts vs national` : rwmDelta <= -2 ? `${rwmDelta} pts vs national` : '≈ national avg'} )}
View
); }