62eeee5f7c
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Frontend - Dynamic-import Chart.js components on detail/compare views so Chart.js no longer ships in initial JS. - Drop force-dynamic on home, compare, rankings so internal data fetches reuse Next.js's per-call revalidate cache. - Switch /school/[slug] to ISR with a 7-day revalidate window (school data updates annually). - Preconnect to analytics + postcodes.io; remove redundant defer on the Umami Script tag (afterInteractive already covers it). - Bump images.minimumCacheTTL to 1 year. - Extract HowItWorks and Editorial sections as server components passed to HomeView via slot props so their JSX stays out of the client bundle. Backend - Add GZipMiddleware (min 512 bytes). - Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage + stale-while-revalidate, ETag generation, and 304 on If-None-Match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
670 lines
29 KiB
TypeScript
670 lines
29 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 { 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<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}>
|
||
{/* Combined Hero + Search and Filters */}
|
||
{!isSearchActive && (
|
||
<div className={styles.heroSection}>
|
||
<span className={styles.heroEyebrow}>
|
||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||
Updated with 2024/25 results
|
||
</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>
|
||
)}
|
||
|
||
{/* 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 & 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={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(--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>
|
||
);
|
||
}
|