/** * 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 { HeroIllustration } from './Illustration'; 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 }, ]; /* ── Value-prop icons ────────────────────────────────────────────────────── Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept local because they exist for this one row and nothing else. */ const ICON_PROPS = { viewBox: '0 0 24 24', width: 21, height: 21, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, } as const; function ShieldCheckIcon() { return ( ); } function BarsIcon() { return ( ); } function HeartIcon() { return ( ); } function CalendarIcon() { return ( ); } interface ValueProp { icon: React.ReactNode; tintClass: string; title: string; body: string; } /* * The brand guideline's fourth value prop is "Save & revisit" (shortlisting / * favourites). That feature does not exist in this product, so it is * deliberately replaced by the admissions-deadline prop below — which is real, * and is backed by the countdown strip further down this page. */ const VALUE_PROPS: ValueProp[] = [ { icon: , tintClass: styles.propIconTrust, title: 'Official & trusted', body: 'Every figure comes from DfE performance tables and Ofsted.', }, { icon: , tintClass: styles.propIconCompare, title: 'Easy to compare', body: 'Up to three schools side by side, on the measures that matter.', }, { icon: , tintClass: styles.propIconContext, title: 'Beyond the numbers', body: 'Class sizes, SEN support and local context, not just results.', }, { icon: , tintClass: styles.propIconDeadline, title: 'Never miss a deadline', body: 'Application and offer dates for both primary and secondary.', }, ]; 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 (
{/* Hero: a Sand panel with the proposition and the search on the left and the brand landscape bleeding to the panel edge on the right. The search lives inside the panel here and above the results elsewhere, which is why FilterBar is rendered in two places rather than moved. */} {!isSearchActive ? (

Find the school where your child will{' '} flourish.

Compare every school in England with clarity and confidence. {/* The coverage sentence is the detail a first-time visitor needs but a returning one does not, so it drops away on phones where every line above the fold costs. */} {' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data for 24,000+ schools — side by side, in one place.

) : ( )} {/* Why this site, in four lines. Only on the landing page. */} {!isSearchActive && (
    {VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
  • {icon}

    {title}

    {body}

  • ))}
)} {/* 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(--status-above)', fontWeight: 600 } : rwmDelta <= -2 ? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 } : { fontSize: '0.7rem', color: 'var(--text-muted)' }; 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
); }