FilterBar was sending radius in km (e.g. 1.6) but the backend expects miles, causing the "Showing schools within X miles" banner to display the wrong value. Change option values to miles (0.5, 1, 3, 5, 10) and default from 1.6 to 1. school.distance from the API is already in miles (backend haversine uses R=3959). SchoolRow was dividing by 1609.34 giving 0.0 mi; CompactSchoolItem was dividing by 1.60934. Both now display school.distance directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
/**
|
||
* HomeView Component
|
||
* Client-side home page view with search and filtering
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { FilterBar } from './FilterBar';
|
||
import { SchoolRow } from './SchoolRow';
|
||
import { SchoolMap } from './SchoolMap';
|
||
import { Pagination } from './Pagination';
|
||
import { EmptyState } from './EmptyState';
|
||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||
import styles from './HomeView.module.css';
|
||
|
||
interface HomeViewProps {
|
||
initialSchools: SchoolsResponse;
|
||
filters: Filters;
|
||
totalSchools?: number | null;
|
||
}
|
||
|
||
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
||
const searchParams = useSearchParams();
|
||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||
const [sortOrder, setSortOrder] = useState<string>('default');
|
||
|
||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||
const isLocationSearch = !!searchParams.get('postcode');
|
||
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
||
|
||
// Close bottom sheet if we change views or search
|
||
useEffect(() => {
|
||
setSelectedMapSchool(null);
|
||
}, [resultsView, searchParams]);
|
||
|
||
const sortedSchools = [...initialSchools.schools].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 === '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}>
|
||
<h1 className={styles.heroTitle}>Compare Primary School Performance</h1>
|
||
<p className={styles.heroDescription}>Search and compare KS2 results for thousands of schools across England</p>
|
||
</div>
|
||
)}
|
||
|
||
<FilterBar
|
||
filters={filters}
|
||
isHero={!isSearchActive}
|
||
/>
|
||
|
||
{/* Discovery section shown on landing page before any search */}
|
||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||
<div className={styles.discoverySection}>
|
||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary schools across England</p>}
|
||
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
|
||
<div className={styles.quickSearches}>
|
||
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
||
{['Manchester', 'Bristol', 'Leeds', 'Birmingham'].map(city => (
|
||
<a key={city} href={`/?search=${city}`} className={styles.quickSearchChip}>{city}</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Location Info Banner with View Toggle */}
|
||
{isLocationSearch && initialSchools.location_info && (
|
||
<div className={styles.locationBannerWrapper}>
|
||
<div className={styles.locationBanner}>
|
||
<span>
|
||
Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '}
|
||
<strong>{initialSchools.location_info.postcode}</strong>
|
||
</span>
|
||
</div>
|
||
{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>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && resultsView === 'list' && (
|
||
<div className={styles.resultsHeader}>
|
||
<h2>
|
||
{initialSchools.total.toLocaleString()} school
|
||
{initialSchools.total !== 1 ? 's' : ''} found
|
||
</h2>
|
||
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
||
<option value="default">Sort: Relevance</option>
|
||
<option value="rwm_desc">Highest R, W & M %</option>
|
||
<option value="rwm_asc">Lowest R, W & M %</option>
|
||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||
<option value="name_asc">Name A–Z</option>
|
||
</select>
|
||
</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>}
|
||
{searchParams.get('postcode') && <span className={styles.filterChip}>Near {searchParams.get('postcode')} ({parseFloat(searchParams.get('radius') || '1')} mi)</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={initialSchools.schools}
|
||
center={initialSchools.location_info?.coordinates}
|
||
onMarkerClick={setSelectedMapSchool}
|
||
/>
|
||
</div>
|
||
<div className={styles.compactList}>
|
||
{initialSchools.schools.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)}
|
||
/>
|
||
</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)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* List View Layout */
|
||
<>
|
||
<div className={styles.schoolList}>
|
||
{sortedSchools.map((school) => (
|
||
<SchoolRow
|
||
key={school.urn}
|
||
school={school}
|
||
isLocationSearch={isLocationSearch}
|
||
onAddToCompare={addSchool}
|
||
onRemoveFromCompare={removeSchool}
|
||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{initialSchools.total_pages > 1 && (
|
||
<Pagination
|
||
currentPage={initialSchools.page}
|
||
totalPages={initialSchools.total_pages}
|
||
total={initialSchools.total}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* Compact School Item for Map View */
|
||
interface CompactSchoolItemProps {
|
||
school: School;
|
||
onAddToCompare: (school: School) => void;
|
||
isInCompare: boolean;
|
||
}
|
||
|
||
function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoolItemProps) {
|
||
return (
|
||
<div className={styles.compactItem}>
|
||
<div className={styles.compactItemContent}>
|
||
<div className={styles.compactItemHeader}>
|
||
<a href={`/school/${school.urn}`} className={styles.compactItemName}>
|
||
{school.school_name}
|
||
</a>
|
||
{school.distance !== undefined && school.distance !== null && (
|
||
<span className={styles.distanceBadge}>
|
||
{school.distance.toFixed(1)} mi
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className={styles.compactItemMeta}>
|
||
{school.school_type && <span>{school.school_type}</span>}
|
||
{school.local_authority && <span>{school.local_authority}</span>}
|
||
</div>
|
||
<div className={styles.compactItemStats}>
|
||
<span className={styles.compactStat}>
|
||
<strong>{school.rwm_expected_pct !== null ? `${school.rwm_expected_pct}%` : '-'}</strong> RWM
|
||
</span>
|
||
<span className={styles.compactStat}>
|
||
<strong>{school.total_pupils || '-'}</strong> pupils
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.compactItemActions}>
|
||
<button
|
||
className={`${styles.compactBtn} ${isInCompare ? styles.compactBtnActive : ''}`}
|
||
onClick={() => onAddToCompare(school)}
|
||
>
|
||
{isInCompare ? 'Remove' : 'Compare'}
|
||
</button>
|
||
<a href={`/school/${school.urn}`} className={styles.compactBtnSecondary}>
|
||
Details
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|