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 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>
|
||||||
|
|||||||
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);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
startTransition(() => {
|
||||||
|
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 = () => {
|
||||||
router.push(pathname);
|
setOmniValue('');
|
||||||
|
startTransition(() => {
|
||||||
|
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>
|
||||||
</form>
|
</div>
|
||||||
)}
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
school={school}
|
className={`${styles.listItemWrapper} ${selectedMapSchool?.urn === school.urn ? styles.highlightedItem : ''}`}
|
||||||
onAddToCompare={addSchool}
|
>
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
<CompactSchoolItem
|
||||||
/>
|
school={school}
|
||||||
|
onAddToCompare={addSchool}
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user