Files
school_compare/nextjs-app/components/HomeView.tsx
Tudor d4abb56c22
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 42s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
feat(ui): redesign landing page search and empty states
- Hide empty state placeholder on initial load

- Add prominent hero mode to FilterBar when no search is active

- Fix SchoolCard test TypeScript and assertion errors
2026-03-05 13:00:34 +00:00

238 lines
8.8 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 { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { FilterBar } from './FilterBar';
import { SchoolCard } from './SchoolCard';
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;
}
export function HomeView({ initialSchools, filters }: HomeViewProps) {
const searchParams = useSearchParams();
const { addSchool, selectedSchools } = useComparisonContext();
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(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'));
// Close bottom sheet if we change views or search
useEffect(() => {
setSelectedMapSchool(null);
}, [resultsView, searchParams]);
return (
<div className={styles.homeView}>
{/* Combined Hero + Search and Filters */}
<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}
/>
{/* 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>
</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.grid}>
{initialSchools.schools.map((school) => (
<SchoolCard
key={school.urn}
school={school}
onAddToCompare={addSchool}
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 / 1.60934).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>
);
}