9e0b004d93
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
782 lines
36 KiB
TypeScript
782 lines
36 KiB
TypeScript
/**
|
||
* 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 styles from './HomeView.module.css';
|
||
|
||
interface HomeViewProps {
|
||
initialSchools: SchoolsResponse;
|
||
filters: Filters;
|
||
totalSchools?: number | null;
|
||
}
|
||
|
||
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 }: 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<School | null>(null);
|
||
const sortOrder = searchParams.get('sort') || 'default';
|
||
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 [nationalAvgRwm, setNationalAvgRwm] = useState<number | null>(null);
|
||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||
const mapParamsRef = useRef<string>('');
|
||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||
const [geoError, setGeoError] = useState<string | null>(null);
|
||
const [chipDays, setChipDays] = useState<(number | null)[]>(ADMISSIONS_CHIPS.map(() => 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<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]);
|
||
|
||
// 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 to avoid SSR mismatch
|
||
useEffect(() => {
|
||
setChipDays(ADMISSIONS_CHIPS.map(c => daysUntil(c.month, c.day)));
|
||
}, []);
|
||
|
||
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;
|
||
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) {
|
||
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');
|
||
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||
} else {
|
||
setGeoState('error');
|
||
setGeoError('No postcode found near your location. Try entering one above.');
|
||
}
|
||
} catch {
|
||
setGeoState('error');
|
||
setGeoError('Could not look up your location. Please try again.');
|
||
}
|
||
},
|
||
(err) => {
|
||
setGeoState('error');
|
||
if (err.code === err.PERMISSION_DENIED) {
|
||
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||
} else {
|
||
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;
|
||
});
|
||
|
||
return (
|
||
<div className={styles.homeView}>
|
||
{/* Combined Hero + Search and Filters */}
|
||
{!isSearchActive && (
|
||
<div className={styles.heroSection}>
|
||
<span className={styles.heroEyebrow}>
|
||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||
2024/25 results · updated April 2026
|
||
</span>
|
||
<h1 className={styles.heroTitle}>
|
||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||
</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>
|
||
</div>
|
||
)}
|
||
|
||
<FilterBar
|
||
filters={filters}
|
||
isHero={!isSearchActive}
|
||
resultFilters={initialSchools.result_filters}
|
||
/>
|
||
|
||
{/* Discovery section shown on landing page before any search */}
|
||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||
<div className={styles.discoverySection}>
|
||
<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>
|
||
<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>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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>
|
||
<div className={styles.countdownRail}>
|
||
{ADMISSIONS_CHIPS.map((chip, i) => {
|
||
const days = chipDays[i];
|
||
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>
|
||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||
</div>
|
||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||
<div className={styles.chipDate}>
|
||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* How it works — only on landing page */}
|
||
{!isSearchActive && (
|
||
<section className={styles.howItWorks}>
|
||
<div className={styles.hiwHeader}>
|
||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||
</div>
|
||
<div className={styles.hiwGrid}>
|
||
|
||
{/* Card 1 — Performance */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
{/* Primary: mini cascade */}
|
||
<div className={styles.hiwPhaseBlock}>
|
||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||
<div className={styles.miniCascade}>
|
||
{[
|
||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
||
].map(({ subj, exp, exc, nat }) => (
|
||
<div key={subj} className={styles.miniCascadeCol}>
|
||
<div className={styles.miniSubj}>{subj}</div>
|
||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||
<div className={styles.miniTrack}>
|
||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||
</div>
|
||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
||
<div className={styles.miniTrack}>
|
||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* Secondary: Attainment 8 */}
|
||
<div className={styles.hiwPhaseBlock}>
|
||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||
<div className={styles.att8Row}>
|
||
<div className={styles.att8BarWrap}>
|
||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
||
<div className={styles.att8Track}>
|
||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
||
</div>
|
||
</div>
|
||
<div className={styles.att8Score}>
|
||
<div className={styles.att8Value}>62.4</div>
|
||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Performance</div>
|
||
<div className={styles.hiwTitle}>Results against the national average</div>
|
||
<p className={styles.hiwDesc}>For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 2 — Ofsted */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
<div className={styles.ofstedPreview}>
|
||
<div className={styles.ofstedHead}>
|
||
<span className={styles.ofstedBullet} />
|
||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||
</div>
|
||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Judgement</div>
|
||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card 3 — Compare */}
|
||
<div className={styles.hiwCard}>
|
||
<div className={styles.hiwVisual}>
|
||
<div className={styles.comparePreview}>
|
||
<div className={styles.compareHead}>
|
||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||
</div>
|
||
{[
|
||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
||
].map(({ label, a, b, aHi }) => (
|
||
<div key={label} className={styles.compareRow}>
|
||
<span className={styles.compareRowLabel}>{label}</span>
|
||
<span className={`${styles.compareRowVal} ${aHi ? styles.compareRowValHi : ''}`}>{a}</span>
|
||
<span className={styles.compareRowVal}>{b}</span>
|
||
</div>
|
||
))}
|
||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.hiwCardBody}>
|
||
<div className={styles.hiwStep}>Compare</div>
|
||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Editorial — only on landing page */}
|
||
{!isSearchActive && (
|
||
<section className={styles.editorial}>
|
||
<div className={styles.editorialGrid}>
|
||
<div className={styles.editorialText}>
|
||
<div className={styles.editorialKicker}>About school data</div>
|
||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||
<p>
|
||
School performance data in England is rich but fragmented. The Department for Education publishes
|
||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||
demographics — each in its own table, each with its own jargon.
|
||
</p>
|
||
<p>
|
||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||
40-page inspection report.
|
||
</p>
|
||
</div>
|
||
<div className={styles.factbox}>
|
||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Schools covered</span>
|
||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Local authorities</span>
|
||
<span className={styles.factVal}>{filters.local_authorities.length > 0 ? filters.local_authorities.length : 152}</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Phases</span>
|
||
<span className={styles.factVal}>Primary & Secondary</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Latest results year</span>
|
||
<span className={styles.factVal}>2024/25</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Historical data</span>
|
||
<span className={styles.factVal}>2016–2025</span>
|
||
</div>
|
||
<div className={styles.factRow}>
|
||
<span className={styles.factKey}>Metrics per school</span>
|
||
<span className={styles.factVal}>40+</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Results Section */}
|
||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||
<div className={styles.sectionHeader}>
|
||
<h2>Featured Schools</h2>
|
||
<p className={styles.sectionDescription}>
|
||
Explore schools from across England
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{hasSearch && (
|
||
<div className={styles.resultsHeader}>
|
||
<h2 aria-live="polite" aria-atomic="true">
|
||
{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`
|
||
}
|
||
</h2>
|
||
<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 => {
|
||
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}
|
||
>
|
||
<option value="default">Sort: Relevance</option>
|
||
{(!isSecondaryView || isMixedView) && <option value="rwm_desc">Highest Reading, Writing & Maths %</option>}
|
||
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing & Maths %</option>}
|
||
{(isSecondaryView || isMixedView) && <option value="att8_desc">Highest Attainment 8</option>}
|
||
{(isSecondaryView || isMixedView) && <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>
|
||
</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>}
|
||
</div>
|
||
)}
|
||
|
||
{initialSchools.schools.length === 0 && isSearchActive ? (
|
||
<EmptyState
|
||
title="No schools found"
|
||
message="Try adjusting your search criteria or filters to find schools."
|
||
action={{
|
||
label: 'Clear all filters',
|
||
onClick: () => {
|
||
window.location.href = '/';
|
||
},
|
||
}}
|
||
/>
|
||
) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
|
||
/* Map View Layout */
|
||
<div className={styles.mapViewContainer}>
|
||
<div className={styles.mapContainer}>
|
||
<SchoolMap
|
||
schools={isLoadingMap ? initialSchools.schools : mapSchools}
|
||
center={initialSchools.location_info?.coordinates}
|
||
referencePoint={initialSchools.location_info?.coordinates}
|
||
onMarkerClick={setSelectedMapSchool}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
laAverages={laAverages}
|
||
/>
|
||
</div>
|
||
<div className={styles.compactList}>
|
||
{(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
|
||
<div
|
||
key={school.urn}
|
||
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||
>
|
||
<CompactSchoolItem
|
||
school={school}
|
||
onAddToCompare={addSchool}
|
||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 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)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* List View Layout */
|
||
<>
|
||
<div className={styles.schoolList}>
|
||
{sortedSchools.map((school) => (
|
||
school.attainment_8_score != null ? (
|
||
<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)}
|
||
nationalAvgRwm={nationalAvgRwm}
|
||
/>
|
||
)
|
||
))}
|
||
</div>
|
||
|
||
{(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>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* 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 (
|
||
<div className={styles.compactItem}>
|
||
<div className={styles.compactItemContent}>
|
||
<div className={styles.compactItemHeader}>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.compactItemName}>
|
||
{school.school_name}
|
||
</a>
|
||
{school.distance !== undefined && school.distance !== null && (
|
||
<span className={styles.distanceBadge}>
|
||
{school.distance.toFixed(1)} mi
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* 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' } :
|
||
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
|
||
{ background: '#e0e0e0', color: '#666' }),
|
||
}}
|
||
>
|
||
{ofstedBadge.label}
|
||
</span>
|
||
</div>
|
||
{/* Headline metric + delta */}
|
||
<div className={styles.compactItemStats}>
|
||
<span className={styles.compactStat}>
|
||
<strong>
|
||
{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'}
|
||
</span>
|
||
{rwmDelta != null && (
|
||
<span style={deltaStyle}>
|
||
{rwmDelta >= 2
|
||
? `+${rwmDelta} pts vs national`
|
||
: rwmDelta <= -2
|
||
? `${rwmDelta} pts vs national`
|
||
: '≈ national avg'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={styles.compactItemActions}>
|
||
<button
|
||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||
onClick={() => onAddToCompare(school)}
|
||
>
|
||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||
</button>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||
View
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|