Files
school_compare/nextjs-app/components/HomeView.tsx
T
Tudor accc562b90
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m29s
feat(brand): adopt the schoolcompare identity across the site
Implements the supplied brand guideline: the pin-and-leaf mark, the lowercase
schoolcompare wordmark, Manrope for headings and Inter for everything a person
reads a number from, the deep-teal/coral/mustard/sage palette, and the rounder
geometry the guideline asks for.

The landing hero is rebuilt to the concept: a Sand panel carrying the
proposition, the search and a trust line, with the brand landscape bleeding to
the panel's right edge, followed by the four value props.

Concept vs reality — where the guideline describes something this product does
not have, the real feature takes its place and the decision is recorded in a
code comment next to it:

- The Thriving Score is not implemented. Four of its five sub-scores (pastoral,
  enrichment, community, facilities) have no source in DfE or Ofsted data, and
  its band names are Ofsted's own, which would sit beside the real Ofsted grade
  and be indistinguishable from it.
- Guides, About, Favourites and Sign in are not added to the nav — there are no
  such pages and no accounts system. Rankings and Admissions, which the
  guideline omits, keep their places because they exist.
- The fourth value prop is "Never miss a deadline" rather than "Save & revisit",
  which would need shortlisting.

Two deviations from the supplied swatches, both forced by contrast and both
confined to text and fills. Coral #F97360 under a white label measures 2.75:1
against a 4.5 floor, so --action is a darkened #BE3C27 and the original coral
survives as --coral for tints, borders and illustration. Mustard #F2C94C is
1.5:1 as text, so --mustard-ink carries labels while --mustard stays exact for
fills and rings. Every token clears AA on all three light grounds; Sand is the
binding one, so several values sit a step darker than white alone would need.

The guideline is light-only, but the site already ships a dark theme, so it is
rebuilt from the same tokens rather than dropped.

Verified: tsc clean, 159/159 unit tests, production build green, and the
generated share card and app icon render from the new mark. The hero and the
site chrome were rendered from the compiled CSS and the prerendered markup to
confirm Manrope and Inter actually resolve — a font token that silently falls
back has shipped here before.
2026-08-07 18:05:49 +01:00

786 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<svg {...ICON_PROPS}>
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function BarsIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
</svg>
);
}
function HeartIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
</svg>
);
}
function CalendarIcon() {
return (
<svg {...ICON_PROPS}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
}
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: <ShieldCheckIcon />,
tintClass: styles.propIconTrust,
title: 'Official & trusted',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
icon: <BarsIcon />,
tintClass: styles.propIconCompare,
title: 'Easy to compare',
body: 'Up to three schools side by side, on the measures that matter.',
},
{
icon: <HeartIcon />,
tintClass: styles.propIconContext,
title: 'Beyond the numbers',
body: 'Class sizes, SEN support and local context, not just results.',
},
{
icon: <CalendarIcon />,
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<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 [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
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<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 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<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) {
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 (
<div className={styles.homeView}>
{/* 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 ? (
<section className={styles.heroPanel}>
<div className={styles.heroContent}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated for the 2026/27 admissions round
</span>
<h1 className={styles.heroTitle}>
Find the school where your child will{' '}
<em className={styles.heroEmph}>flourish.</em>
</h1>
<p className={styles.heroDescription}>
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. */}
<span className={styles.heroDescriptionFull}>
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
and admissions data for <strong>24,000+ schools</strong> side by
side, in one place.
</span>
</p>
<FilterBar
filters={filters}
isHero
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
<p className={styles.heroTrust}>
<span className={styles.heroTrustDots} aria-hidden="true">
<span className={styles.heroTrustDot} />
<span className={styles.heroTrustDot} />
<span className={styles.heroTrustDot} />
</span>
Built on official DfE and Ofsted data 24,000+ schools
</p>
</div>
<div className={styles.heroArt}>
<HeroIllustration />
</div>
</section>
) : (
<FilterBar
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
)}
{/* Why this site, in four lines. Only on the landing page. */}
{!isSearchActive && (
<ul className={styles.valueProps}>
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
<li key={title} className={styles.valueProp}>
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
<div>
<h2 className={styles.propTitle}>{title}</h2>
<p className={styles.propBody}>{body}</p>
</div>
</li>
))}
</ul>
)}
{/* 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}
style={{
opacity: sortedChips[0]?.days !== null ? 1 : 0,
transition: 'opacity 0.2s ease',
}}
>
{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 (
<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>
)}
{/* 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>
)}
{/* How it works + Editorial — server-rendered slots, only on landing */}
{!isSearchActive && howItWorks}
{!isSearchActive && editorial}
{/* 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 &amp; Maths %</option>}
{(!isSecondaryView || isMixedView) && <option value="rwm_asc">Lowest Reading, Writing &amp; 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 AZ</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={addSchoolFromSearch}
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={addSchoolFromSearch}
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={addSchoolFromSearch}
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={addSchoolFromSearch}
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(--status-above)', fontWeight: 600 }
: rwmDelta <= -2
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
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(--status-above-bg)', color: 'var(--status-above)' } :
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
}}
>
{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>
);
}