All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 33s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m9s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Sort was local state, lost on navigation. Now reads from
searchParams.get('sort') and pushes to URL on change.
'default' removes the param to keep URLs clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
389 lines
17 KiB
TypeScript
389 lines
17 KiB
TypeScript
/**
|
||
* HomeView Component
|
||
* Client-side home page view with search and filtering
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect, useRef } 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 } from '@/lib/api';
|
||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||
import { schoolUrl } from '@/lib/utils';
|
||
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 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 [mapSchools, setMapSchools] = useState<School[]>([]);
|
||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||
const prevSearchParamsRef = useRef(searchParams.toString());
|
||
|
||
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 hasSecondaryResults = allSchools.some(s => s.attainment_8_score != null);
|
||
const isSecondaryView = currentPhase.toLowerCase().includes('secondary') || hasSecondaryResults;
|
||
|
||
// Reset pagination state when search params change
|
||
useEffect(() => {
|
||
const newParamsStr = searchParams.toString();
|
||
if (newParamsStr !== prevSearchParamsRef.current) {
|
||
prevSearchParamsRef.current = newParamsStr;
|
||
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
|
||
useEffect(() => {
|
||
if (resultsView !== 'map' || !isLocationSearch) return;
|
||
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 schools are visible
|
||
useEffect(() => {
|
||
if (!isSecondaryView) return;
|
||
fetchLAaverages({ cache: 'force-cache' })
|
||
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
|
||
.catch(() => {});
|
||
}, [isSecondaryView]);
|
||
|
||
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 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}>
|
||
<h1 className={styles.heroTitle}>Find Local Schools</h1>
|
||
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</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}>
|
||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary and secondary 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>
|
||
)}
|
||
|
||
{/* 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 && <option value="rwm_desc">Highest R, W & M %</option>}
|
||
{!isSecondaryView && <option value="rwm_asc">Lowest R, W & M %</option>}
|
||
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
|
||
{isSecondaryView && <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}
|
||
/>
|
||
</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)}
|
||
/>
|
||
</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) => (
|
||
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)}
|
||
/>
|
||
)
|
||
))}
|
||
</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;
|
||
}
|
||
|
||
function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoolItemProps) {
|
||
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>
|
||
<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.attainment_8_score != null
|
||
? school.attainment_8_score.toFixed(1)
|
||
: school.rwm_expected_pct !== null
|
||
? `${school.rwm_expected_pct}%`
|
||
: '-'}
|
||
</strong>{' '}
|
||
{school.attainment_8_score != null ? 'Att 8' : 'RWM'}
|
||
</span>
|
||
<span className={styles.compactStat}>
|
||
<strong>{school.total_pupils || '-'}</strong> pupils
|
||
</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>
|
||
);
|
||
}
|