feat(ux): implement UX audit recommendations
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m10s
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

- Redesign landing page with unified Omnibox search

- Add ComparisonToast for better comparison flow visibility

- Add visual 'Added' state to SchoolCard

- Add info tooltips to educational metrics

- Optimize mobile map view with Bottom Sheet

- Standardize distance display to miles
This commit is contained in:
Tudor
2026-03-05 09:33:47 +00:00
parent 6a95445f5e
commit ad7380dba5
9 changed files with 430 additions and 279 deletions

View File

@@ -3,6 +3,7 @@ import { DM_Sans, Playfair_Display } from 'next/font/google';
import Script from 'next/script'; import Script from 'next/script';
import { Navigation } from '@/components/Navigation'; import { Navigation } from '@/components/Navigation';
import { Footer } from '@/components/Footer'; import { Footer } from '@/components/Footer';
import { ComparisonToast } from '@/components/ComparisonToast';
import { ComparisonProvider } from '@/context/ComparisonProvider'; import { ComparisonProvider } from '@/context/ComparisonProvider';
import './globals.css'; import './globals.css';
@@ -70,6 +71,7 @@ export default function RootLayout({
<main className="main"> <main className="main">
{children} {children}
</main> </main>
<ComparisonToast />
<Footer /> <Footer />
</ComparisonProvider> </ComparisonProvider>
</body> </body>

View File

@@ -0,0 +1,113 @@
.toastContainer {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes slideUp {
from {
transform: translate(-50%, 150%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
.toastContent {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
background: var(--bg-accent, #1a1612);
color: var(--text-inverse, #faf7f2);
border-radius: 50px;
box-shadow: 0 10px 30px rgba(26, 22, 18, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.toastInfo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toastBadge {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--accent-coral, #e07256);
color: white;
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
}
.toastText {
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
}
.toastActions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btnClear {
background: transparent;
border: none;
color: rgba(250, 247, 242, 0.7);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem;
transition: color 0.2s ease;
}
.btnClear:hover {
color: var(--text-inverse, #faf7f2);
}
.btnCompare {
background: white;
color: var(--bg-accent, #1a1612);
padding: 0.6rem 1.25rem;
border-radius: 25px;
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: transform 0.2s ease, background-color 0.2s ease;
white-space: nowrap;
}
.btnCompare:hover {
transform: translateY(-1px);
background: var(--bg-secondary, #f3ede4);
}
@media (max-width: 640px) {
.toastContainer {
bottom: 1.5rem;
width: calc(100% - 3rem);
}
.toastContent {
flex-direction: column;
gap: 1rem;
border-radius: 16px;
padding: 1.25rem;
}
.toastActions {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useComparison } from '@/hooks/useComparison';
import { usePathname } from 'next/navigation';
import styles from './ComparisonToast.module.css';
export function ComparisonToast() {
const { selectedSchools, clearAll } = useComparison();
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
// Don't show toast on the compare page itself
if (pathname === '/compare') return null;
if (selectedSchools.length === 0) return null;
return (
<div className={styles.toastContainer}>
<div className={styles.toastContent}>
<div className={styles.toastInfo}>
<span className={styles.toastBadge}>{selectedSchools.length}</span>
<span className={styles.toastText}>
{selectedSchools.length === 1 ? 'school' : 'schools'} selected
</span>
</div>
<div className={styles.toastActions}>
<button onClick={clearAll} className={styles.btnClear}>
Clear
</button>
<Link href="/compare" className={styles.btnCompare}>
Compare Now
</Link>
</div>
</div>
</div>
);
}

View File

@@ -2,166 +2,101 @@
background: var(--bg-card, white); background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5); border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px; border-radius: 10px;
padding: 1rem 1.25rem; padding: 1.5rem;
margin-bottom: 1.25rem; margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06)); box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
transition: opacity 0.2s ease;
max-width: 800px;
margin-left: auto;
margin-right: auto;
} }
.filterBar.withHero { .filterBar.isLoading {
background: linear-gradient(180deg, var(--bg-secondary, #f3ede4) 0%, var(--bg-card, white) 50%); opacity: 0.7;
padding: 1.25rem 1.25rem 1rem; pointer-events: none;
}
.heroSection {
text-align: center;
margin-bottom: 0.875rem;
padding-bottom: 0.875rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
}
.heroTitle {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin: 0 0 0.25rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.heroDescription {
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
margin: 0;
line-height: 1.4;
}
.searchModeToggle {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
background: var(--bg-secondary, #f3ede4);
padding: 0.2rem;
border-radius: 6px;
}
.searchModeToggle button {
flex: 1;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
color: var(--text-secondary, #5c564d);
transition: all 0.2s ease;
}
.searchModeToggle button.active {
background: var(--bg-card, white);
color: var(--accent-coral, #e07256);
box-shadow: 0 2px 4px rgba(26, 22, 18, 0.08);
} }
.searchSection { .searchSection {
margin-bottom: 0.75rem; margin-bottom: 1rem;
} }
.searchInput { .omniBoxContainer {
width: 100%; display: flex;
padding: 0.625rem 0.875rem; gap: 0.5rem;
font-size: 0.9375rem; }
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px; .omniInput {
flex: 1;
padding: 0.875rem 1.25rem;
font-size: 1.05rem;
border: 2px solid var(--border-color, #e5dfd5);
border-radius: 8px;
outline: none; outline: none;
transition: all 0.2s ease; transition: all 0.2s ease;
background: var(--bg-card, white); background: var(--bg-card, white);
font-family: inherit;
} }
.searchInput:focus { .omniInput:focus {
border-color: var(--accent-coral, #e07256); border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.15); box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.15);
} }
.searchInput::placeholder { .omniInput::placeholder {
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
} }
.locationForm {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.postcodeInput {
flex: 1;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
background: var(--bg-card, white);
}
.postcodeInput:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 2px rgba(224, 114, 86, 0.15);
}
.postcodeInput::placeholder {
color: var(--text-muted, #8a847a);
}
.radiusSelect {
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px;
background: var(--bg-card, white);
cursor: pointer;
outline: none;
color: var(--text-primary, #1a1612);
}
.radiusSelect:focus {
border-color: var(--accent-coral, #e07256);
}
.searchButton { .searchButton {
padding: 0.625rem 1.25rem; padding: 0.875rem 2rem;
font-size: 0.9375rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
background: var(--accent-coral, #e07256); background: var(--accent-coral, #e07256);
color: white; color: white;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 120px;
} }
.searchButton:hover { .searchButton:hover:not(:disabled) {
background: var(--accent-coral-dark, #c45a3f); background: var(--accent-coral-dark, #c45a3f);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3); box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
} }
.searchButton:active { .searchButton:disabled {
transform: translateY(0); opacity: 0.8;
cursor: not-allowed;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
} }
.filters { .filters {
display: flex; display: flex;
gap: 0.5rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filterSelect { .filterSelect {
flex: 1; flex: 1;
min-width: 180px; min-width: 200px;
padding: 0.5rem 0.75rem; padding: 0.75rem 1rem;
font-size: 0.8125rem; font-size: 0.95rem;
border: 1px solid var(--border-color, #e5dfd5); border: 1px solid var(--border-color, #e5dfd5);
border-radius: 6px; border-radius: 6px;
background: var(--bg-card, white); background: var(--bg-card, white);
@@ -175,8 +110,8 @@
} }
.clearButton { .clearButton {
padding: 0.5rem 0.75rem; padding: 0.75rem 1.25rem;
font-size: 0.8125rem; font-size: 0.95rem;
font-weight: 500; font-weight: 500;
background: var(--bg-secondary, #f3ede4); background: var(--bg-secondary, #f3ede4);
color: var(--text-secondary, #5c564d); color: var(--text-secondary, #5c564d);
@@ -186,38 +121,24 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.clearButton:hover { .clearButton:hover:not(:disabled) {
background: var(--border-color, #e5dfd5); background: var(--border-color, #e5dfd5);
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.filterBar { .filterBar {
padding: 0.875rem; padding: 1rem;
border-radius: 8px;
} }
.filterBar.withHero { .omniBoxContainer {
padding: 1rem 0.875rem 0.875rem;
}
.heroSection {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
}
.heroTitle {
font-size: 1.25rem;
}
.heroDescription {
font-size: 0.8125rem;
}
.locationForm {
flex-direction: column; flex-direction: column;
} }
.searchButton {
width: 100%;
}
.filters { .filters {
flex-direction: column; flex-direction: column;
} }

View File

@@ -1,39 +1,29 @@
/**
* FilterBar Component
* Search and filter controls for schools
*/
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useTransition } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { debounce, isValidPostcode } from '@/lib/utils'; import { isValidPostcode } from '@/lib/utils';
import type { Filters } from '@/lib/types'; import type { Filters } from '@/lib/types';
import styles from './FilterBar.module.css'; import styles from './FilterBar.module.css';
interface FilterBarProps { interface FilterBarProps {
filters: Filters; filters: Filters;
showLocationSearch?: boolean;
heroTitle?: string;
heroDescription?: string;
} }
export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroDescription }: FilterBarProps) { export function FilterBar({ filters }: FilterBarProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [searchMode, setSearchMode] = useState<'name' | 'location'>(
searchParams.get('postcode') ? 'location' : 'name'
);
const currentSearch = searchParams.get('search') || ''; const currentSearch = searchParams.get('search') || '';
const currentPostcode = searchParams.get('postcode') || '';
const initialOmniValue = currentPostcode || currentSearch;
const [omniValue, setOmniValue] = useState(initialOmniValue);
const currentLA = searchParams.get('local_authority') || ''; const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || ''; const currentType = searchParams.get('school_type') || '';
const currentPostcode = searchParams.get('postcode') || '';
const currentRadiusKm = searchParams.get('radius') || '0.8';
// Convert km back to miles for display
const currentRadiusMiles = (parseFloat(currentRadiusKm) / 1.60934).toFixed(1);
const updateURL = useCallback((updates: Record<string, string>) => { const updateURL = useCallback((updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
@@ -46,128 +36,64 @@ export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroD
} }
}); });
// Reset to page 1 when filters change
params.delete('page'); params.delete('page');
startTransition(() => {
router.push(`${pathname}?${params.toString()}`); router.push(`${pathname}?${params.toString()}`);
});
}, [searchParams, pathname, router]); }, [searchParams, pathname, router]);
// Debounced search handler const handleSearchSubmit = (e: React.FormEvent) => {
const debouncedSearch = useMemo( e.preventDefault();
() => debounce((value: string) => { if (!omniValue.trim()) {
updateURL({ search: value, postcode: '', radius: '' }); updateURL({ search: '', postcode: '', radius: '' });
}, 300), return;
[updateURL] }
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (isValidPostcode(omniValue)) {
debouncedSearch(e.target.value); // Default to 1 mile radius (approx 1.6 km)
updateURL({ postcode: omniValue.trim().toUpperCase(), radius: '1.6', search: '' });
} else {
updateURL({ search: omniValue.trim(), postcode: '', radius: '' });
}
}; };
const handleFilterChange = (key: string, value: string) => { const handleFilterChange = (key: string, value: string) => {
updateURL({ [key]: value }); updateURL({ [key]: value });
}; };
const handleSearchModeToggle = (mode: 'name' | 'location') => {
setSearchMode(mode);
if (mode === 'name') {
updateURL({ postcode: '', radius: '' });
} else {
updateURL({ search: '' });
}
};
const handleLocationSearch = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const postcode = formData.get('postcode') as string;
const radiusMiles = formData.get('radius') as string;
if (!postcode.trim()) {
alert('Please enter a postcode');
return;
}
if (!isValidPostcode(postcode)) {
alert('Please enter a valid UK postcode');
return;
}
// Convert miles to km for API (backend expects km)
const radiusKm = (parseFloat(radiusMiles) * 1.60934).toFixed(1);
updateURL({ postcode, radius: radiusKm, search: '' });
};
const handleClearFilters = () => { const handleClearFilters = () => {
setOmniValue('');
startTransition(() => {
router.push(pathname); router.push(pathname);
});
}; };
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode; const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode;
return ( return (
<div className={`${styles.filterBar} ${heroTitle ? styles.withHero : ''}`}> <div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''}`}>
{heroTitle && ( <form onSubmit={handleSearchSubmit} className={styles.searchSection}>
<div className={styles.heroSection}> <div className={styles.omniBoxContainer}>
<h1 className={styles.heroTitle}>{heroTitle}</h1>
{heroDescription && <p className={styles.heroDescription}>{heroDescription}</p>}
</div>
)}
{showLocationSearch && (
<div className={styles.searchModeToggle}>
<button
className={searchMode === 'name' ? styles.active : ''}
onClick={() => handleSearchModeToggle('name')}
>
Search by Name
</button>
<button
className={searchMode === 'location' ? styles.active : ''}
onClick={() => handleSearchModeToggle('location')}
>
Search by Location
</button>
</div>
)}
{searchMode === 'name' ? (
<div className={styles.searchSection}>
<input <input
type="search" type="search"
name="search" value={omniValue}
placeholder="Search schools by name..." onChange={(e) => setOmniValue(e.target.value)}
defaultValue={currentSearch} placeholder="Search by school name or postcode (e.g., SW1A 1AA)..."
onChange={handleSearchChange} className={styles.omniInput}
className={styles.searchInput}
/> />
</div> <button type="submit" className={styles.searchButton} disabled={isPending}>
) : ( {isPending ? <div className={styles.spinner}></div> : 'Search'}
<form onSubmit={handleLocationSearch} className={styles.locationForm}>
<input
type="text"
name="postcode"
placeholder="Enter postcode (e.g., SW1A 1AA)"
defaultValue={currentPostcode}
className={styles.postcodeInput}
required
/>
<select name="radius" defaultValue={currentRadiusMiles} className={styles.radiusSelect}>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
</select>
<button type="submit" className={styles.searchButton}>
Search
</button> </button>
</div>
</form> </form>
)}
<div className={styles.filters}> <div className={styles.filters}>
<select <select
value={currentLA} value={currentLA}
onChange={(e) => handleFilterChange('local_authority', e.target.value)} onChange={(e) => handleFilterChange('local_authority', e.target.value)}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending}
> >
<option value="">All Local Authorities</option> <option value="">All Local Authorities</option>
{filters.local_authorities.map((la) => ( {filters.local_authorities.map((la) => (
@@ -181,6 +107,7 @@ export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroD
value={currentType} value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)} onChange={(e) => handleFilterChange('school_type', e.target.value)}
className={styles.filterSelect} className={styles.filterSelect}
disabled={isPending}
> >
<option value="">All School Types</option> <option value="">All School Types</option>
{filters.school_types.map((type) => ( {filters.school_types.map((type) => (
@@ -191,7 +118,7 @@ export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroD
</select> </select>
{hasActiveFilters && ( {hasActiveFilters && (
<button onClick={handleClearFilters} className={styles.clearButton}> <button onClick={handleClearFilters} className={styles.clearButton} type="button" disabled={isPending}>
Clear Filters Clear Filters
</button> </button>
)} )}

View File

@@ -419,3 +419,92 @@
padding: 2rem 1.25rem; padding: 2rem 1.25rem;
} }
} }
/* Highlighted List Item */
.highlightedItem .compactItem {
border-color: var(--accent-teal, #2d7d7d);
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
background: var(--bg-secondary, #f3ede4);
}
/* Mobile Bottom Sheet */
.bottomSheetWrapper {
display: none;
}
@media (max-width: 768px) {
.bottomSheetWrapper {
display: block;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1000;
padding: 1rem;
pointer-events: none;
}
.bottomSheet {
position: relative;
background: var(--bg-card, white);
border-radius: 12px;
box-shadow: 0 -4px 24px rgba(26, 22, 18, 0.15);
pointer-events: auto;
animation: slideUpSheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.bottomSheet .compactItem {
border: none;
box-shadow: none;
background: transparent;
padding: 1rem;
}
.bottomSheet .compactItem:hover {
box-shadow: none;
}
.closeSheetBtn {
position: absolute;
top: -12px;
right: -12px;
width: 30px;
height: 30px;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: var(--text-secondary, #5c564d);
cursor: pointer;
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.1);
z-index: 10;
}
@keyframes slideUpSheet {
from {
transform: translateY(120%);
}
to {
transform: translateY(0);
}
}
/* When map view on mobile, expand map and hide list */
.mapViewContainer {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: calc(100vh - 280px);
min-height: 400px;
}
.mapContainer {
height: 100%;
}
.compactList {
display: none;
}
}

View File

@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { FilterBar } from './FilterBar'; import { FilterBar } from './FilterBar';
import { SchoolCard } from './SchoolCard'; import { SchoolCard } from './SchoolCard';
@@ -25,18 +25,26 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { addSchool, selectedSchools } = useComparisonContext(); const { addSchool, selectedSchools } = useComparisonContext();
const [resultsView, setResultsView] = useState<'list' | 'map'>('list'); const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
const hasSearch = searchParams.get('search') || searchParams.get('postcode'); const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode'); const isLocationSearch = !!searchParams.get('postcode');
// Close bottom sheet if we change views or search
useEffect(() => {
setSelectedMapSchool(null);
}, [resultsView, searchParams]);
return ( return (
<div className={styles.homeView}> <div className={styles.homeView}>
{/* Combined Hero + Search and Filters */} {/* 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 <FilterBar
filters={filters} filters={filters}
showLocationSearch
heroTitle="Compare Primary School Performance"
heroDescription="Search and compare KS2 results for thousands of schools across England"
/> />
{/* Location Info Banner with View Toggle */} {/* Location Info Banner with View Toggle */}
@@ -110,7 +118,7 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
action={ action={
hasSearch hasSearch
? { ? {
label: 'Clear Filters', label: 'Clear all filters and show featured schools',
onClick: () => { onClick: () => {
window.location.href = '/'; window.location.href = '/';
}, },
@@ -125,18 +133,37 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
<SchoolMap <SchoolMap
schools={initialSchools.schools} schools={initialSchools.schools}
center={initialSchools.location_info?.coordinates} center={initialSchools.location_info?.coordinates}
onMarkerClick={setSelectedMapSchool}
/> />
</div> </div>
<div className={styles.compactList}> <div className={styles.compactList}>
{initialSchools.schools.map((school) => ( {initialSchools.schools.map((school) => (
<CompactSchoolItem <div
key={school.urn} key={school.urn}
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
>
<CompactSchoolItem
school={school} school={school}
onAddToCompare={addSchool} onAddToCompare={addSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)} isInCompare={selectedSchools.some(s => s.urn === school.urn)}
/> />
</div>
))} ))}
</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> </div>
) : ( ) : (
/* List View Layout */ /* List View Layout */
@@ -147,6 +174,7 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
key={school.urn} key={school.urn}
school={school} school={school}
onAddToCompare={addSchool} onAddToCompare={addSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
/> />
))} ))}
</div> </div>
@@ -182,7 +210,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
</a> </a>
{school.distance !== undefined && school.distance !== null && ( {school.distance !== undefined && school.distance !== null && (
<span className={styles.distanceBadge}> <span className={styles.distanceBadge}>
{school.distance.toFixed(1)} mi {(school.distance / 1.60934).toFixed(1)} mi
</span> </span>
)} )}
</div> </div>

View File

@@ -7,6 +7,11 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.card.cardInCompare {
border-color: var(--accent-teal, #2d7d7d);
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
}
.card:hover { .card:hover {
border-left-color: var(--accent-coral, #e07256); border-left-color: var(--accent-coral, #e07256);
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1)); box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
@@ -172,16 +177,23 @@
color: white; color: white;
} }
.btnPrimary:hover { .btnPrimary:hover:not(:disabled) {
background: var(--accent-coral-dark, #c45a3f); background: var(--accent-coral-dark, #c45a3f);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(224, 114, 86, 0.25); box-shadow: 0 3px 8px rgba(224, 114, 86, 0.25);
} }
.btnPrimary:active { .btnPrimary:active:not(:disabled) {
transform: translateY(0); transform: translateY(0);
} }
.btnPrimary.btnAdded {
background: var(--bg-secondary, #f3ede4);
color: var(--text-secondary, #5c564d);
border: 1px solid var(--border-color, #e5dfd5);
cursor: default;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.card { .card {
padding: 0.875rem; padding: 0.875rem;

View File

@@ -13,14 +13,15 @@ interface SchoolCardProps {
onAddToCompare?: (school: School) => void; onAddToCompare?: (school: School) => void;
showDistance?: boolean; showDistance?: boolean;
distance?: number; distance?: number;
isInCompare?: boolean;
} }
export function SchoolCard({ school, onAddToCompare, showDistance, distance }: SchoolCardProps) { export function SchoolCard({ school, onAddToCompare, showDistance, distance, isInCompare = false }: SchoolCardProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct); const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const trendColor = getTrendColor(trend); const trendColor = getTrendColor(trend);
return ( return (
<div className={styles.card}> <div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
<div className={styles.header}> <div className={styles.header}>
<h3 className={styles.title}> <h3 className={styles.title}>
<Link href={`/school/${school.urn}`}> <Link href={`/school/${school.urn}`}>
@@ -29,7 +30,7 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
</h3> </h3>
{showDistance && distance !== undefined && ( {showDistance && distance !== undefined && (
<span className={styles.distance}> <span className={styles.distance}>
{distance.toFixed(1)} km away {(distance / 1.60934).toFixed(1)} miles away
</span> </span>
)} )}
</div> </div>
@@ -50,13 +51,16 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
<div className={styles.metrics}> <div className={styles.metrics}>
{school.rwm_expected_pct !== null && ( {school.rwm_expected_pct !== null && (
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricLabel}>RWM Expected</span> <span className={styles.metricLabel} title="Percentage of pupils achieving the expected standard in Reading, Writing, and Maths">
RWM Expected
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</span>
<div className={styles.metricValue}> <div className={styles.metricValue}>
<strong>{formatPercentage(school.rwm_expected_pct)}</strong> <strong>{formatPercentage(school.rwm_expected_pct)}</strong>
{school.prev_rwm_expected_pct !== null && ( {school.prev_rwm_expected_pct !== null && (
<span <span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`} className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous: ${formatPercentage(school.prev_rwm_expected_pct)}`} title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
> >
{trend === 'up' && ( {trend === 'up' && (
<svg viewBox="0 0 16 16" fill="none" className={styles.trendIcon}> <svg viewBox="0 0 16 16" fill="none" className={styles.trendIcon}>
@@ -87,21 +91,30 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
{school.reading_progress !== null && ( {school.reading_progress !== null && (
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricLabel}>Reading Progress</span> <span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Reading. >0 is above average.">
Reading
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</span>
<strong>{formatProgress(school.reading_progress)}</strong> <strong>{formatProgress(school.reading_progress)}</strong>
</div> </div>
)} )}
{school.writing_progress !== null && ( {school.writing_progress !== null && (
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricLabel}>Writing Progress</span> <span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Writing. >0 is above average.">
Writing
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</span>
<strong>{formatProgress(school.writing_progress)}</strong> <strong>{formatProgress(school.writing_progress)}</strong>
</div> </div>
)} )}
{school.maths_progress !== null && ( {school.maths_progress !== null && (
<div className={styles.metric}> <div className={styles.metric}>
<span className={styles.metricLabel}>Maths Progress</span> <span className={styles.metricLabel} title="Progress score from KS1 to KS2 in Maths. >0 is above average.">
Maths
<svg className="info-icon" style={{ marginLeft: '4px', width: '10px', height: '10px', verticalAlign: 'middle', color: 'var(--text-muted)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</span>
<strong>{formatProgress(school.maths_progress)}</strong> <strong>{formatProgress(school.maths_progress)}</strong>
</div> </div>
)} )}
@@ -115,9 +128,10 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
{onAddToCompare && ( {onAddToCompare && (
<button <button
onClick={() => onAddToCompare(school)} onClick={() => onAddToCompare(school)}
className={styles.btnPrimary} className={`${styles.btnPrimary} ${isInCompare ? styles.btnAdded : ''}`}
disabled={isInCompare}
> >
Add to Compare {isInCompare ? 'Added ✓' : 'Add to Compare'}
</button> </button>
)} )}
</div> </div>