feat(ux): implement UX audit recommendations
- 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:
@@ -3,6 +3,7 @@ import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import './globals.css';
|
||||
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<main className="main">
|
||||
{children}
|
||||
</main>
|
||||
<ComparisonToast />
|
||||
<Footer />
|
||||
</ComparisonProvider>
|
||||
</body>
|
||||
|
||||
113
nextjs-app/components/ComparisonToast.module.css
Normal file
113
nextjs-app/components/ComparisonToast.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
45
nextjs-app/components/ComparisonToast.tsx
Normal file
45
nextjs-app/components/ComparisonToast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,166 +2,101 @@
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
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 {
|
||||
background: linear-gradient(180deg, var(--bg-secondary, #f3ede4) 0%, var(--bg-card, white) 50%);
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
.filterBar.isLoading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 6px;
|
||||
.omniBoxContainer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.omniInput {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-card, white);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
.omniInput:focus {
|
||||
border-color: var(--accent-coral, #e07256);
|
||||
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.15);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
.omniInput::placeholder {
|
||||
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 {
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
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);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
|
||||
}
|
||||
|
||||
.searchButton:active {
|
||||
transform: translateY(0);
|
||||
.searchButton:disabled {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 200px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card, white);
|
||||
@@ -175,8 +110,8 @@
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
@@ -186,37 +121,23 @@
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
.clearButton:hover:not(:disabled) {
|
||||
background: var(--border-color, #e5dfd5);
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 0.875rem;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filterBar.withHero {
|
||||
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 {
|
||||
.omniBoxContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
/**
|
||||
* FilterBar Component
|
||||
* Search and filter controls for schools
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useTransition } from 'react';
|
||||
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 styles from './FilterBar.module.css';
|
||||
|
||||
interface FilterBarProps {
|
||||
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 pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [searchMode, setSearchMode] = useState<'name' | 'location'>(
|
||||
searchParams.get('postcode') ? 'location' : 'name'
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
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 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 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');
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
}, [searchParams, pathname, router]);
|
||||
|
||||
// Debounced search handler
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce((value: string) => {
|
||||
updateURL({ search: value, postcode: '', radius: '' });
|
||||
}, 300),
|
||||
[updateURL]
|
||||
);
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!omniValue.trim()) {
|
||||
updateURL({ search: '', postcode: '', radius: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSearch(e.target.value);
|
||||
if (isValidPostcode(omniValue)) {
|
||||
// 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) => {
|
||||
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 = () => {
|
||||
router.push(pathname);
|
||||
setOmniValue('');
|
||||
startTransition(() => {
|
||||
router.push(pathname);
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode;
|
||||
|
||||
return (
|
||||
<div className={`${styles.filterBar} ${heroTitle ? styles.withHero : ''}`}>
|
||||
{heroTitle && (
|
||||
<div className={styles.heroSection}>
|
||||
<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}>
|
||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''}`}>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search schools by name..."
|
||||
defaultValue={currentSearch}
|
||||
onChange={handleSearchChange}
|
||||
className={styles.searchInput}
|
||||
value={omniValue}
|
||||
onChange={(e) => setOmniValue(e.target.value)}
|
||||
placeholder="Search by school name or postcode (e.g., SW1A 1AA)..."
|
||||
className={styles.omniInput}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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 type="submit" className={styles.searchButton} disabled={isPending}>
|
||||
{isPending ? <div className={styles.spinner}></div> : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{filters.local_authorities.map((la) => (
|
||||
@@ -181,6 +107,7 @@ export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroD
|
||||
value={currentType}
|
||||
onChange={(e) => handleFilterChange('school_type', e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{filters.school_types.map((type) => (
|
||||
@@ -191,7 +118,7 @@ export function FilterBar({ filters, showLocationSearch = true, heroTitle, heroD
|
||||
</select>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={handleClearFilters} className={styles.clearButton}>
|
||||
<button onClick={handleClearFilters} className={styles.clearButton} type="button" disabled={isPending}>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -419,3 +419,92 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { SchoolCard } from './SchoolCard';
|
||||
@@ -25,18 +25,26 @@ 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');
|
||||
|
||||
// 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}
|
||||
showLocationSearch
|
||||
heroTitle="Compare Primary School Performance"
|
||||
heroDescription="Search and compare KS2 results for thousands of schools across England"
|
||||
/>
|
||||
|
||||
{/* Location Info Banner with View Toggle */}
|
||||
@@ -110,7 +118,7 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
|
||||
action={
|
||||
hasSearch
|
||||
? {
|
||||
label: 'Clear Filters',
|
||||
label: 'Clear all filters and show featured schools',
|
||||
onClick: () => {
|
||||
window.location.href = '/';
|
||||
},
|
||||
@@ -125,18 +133,37 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
|
||||
<SchoolMap
|
||||
schools={initialSchools.schools}
|
||||
center={initialSchools.location_info?.coordinates}
|
||||
onMarkerClick={setSelectedMapSchool}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.compactList}>
|
||||
{initialSchools.schools.map((school) => (
|
||||
<CompactSchoolItem
|
||||
key={school.urn}
|
||||
school={school}
|
||||
onAddToCompare={addSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
/>
|
||||
<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 */
|
||||
@@ -147,6 +174,7 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
|
||||
key={school.urn}
|
||||
school={school}
|
||||
onAddToCompare={addSchool}
|
||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -182,7 +210,7 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
|
||||
</a>
|
||||
{school.distance !== undefined && school.distance !== null && (
|
||||
<span className={styles.distanceBadge}>
|
||||
{school.distance.toFixed(1)} mi
|
||||
{(school.distance / 1.60934).toFixed(1)} mi
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
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 {
|
||||
border-left-color: var(--accent-coral, #e07256);
|
||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
||||
@@ -172,16 +177,23 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
.btnPrimary:hover:not(:disabled) {
|
||||
background: var(--accent-coral-dark, #c45a3f);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(224, 114, 86, 0.25);
|
||||
}
|
||||
|
||||
.btnPrimary:active {
|
||||
.btnPrimary:active:not(:disabled) {
|
||||
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) {
|
||||
.card {
|
||||
padding: 0.875rem;
|
||||
|
||||
@@ -13,14 +13,15 @@ interface SchoolCardProps {
|
||||
onAddToCompare?: (school: School) => void;
|
||||
showDistance?: boolean;
|
||||
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 trendColor = getTrendColor(trend);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={`${styles.card} ${isInCompare ? styles.cardInCompare : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>
|
||||
<Link href={`/school/${school.urn}`}>
|
||||
@@ -29,7 +30,7 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
|
||||
</h3>
|
||||
{showDistance && distance !== undefined && (
|
||||
<span className={styles.distance}>
|
||||
{distance.toFixed(1)} km away
|
||||
{(distance / 1.60934).toFixed(1)} miles away
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -50,13 +51,16 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
|
||||
<div className={styles.metrics}>
|
||||
{school.rwm_expected_pct !== null && (
|
||||
<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}>
|
||||
<strong>{formatPercentage(school.rwm_expected_pct)}</strong>
|
||||
{school.prev_rwm_expected_pct !== null && (
|
||||
<span
|
||||
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' && (
|
||||
<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 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{school.writing_progress !== null && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{school.maths_progress !== null && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,9 +128,10 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
|
||||
{onAddToCompare && (
|
||||
<button
|
||||
onClick={() => onAddToCompare(school)}
|
||||
className={styles.btnPrimary}
|
||||
className={`${styles.btnPrimary} ${isInCompare ? styles.btnAdded : ''}`}
|
||||
disabled={isInCompare}
|
||||
>
|
||||
Add to Compare
|
||||
{isInCompare ? 'Added ✓' : 'Add to Compare'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user