Complete Next.js migration with SSR and Docker deployment
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m26s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped

- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router
- Add server-side rendering for all pages (Home, Compare, Rankings)
- Create individual school pages with dynamic routing (/school/[urn])
- Implement Chart.js and Leaflet map integrations
- Add comprehensive SEO with sitemap, robots.txt, and JSON-LD
- Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js)
- Update CI/CD pipeline to build both backend and frontend images
- Fix Dockerfile to include devDependencies for TypeScript compilation
- Add Jest testing configuration
- Implement performance optimizations (code splitting, caching)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-02-02 20:34:35 +00:00
parent f4919db3b9
commit ff7f5487e6
72 changed files with 18636 additions and 20 deletions

View File

@@ -0,0 +1,176 @@
/**
* ComparisonChart Component
* Multi-school comparison chart using Chart.js
*/
'use client';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
ChartOptions,
} from 'chart.js';
import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS } from '@/lib/utils';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>;
metric: string;
metricLabel: string;
}
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// Get all schools and their data
const schools = Object.entries(comparisonData);
if (schools.length === 0) {
return <div>No data available</div>;
}
// Get years from first school (assuming all schools have same years)
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
// Create datasets for each school
const datasets = schools.map(([urn, data], index) => {
const schoolInfo = data.school_info;
const color = CHART_COLORS[index % CHART_COLORS.length];
return {
label: schoolInfo.school_name,
data: years.map((year) => {
const yearData = data.yearly_data.find((d) => d.year === year);
if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null;
}),
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
tension: 0.3,
spanGaps: true,
};
});
const chartData = {
labels: years.map(String),
datasets,
};
// Determine if metric is a progress score or percentage
const isProgressScore = metric.includes('progress');
const isPercentage = metric.includes('pct') || metric.includes('rate');
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
position: 'top' as const,
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
},
title: {
display: true,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
callbacks: {
label: function (context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
if (isProgressScore) {
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
} else {
label += 'N/A';
}
return label;
},
},
},
},
scales: {
y: {
type: 'linear' as const,
display: true,
title: {
display: true,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
font: {
size: 12,
weight: 'bold',
},
},
...(isPercentage && {
min: 0,
max: 100,
}),
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
title: {
display: true,
text: 'Year',
font: {
size: 12,
weight: 'bold',
},
},
},
},
};
return <Line data={chartData} options={options} />;
}

View File

@@ -0,0 +1,312 @@
.container {
width: 100%;
}
/* Header */
.header {
margin-bottom: 2rem;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
flex-wrap: wrap;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
}
.addButton {
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.addButton:hover {
background: var(--primary-dark);
}
/* Metric Selector */
.metricSelector {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: var(--shadow-sm);
}
.metricLabel {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.metricSelect {
flex: 1;
max-width: 400px;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border-medium);
border-radius: var(--radius-md);
background: white;
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition);
}
.metricSelect:hover {
border-color: var(--primary);
}
.metricSelect:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Schools Section */
.schoolsSection {
margin-bottom: 2rem;
}
.schoolsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.schoolCard {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 1.5rem;
position: relative;
box-shadow: var(--shadow-sm);
transition: all var(--transition);
}
.schoolCard:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.removeButton {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--danger);
color: white;
border: none;
border-radius: 50%;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: all var(--transition);
}
.removeButton:hover {
background: #dc2626;
transform: scale(1.1);
}
.schoolName {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.75rem;
padding-right: 2rem;
line-height: 1.3;
}
.schoolName a {
color: var(--text-primary);
text-decoration: none;
transition: color var(--transition);
}
.schoolName a:hover {
color: var(--primary);
}
.schoolMeta {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.metaItem {
font-size: 0.875rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.25rem;
}
.latestValue {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
text-align: center;
}
.latestLabel {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.latestNumber {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
}
/* Chart Section */
.chartSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.sectionTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-light);
}
.chartContainer {
width: 100%;
height: 400px;
position: relative;
}
/* Table Section */
.tableSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.tableWrapper {
overflow-x: auto;
margin-top: 1rem;
}
.comparisonTable {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.comparisonTable thead {
background: var(--bg-secondary);
}
.comparisonTable th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border-medium);
white-space: nowrap;
}
.comparisonTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-secondary);
text-align: left;
}
.comparisonTable tbody tr:last-child td {
border-bottom: none;
}
.comparisonTable tbody tr:hover {
background: var(--bg-secondary);
}
.yearCell {
font-weight: 600;
color: var(--text-primary);
}
/* Responsive Design */
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
align-items: stretch;
}
.addButton {
width: 100%;
}
.header h1 {
font-size: 1.5rem;
}
.metricSelector {
flex-direction: column;
align-items: stretch;
}
.metricSelect {
max-width: 100%;
}
.schoolsGrid {
grid-template-columns: 1fr;
}
.chartContainer {
height: 300px;
}
.comparisonTable {
font-size: 0.875rem;
}
.comparisonTable th,
.comparisonTable td {
padding: 0.75rem 0.5rem;
}
}

View File

@@ -0,0 +1,268 @@
/**
* ComparisonView Component
* Client-side comparison interface with charts and tables
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { ComparisonChart } from './ComparisonChart';
import { SchoolSearchModal } from './SchoolSearchModal';
import { EmptyState } from './EmptyState';
import type { ComparisonData, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import styles from './ComparisonView.module.css';
interface ComparisonViewProps {
initialData: Record<string, ComparisonData> | null;
initialUrns: number[];
metrics: MetricDefinition[];
selectedMetric: string;
}
export function ComparisonView({
initialData,
initialUrns,
metrics,
selectedMetric: initialMetric,
}: ComparisonViewProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool } = useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false);
const [comparisonData, setComparisonData] = useState(initialData);
// Sync URL with selected schools
useEffect(() => {
const urns = selectedSchools.map((s) => s.urn).join(',');
const params = new URLSearchParams(searchParams);
if (urns) {
params.set('urns', urns);
} else {
params.delete('urns');
}
params.set('metric', selectedMetric);
const newUrl = `${pathname}?${params.toString()}`;
router.replace(newUrl, { scroll: false });
// Fetch comparison data
if (selectedSchools.length > 0) {
fetch(`/api/compare?urns=${urns}`)
.then((res) => res.json())
.then((data) => setComparisonData(data.comparison))
.catch((err) => console.error('Failed to fetch comparison:', err));
} else {
setComparisonData(null);
}
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
const handleMetricChange = (metric: string) => {
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => {
removeSchool(urn);
};
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
// No schools selected
if (selectedSchools.length === 0) {
return (
<div className={styles.container}>
<header className={styles.header}>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
Add schools to your comparison basket to see side-by-side performance data
</p>
</header>
<EmptyState
title="No schools selected"
message="Add schools from the home page or search to start comparing."
action={{
label: 'Browse Schools',
onClick: () => router.push('/'),
}}
/>
</div>
);
}
// Get years for table
const years =
comparisonData && Object.keys(comparisonData).length > 0
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
: [];
return (
<div className={styles.container}>
{/* Header */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div>
<h1>Compare Schools</h1>
<p className={styles.subtitle}>
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
</p>
</div>
<button onClick={() => setIsModalOpen(true)} className={styles.addButton}>
+ Add School
</button>
</div>
</header>
{/* Metric Selector */}
<section className={styles.metricSelector}>
<label htmlFor="metric-select" className={styles.metricLabel}>
Select Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.metricSelect}
>
{metrics.map((metric) => (
<option key={metric.key} value={metric.key}>
{metric.label}
</option>
))}
</select>
</section>
{/* School Cards */}
<section className={styles.schoolsSection}>
<div className={styles.schoolsGrid}>
{selectedSchools.map((school) => (
<div key={school.urn} className={styles.schoolCard}>
<button
onClick={() => handleRemoveSchool(school.urn)}
className={styles.removeButton}
aria-label="Remove school"
>
×
</button>
<h3 className={styles.schoolName}>
<a href={`/school/${school.urn}`}>{school.school_name}</a>
</h3>
<div className={styles.schoolMeta}>
{school.local_authority && (
<span className={styles.metaItem}>📍 {school.local_authority}</span>
)}
{school.school_type && (
<span className={styles.metaItem}>🏫 {school.school_type}</span>
)}
</div>
{/* Latest metric value */}
{comparisonData && comparisonData[school.urn] && (
<div className={styles.latestValue}>
<div className={styles.latestLabel}>{metricLabel}</div>
<div className={styles.latestNumber}>
{(() => {
const yearlyData = comparisonData[school.urn].yearly_data;
if (yearlyData.length === 0) return '-';
const latestData = yearlyData[yearlyData.length - 1];
const value = latestData[selectedMetric as keyof typeof latestData];
if (value === null || value === undefined) return '-';
// Format based on metric type
if (selectedMetric.includes('progress')) {
return formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
return formatPercentage(value as number);
} else {
return typeof value === 'number' ? value.toFixed(1) : String(value);
}
})()}
</div>
</div>
)}
</div>
))}
</div>
</section>
{/* Comparison Chart */}
{comparisonData && Object.keys(comparisonData).length > 0 && (
<section className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<ComparisonChart
comparisonData={comparisonData}
metric={selectedMetric}
metricLabel={metricLabel}
/>
</div>
</section>
)}
{/* Comparison Table */}
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
<section className={styles.tableSection}>
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
<div className={styles.tableWrapper}>
<table className={styles.comparisonTable}>
<thead>
<tr>
<th>Year</th>
{selectedSchools.map((school) => (
<th key={school.urn}>{school.school_name}</th>
))}
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className={styles.yearCell}>{year}</td>
{selectedSchools.map((school) => {
const schoolData = comparisonData[school.urn];
if (!schoolData) return <td key={school.urn}>-</td>;
const yearData = schoolData.yearly_data.find((d) => d.year === year);
if (!yearData) return <td key={school.urn}>-</td>;
const value = yearData[selectedMetric as keyof typeof yearData];
if (value === null || value === undefined) {
return <td key={school.urn}>-</td>;
}
// Format based on metric type
let displayValue: string;
if (selectedMetric.includes('progress')) {
displayValue = formatProgress(value as number);
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
displayValue = formatPercentage(value as number);
} else {
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
}
return <td key={school.urn}>{displayValue}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* School Search Modal */}
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,47 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: white;
border: 2px dashed #e5e7eb;
border-radius: 12px;
min-height: 400px;
}
.icon {
color: #d1d5db;
margin-bottom: 1.5rem;
}
.title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.message {
margin: 0 0 1.5rem 0;
font-size: 1rem;
color: #6b7280;
max-width: 500px;
}
.button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.button:hover {
background: #2563eb;
}

View File

@@ -0,0 +1,44 @@
/**
* EmptyState Component
* Display message when no results found
*/
import styles from './EmptyState.module.css';
interface EmptyStateProps {
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
export function EmptyState({ title, message, action }: EmptyStateProps) {
return (
<div className={styles.emptyState}>
<div className={styles.icon}>
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
<h3 className={styles.title}>{title}</h3>
<p className={styles.message}>{message}</p>
{action && (
<button onClick={action.onClick} className={styles.button}>
{action.label}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
.filterBar {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.searchModeToggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
background: #f3f4f6;
padding: 0.25rem;
border-radius: 6px;
}
.searchModeToggle button {
flex: 1;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
}
.searchModeToggle button.active {
background: white;
color: #1f2937;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.searchSection {
margin-bottom: 1rem;
}
.searchInput {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
}
.searchInput:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.locationForm {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.postcodeInput {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
}
.postcodeInput:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.radiusSelect {
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
cursor: pointer;
outline: none;
}
.searchButton {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.searchButton:hover {
background: #2563eb;
}
.filters {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterSelect {
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
cursor: pointer;
outline: none;
}
.filterSelect:focus {
border-color: #3b82f6;
}
.clearButton {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.clearButton:hover {
background: #e5e7eb;
}
@media (max-width: 768px) {
.filterBar {
padding: 1rem;
}
.locationForm {
flex-direction: column;
}
.filters {
flex-direction: column;
}
.filterSelect {
min-width: 100%;
}
}

View File

@@ -0,0 +1,189 @@
/**
* FilterBar Component
* Search and filter controls for schools
*/
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { debounce, isValidPostcode } from '@/lib/utils';
import type { Filters } from '@/lib/types';
import styles from './FilterBar.module.css';
interface FilterBarProps {
filters: Filters;
showLocationSearch?: boolean;
}
export function FilterBar({ filters, showLocationSearch = true }: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [searchMode, setSearchMode] = useState<'name' | 'location'>(
searchParams.get('postcode') ? 'location' : 'name'
);
const currentSearch = searchParams.get('search') || '';
const currentLA = searchParams.get('local_authority') || '';
const currentType = searchParams.get('school_type') || '';
const currentPostcode = searchParams.get('postcode') || '';
const currentRadius = searchParams.get('radius') || '5';
const updateURL = useCallback((updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value && value !== '') {
params.set(key, value);
} else {
params.delete(key);
}
});
// Reset to page 1 when filters change
params.delete('page');
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 handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
debouncedSearch(e.target.value);
};
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 radius = 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;
}
updateURL({ postcode, radius, search: '' });
};
const handleClearFilters = () => {
router.push(pathname);
};
const hasActiveFilters = currentSearch || currentLA || currentType || currentPostcode;
return (
<div className={styles.filterBar}>
{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
type="search"
name="search"
placeholder="Search schools by name..."
defaultValue={currentSearch}
onChange={handleSearchChange}
className={styles.searchInput}
/>
</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={currentRadius} className={styles.radiusSelect}>
<option value="1">1 km</option>
<option value="2">2 km</option>
<option value="5">5 km</option>
<option value="10">10 km</option>
<option value="20">20 km</option>
</select>
<button type="submit" className={styles.searchButton}>
Search
</button>
</form>
)}
<div className={styles.filters}>
<select
value={currentLA}
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
className={styles.filterSelect}
>
<option value="">All Local Authorities</option>
{filters.local_authorities.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
<select
value={currentType}
onChange={(e) => handleFilterChange('school_type', e.target.value)}
className={styles.filterSelect}
>
<option value="">All School Types</option>
{filters.school_types.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
{hasActiveFilters && (
<button onClick={handleClearFilters} className={styles.clearButton}>
Clear Filters
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
.footer {
background: #1f2937;
color: #d1d5db;
margin-top: auto;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 3rem 1.5rem 2rem;
}
.content {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
}
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: white;
}
.description {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: #9ca3af;
}
.sectionTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.link {
font-size: 0.875rem;
color: #9ca3af;
text-decoration: none;
transition: color 0.2s ease;
}
.link:hover {
color: white;
}
.linkDisabled {
font-size: 0.875rem;
color: #6b7280;
cursor: not-allowed;
}
.bottom {
padding-top: 2rem;
border-top: 1px solid #374151;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.copyright,
.disclaimer {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
}
.disclaimer .link {
color: #60a5fa;
}
.disclaimer .link:hover {
color: #93c5fd;
}
@media (max-width: 768px) {
.container {
padding: 2rem 1rem 1.5rem;
}
.content {
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.bottom {
text-align: center;
}
}

View File

@@ -0,0 +1,91 @@
/**
* Footer Component
* Site footer with links and info
*/
import styles from './Footer.module.css';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.section}>
<h3 className={styles.title}>SchoolCompare</h3>
<p className={styles.description}>
Compare primary school KS2 performance across England. Data sourced from UK Government
Compare School Performance.
</p>
</div>
<div className={styles.section}>
<h4 className={styles.sectionTitle}>About</h4>
<ul className={styles.links}>
<li>
<a
href="https://www.compare-school-performance.service.gov.uk/"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Data Source
</a>
</li>
<li>
<span className={styles.linkDisabled}>Privacy Policy</span>
</li>
<li>
<span className={styles.linkDisabled}>Terms of Use</span>
</li>
</ul>
</div>
<div className={styles.section}>
<h4 className={styles.sectionTitle}>Resources</h4>
<ul className={styles.links}>
<li>
<a
href="https://www.gov.uk/government/organisations/department-for-education"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Department for Education
</a>
</li>
<li>
<a
href="https://www.gov.uk/school-performance-tables"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
School Performance Tables
</a>
</li>
</ul>
</div>
</div>
<div className={styles.bottom}>
<p className={styles.copyright}>
© {currentYear} SchoolCompare. Data © Crown copyright.
</p>
<p className={styles.disclaimer}>
This is an unofficial service. Official school performance data is available at{' '}
<a
href="https://www.compare-school-performance.service.gov.uk/"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
compare-school-performance.service.gov.uk
</a>
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,103 @@
.homeView {
width: 100%;
}
.hero {
text-align: center;
margin-bottom: 3rem;
padding: 2rem 0;
}
.heroTitle {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
line-height: 1.2;
}
.heroDescription {
font-size: 1.125rem;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
.locationBanner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
margin-bottom: 2rem;
font-size: 0.9375rem;
color: #1e40af;
}
.locationIcon {
font-size: 1.25rem;
}
.results {
margin-top: 2rem;
}
.sectionHeader {
margin-bottom: 2rem;
}
.sectionHeader h2 {
font-size: 1.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.sectionDescription {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
}
.resultsHeader {
margin-bottom: 2rem;
}
.resultsHeader h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 768px) {
.hero {
padding: 1rem 0;
margin-bottom: 2rem;
}
.heroTitle {
font-size: 2rem;
}
.heroDescription {
font-size: 1rem;
}
.grid {
grid-template-columns: 1fr;
}
.locationBanner {
padding: 0.875rem 1rem;
font-size: 0.875rem;
}
}

View File

@@ -0,0 +1,110 @@
/**
* HomeView Component
* Client-side home page view with search and filtering
*/
'use client';
import { useSearchParams } from 'next/navigation';
import { FilterBar } from './FilterBar';
import { SchoolCard } from './SchoolCard';
import { Pagination } from './Pagination';
import { EmptyState } from './EmptyState';
import { useComparisonContext } from '@/context/ComparisonContext';
import type { SchoolsResponse, Filters } from '@/lib/types';
import styles from './HomeView.module.css';
interface HomeViewProps {
initialSchools: SchoolsResponse;
filters: Filters;
}
export function HomeView({ initialSchools, filters }: HomeViewProps) {
const searchParams = useSearchParams();
const { addSchool } = useComparisonContext();
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
return (
<div className={styles.homeView}>
{/* Hero Section */}
<section className={styles.hero}>
<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>
</section>
{/* Search and Filters */}
<FilterBar filters={filters} showLocationSearch />
{/* Location Info Banner */}
{isLocationSearch && initialSchools.location_info && (
<div className={styles.locationBanner}>
<span className={styles.locationIcon}>📍</span>
<span>
Showing schools within {initialSchools.location_info.radius}km of{' '}
<strong>{initialSchools.location_info.postcode}</strong>
</span>
</div>
)}
{/* Results Section */}
<section className={styles.results}>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
<h2>Featured Schools</h2>
<p className={styles.sectionDescription}>
Explore schools from across England
</p>
</div>
)}
{hasSearch && (
<div className={styles.resultsHeader}>
<h2>
{initialSchools.total.toLocaleString()} school
{initialSchools.total !== 1 ? 's' : ''} found
</h2>
</div>
)}
{initialSchools.schools.length === 0 ? (
<EmptyState
title="No schools found"
message="Try adjusting your search criteria or filters to find schools."
action={{
label: 'Clear Filters',
onClick: () => {
window.location.href = '/';
},
}}
/>
) : (
<>
<div className={styles.grid}>
{initialSchools.schools.map((school) => (
<SchoolCard
key={school.urn}
school={school}
onAddToCompare={addSchool}
/>
))}
</div>
{initialSchools.total_pages > 1 && (
<Pagination
currentPage={initialSchools.page}
totalPages={initialSchools.total_pages}
total={initialSchools.total}
/>
)}
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,104 @@
/**
* LeafletMapInner Component
* Internal Leaflet map implementation (client-side only)
*/
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { School } from '@/lib/types';
// Fix for default marker icons in Next.js
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
interface LeafletMapInnerProps {
schools: School[];
center: [number, number];
zoom: number;
onMarkerClick?: (school: School) => void;
}
export default function LeafletMapInner({ schools, center, zoom, onMarkerClick }: LeafletMapInnerProps) {
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!mapContainerRef.current) return;
// Initialize map
if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(mapRef.current);
}
// Clear existing markers
mapRef.current.eachLayer((layer) => {
if (layer instanceof L.Marker) {
mapRef.current!.removeLayer(layer);
}
});
// Add markers for schools
schools.forEach((school) => {
if (school.latitude && school.longitude && mapRef.current) {
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
// Create popup content
const popupContent = `
<div style="min-width: 200px;">
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">📍 ${school.local_authority}</div>` : ''}
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">🏫 ${school.school_type}</div>` : ''}
<a href="/school/${school.urn}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #3b82f6; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a>
</div>
`;
marker.bindPopup(popupContent);
if (onMarkerClick) {
marker.on('click', () => onMarkerClick(school));
}
}
});
// Update map view
if (schools.length > 1) {
const bounds = L.latLngBounds(
schools
.filter(s => s.latitude && s.longitude)
.map(s => [s.latitude!, s.longitude!] as [number, number])
);
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
} else {
mapRef.current.setView(center, zoom);
}
// Cleanup
return () => {
// Don't destroy map on every update, just clean markers
};
}, [schools, center, zoom, onMarkerClick]);
// Cleanup map on unmount
useEffect(() => {
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, []);
return <div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />;
}

View File

@@ -0,0 +1,120 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.skeletonCard {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
.skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.title {
height: 1.5rem;
width: 80%;
margin-bottom: 1rem;
}
.meta {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tag {
height: 1.5rem;
width: 5rem;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 6px;
}
.metric {
height: 3rem;
}
.actions {
display: flex;
gap: 0.75rem;
}
.button {
flex: 1;
height: 2.5rem;
}
/* List skeleton */
.list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.skeletonListItem {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
.listTitle {
height: 1.5rem;
width: 60%;
margin-bottom: 0.75rem;
}
.listText {
height: 1rem;
width: 40%;
}
/* Text skeleton */
.textContainer {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.text {
height: 1rem;
width: 100%;
}
.text:last-child {
width: 70%;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
.metrics {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,59 @@
/**
* LoadingSkeleton Component
* Placeholder for loading states
*/
import styles from './LoadingSkeleton.module.css';
interface LoadingSkeletonProps {
count?: number;
type?: 'card' | 'list' | 'text';
}
export function LoadingSkeleton({ count = 3, type = 'card' }: LoadingSkeletonProps) {
if (type === 'card') {
return (
<div className={styles.grid}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={styles.skeletonCard}>
<div className={`${styles.skeleton} ${styles.title}`} />
<div className={styles.meta}>
<div className={`${styles.skeleton} ${styles.tag}`} />
<div className={`${styles.skeleton} ${styles.tag}`} />
</div>
<div className={styles.metrics}>
<div className={`${styles.skeleton} ${styles.metric}`} />
<div className={`${styles.skeleton} ${styles.metric}`} />
<div className={`${styles.skeleton} ${styles.metric}`} />
</div>
<div className={styles.actions}>
<div className={`${styles.skeleton} ${styles.button}`} />
<div className={`${styles.skeleton} ${styles.button}`} />
</div>
</div>
))}
</div>
);
}
if (type === 'list') {
return (
<div className={styles.list}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={styles.skeletonListItem}>
<div className={`${styles.skeleton} ${styles.listTitle}`} />
<div className={`${styles.skeleton} ${styles.listText}`} />
</div>
))}
</div>
);
}
return (
<div className={styles.textContainer}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={`${styles.skeleton} ${styles.text}`} />
))}
</div>
);
}

View File

@@ -0,0 +1,144 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal.small {
width: 100%;
max-width: 400px;
}
.modal.medium {
width: 100%;
max-width: 600px;
}
.modal.large {
width: 100%;
max-width: 900px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.closeButton {
padding: 0.5rem;
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.closeButton:hover {
background: #f3f4f6;
color: #1f2937;
}
.content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
/* Scrollbar styles */
.content::-webkit-scrollbar {
width: 8px;
}
.content::-webkit-scrollbar-track {
background: #f3f4f6;
}
.content::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.content::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
@media (max-width: 640px) {
.overlay {
padding: 0;
align-items: flex-end;
}
.modal {
width: 100%;
max-width: 100%;
max-height: 95vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.header,
.content {
padding: 1rem;
}
}

View File

@@ -0,0 +1,82 @@
/**
* Modal Component
* Reusable modal overlay with animations
*/
'use client';
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
size?: 'small' | 'medium' | 'large';
}
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
// Add event listener
document.addEventListener('keydown', handleEscape);
// Prevent body scroll
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, handleEscape]);
if (!isOpen || typeof window === 'undefined') return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return createPortal(
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}>
<div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>}
<button
className={styles.closeButton}
onClick={onClose}
aria-label="Close modal"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className={styles.content}>
{children}
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,103 @@
.header {
position: sticky;
top: 0;
z-index: 100;
background: white;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: #1f2937;
font-size: 1.25rem;
font-weight: 700;
transition: color 0.2s ease;
}
.logo:hover {
color: #3b82f6;
}
.logoIcon {
font-size: 1.5rem;
}
.logoText {
font-weight: 700;
}
.nav {
display: flex;
gap: 0.5rem;
}
.navLink {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
font-weight: 500;
color: #6b7280;
text-decoration: none;
border-radius: 6px;
transition: all 0.2s ease;
}
.navLink:hover {
color: #1f2937;
background: #f3f4f6;
}
.navLink.active {
color: #3b82f6;
background: #eff6ff;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: #3b82f6;
border-radius: 9999px;
}
@media (max-width: 640px) {
.container {
padding: 0 1rem;
}
.logoText {
display: none;
}
.nav {
gap: 0.25rem;
}
.navLink {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}

View File

@@ -0,0 +1,58 @@
/**
* Navigation Component
* Main navigation header with active link highlighting
*/
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import styles from './Navigation.module.css';
export function Navigation() {
const pathname = usePathname();
const { selectedSchools } = useComparison();
const isActive = (path: string) => {
if (path === '/') {
return pathname === '/';
}
return pathname.startsWith(path);
};
return (
<header className={styles.header}>
<div className={styles.container}>
<Link href="/" className={styles.logo}>
<span className={styles.logoIcon}>🏫</span>
<span className={styles.logoText}>SchoolCompare</span>
</Link>
<nav className={styles.nav}>
<Link
href="/"
className={isActive('/') ? `${styles.navLink} ${styles.active}` : styles.navLink}
>
Home
</Link>
<Link
href="/compare"
className={isActive('/compare') ? `${styles.navLink} ${styles.active}` : styles.navLink}
>
Compare
{selectedSchools.length > 0 && (
<span className={styles.badge}>{selectedSchools.length}</span>
)}
</Link>
<Link
href="/rankings"
className={isActive('/rankings') ? `${styles.navLink} ${styles.active}` : styles.navLink}
>
Rankings
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,97 @@
.pagination {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 2rem 0;
}
.info {
font-size: 0.875rem;
color: #6b7280;
}
.controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navButton {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.navButton:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
}
.navButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pages {
display: flex;
gap: 0.25rem;
}
.pageButton,
.pageButtonActive {
min-width: 2.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.pageButton {
color: #374151;
}
.pageButton:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.pageButtonActive {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.ellipsis {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #9ca3af;
}
@media (max-width: 640px) {
.controls {
flex-wrap: wrap;
}
.pages {
order: -1;
width: 100%;
justify-content: center;
}
.navButton {
flex: 1;
}
}

View File

@@ -0,0 +1,126 @@
/**
* Pagination Component
* Navigate through pages of results
*/
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import styles from './Pagination.module.css';
interface PaginationProps {
currentPage: number;
totalPages: number;
total: number;
}
export function Pagination({ currentPage, totalPages, total }: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
if (totalPages <= 1) return null;
const goToPage = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', page.toString());
router.push(`${pathname}?${params.toString()}`);
};
const handlePrevious = () => {
if (currentPage > 1) {
goToPage(currentPage - 1);
}
};
const handleNext = () => {
if (currentPage < totalPages) {
goToPage(currentPage + 1);
}
};
// Generate page numbers to show
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7;
if (totalPages <= maxVisible) {
// Show all pages
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Show first, last, and pages around current
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
};
const pageNumbers = getPageNumbers();
return (
<div className={styles.pagination}>
<div className={styles.info}>
Showing page {currentPage} of {totalPages} ({total.toLocaleString()} total schools)
</div>
<div className={styles.controls}>
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className={styles.navButton}
aria-label="Previous page"
>
Previous
</button>
<div className={styles.pages}>
{pageNumbers.map((page, index) => (
typeof page === 'number' ? (
<button
key={index}
onClick={() => goToPage(page)}
className={page === currentPage ? styles.pageButtonActive : styles.pageButton}
aria-label={`Go to page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
) : (
<span key={index} className={styles.ellipsis}>
{page}
</span>
)
))}
</div>
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className={styles.navButton}
aria-label="Next page"
>
Next
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
.chartWrapper {
width: 100%;
height: 100%;
position: relative;
}
@media (max-width: 768px) {
.chartWrapper {
font-size: 0.875rem;
}
}

View File

@@ -0,0 +1,205 @@
/**
* PerformanceChart Component
* Displays school performance data over time using Chart.js
*/
'use client';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
ChartOptions,
} from 'chart.js';
import type { SchoolResult } from '@/lib/types';
import styles from './PerformanceChart.module.css';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface PerformanceChartProps {
data: SchoolResult[];
schoolName: string;
}
export function PerformanceChart({ data, schoolName }: PerformanceChartProps) {
// Sort data by year
const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => d.year.toString());
// Prepare datasets
const datasets = [
{
label: 'RWM Expected %',
data: sortedData.map(d => d.rwm_expected_pct),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
},
{
label: 'RWM Higher %',
data: sortedData.map(d => d.rwm_high_pct),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
},
{
label: 'Reading Progress',
data: sortedData.map(d => d.reading_progress),
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
yAxisID: 'y1',
},
{
label: 'Writing Progress',
data: sortedData.map(d => d.writing_progress),
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.3,
yAxisID: 'y1',
},
{
label: 'Maths Progress',
data: sortedData.map(d => d.maths_progress),
borderColor: 'rgb(236, 72, 153)',
backgroundColor: 'rgba(236, 72, 153, 0.1)',
tension: 0.3,
yAxisID: 'y1',
},
];
const chartData = {
labels: years,
datasets,
};
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
plugins: {
legend: {
position: 'top' as const,
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
},
title: {
display: true,
text: `${schoolName} - Performance Over Time`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
if (context.dataset.yAxisID === 'y1') {
// Progress scores
label += context.parsed.y.toFixed(1);
} else {
// Percentages
label += context.parsed.y.toFixed(1) + '%';
}
}
return label;
}
}
},
},
scales: {
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
title: {
display: true,
text: 'Percentage (%)',
font: {
size: 12,
weight: 'bold',
},
},
min: 0,
max: 100,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
title: {
display: true,
text: 'Progress Score',
font: {
size: 12,
weight: 'bold',
},
},
grid: {
drawOnChartArea: false,
},
},
x: {
grid: {
display: false,
},
title: {
display: true,
text: 'Year',
font: {
size: 12,
weight: 'bold',
},
},
},
},
};
return (
<div className={styles.chartWrapper}>
<Line data={chartData} options={options} />
</div>
);
}

View File

@@ -0,0 +1,284 @@
.container {
width: 100%;
}
/* Header */
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
}
/* Filters */
.filters {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 2rem;
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
box-shadow: var(--shadow-sm);
}
.filterGroup {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 200px;
}
.filterLabel {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.filterSelect {
flex: 1;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border-medium);
border-radius: var(--radius-md);
background: white;
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition);
}
.filterSelect:hover {
border-color: var(--primary);
}
.filterSelect:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Rankings Section */
.rankingsSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-sm);
}
.tableWrapper {
overflow-x: auto;
}
.rankingsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.rankingsTable thead {
background: var(--bg-secondary);
}
.rankingsTable th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border-medium);
white-space: nowrap;
}
.rankHeader {
width: 80px;
}
.schoolHeader {
min-width: 250px;
}
.areaHeader {
min-width: 150px;
}
.typeHeader {
min-width: 120px;
}
.valueHeader {
width: 120px;
text-align: center;
}
.actionHeader {
width: 120px;
text-align: center;
}
.rankingsTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-secondary);
}
.rankingsTable tbody tr:last-child td {
border-bottom: none;
}
.rankingsTable tbody tr:hover {
background: var(--bg-secondary);
}
/* Top 3 Highlighting */
.rank1 {
background: linear-gradient(90deg, rgba(255, 215, 0, 0.1) 0%, transparent 100%) !important;
}
.rank2 {
background: linear-gradient(90deg, rgba(192, 192, 192, 0.1) 0%, transparent 100%) !important;
}
.rank3 {
background: linear-gradient(90deg, rgba(205, 127, 50, 0.1) 0%, transparent 100%) !important;
}
.rankCell {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--text-primary);
}
.medal {
font-size: 1.5rem;
line-height: 1;
}
.rankNumber {
font-size: 1rem;
}
.schoolCell {
font-weight: 500;
}
.schoolLink {
color: var(--text-primary);
text-decoration: none;
transition: color var(--transition);
}
.schoolLink:hover {
color: var(--primary);
text-decoration: underline;
}
.areaCell,
.typeCell {
color: var(--text-secondary);
}
.valueCell {
text-align: center;
font-size: 1rem;
}
.valueCell strong {
color: var(--text-primary);
}
.actionCell {
text-align: center;
}
.addButton {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition);
background: var(--primary);
color: white;
}
.addButton:hover:not(:disabled) {
background: var(--primary-dark);
}
.addButton:disabled {
background: var(--secondary);
cursor: not-allowed;
opacity: 0.6;
}
/* No Results */
.noResults {
text-align: center;
padding: 3rem 2rem;
color: var(--text-secondary);
}
.noResults p {
font-size: 1rem;
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.header h1 {
font-size: 1.5rem;
}
.filters {
flex-direction: column;
gap: 1rem;
}
.filterGroup {
flex-direction: column;
align-items: stretch;
min-width: 100%;
}
.rankingsSection {
padding: 1rem;
}
.rankingsTable {
font-size: 0.875rem;
}
.rankingsTable th,
.rankingsTable td {
padding: 0.75rem 0.5rem;
}
.medal {
font-size: 1.25rem;
}
.schoolHeader {
min-width: 180px;
}
.areaHeader,
.typeHeader {
min-width: 100px;
}
}

View File

@@ -0,0 +1,229 @@
/**
* RankingsView Component
* Client-side rankings interface with filters
*/
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress } from '@/lib/utils';
import styles from './RankingsView.module.css';
interface RankingsViewProps {
rankings: RankingEntry[];
filters: Filters;
metrics: MetricDefinition[];
selectedMetric: string;
selectedArea?: string;
selectedYear?: number;
}
export function RankingsView({
rankings,
filters,
metrics,
selectedMetric,
selectedArea,
selectedYear,
}: RankingsViewProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { addSchool, isSelected } = useComparison();
const updateFilters = (updates: Record<string, string | undefined>) => {
const params = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
router.push(`${pathname}?${params.toString()}`);
};
const handleMetricChange = (metric: string) => {
updateFilters({ metric });
};
const handleAreaChange = (area: string) => {
updateFilters({ local_authority: area || undefined });
};
const handleYearChange = (year: string) => {
updateFilters({ year: year || undefined });
};
const handleAddToCompare = (ranking: RankingEntry) => {
addSchool({
...ranking,
// Ensure required School fields are present
address: null,
postcode: null,
latitude: null,
longitude: null,
} as any);
};
// Get metric definition
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
const metricLabel = currentMetricDef?.label || selectedMetric;
const isProgressScore = selectedMetric.includes('progress');
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
return (
<div className={styles.container}>
{/* Header */}
<header className={styles.header}>
<h1>School Rankings</h1>
<p className={styles.subtitle}>
Top-performing schools by {metricLabel.toLowerCase()}
</p>
</header>
{/* Filters */}
<section className={styles.filters}>
<div className={styles.filterGroup}>
<label htmlFor="metric-select" className={styles.filterLabel}>
Metric:
</label>
<select
id="metric-select"
value={selectedMetric}
onChange={(e) => handleMetricChange(e.target.value)}
className={styles.filterSelect}
>
{metrics.map((metric) => (
<option key={metric.key} value={metric.key}>
{metric.label}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label htmlFor="area-select" className={styles.filterLabel}>
Area:
</label>
<select
id="area-select"
value={selectedArea || ''}
onChange={(e) => handleAreaChange(e.target.value)}
className={styles.filterSelect}
>
<option value="">All Areas</option>
{filters.local_authorities.map((area) => (
<option key={area} value={area}>
{area}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label htmlFor="year-select" className={styles.filterLabel}>
Year:
</label>
<select
id="year-select"
value={selectedYear?.toString() || ''}
onChange={(e) => handleYearChange(e.target.value)}
className={styles.filterSelect}
>
<option value="">Latest</option>
{filters.years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
</section>
{/* Rankings Table */}
<section className={styles.rankingsSection}>
{rankings.length === 0 ? (
<div className={styles.noResults}>
<p>No rankings available for the selected filters.</p>
</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.rankingsTable}>
<thead>
<tr>
<th className={styles.rankHeader}>Rank</th>
<th className={styles.schoolHeader}>School</th>
<th className={styles.areaHeader}>Area</th>
<th className={styles.typeHeader}>Type</th>
<th className={styles.valueHeader}>{metricLabel}</th>
<th className={styles.actionHeader}>Action</th>
</tr>
</thead>
<tbody>
{rankings.map((ranking, index) => {
const rank = index + 1;
const isTopThree = rank <= 3;
const alreadyInComparison = isSelected(ranking.urn);
// Format the value
let displayValue: string;
if (ranking.value === null || ranking.value === undefined) {
displayValue = '-';
} else if (isProgressScore) {
displayValue = formatProgress(ranking.value);
} else if (isPercentage) {
displayValue = formatPercentage(ranking.value);
} else {
displayValue = ranking.value.toFixed(1);
}
return (
<tr
key={ranking.urn}
className={isTopThree ? styles[`rank${rank}`] : ''}
>
<td className={styles.rankCell}>
{isTopThree && (
<span className={styles.medal}>
{rank === 1 && '🥇'}
{rank === 2 && '🥈'}
{rank === 3 && '🥉'}
</span>
)}
<span className={styles.rankNumber}>{rank}</span>
</td>
<td className={styles.schoolCell}>
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
{ranking.school_name}
</a>
</td>
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
<td className={styles.valueCell}>
<strong>{displayValue}</strong>
</td>
<td className={styles.actionCell}>
<button
onClick={() => handleAddToCompare(ranking)}
disabled={alreadyInComparison}
className={styles.addButton}
>
{alreadyInComparison ? '✓ Added' : '+ Add'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,159 @@
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
}
.title a {
color: #1f2937;
text-decoration: none;
}
.title a:hover {
color: #3b82f6;
text-decoration: underline;
}
.distance {
font-size: 0.875rem;
color: #6b7280;
white-space: nowrap;
background: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.metaItem {
font-size: 0.875rem;
color: #6b7280;
padding: 0.25rem 0.75rem;
background: #f9fafb;
border-radius: 4px;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 6px;
}
.metric {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metricLabel {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metricValue {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metricValue strong {
font-size: 1.125rem;
color: #1f2937;
}
.trend {
font-size: 1rem;
font-weight: bold;
cursor: help;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btnSecondary,
.btnPrimary {
flex: 1;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
text-align: center;
text-decoration: none;
transition: all 0.2s ease;
}
.btnSecondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btnSecondary:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.btnPrimary {
background: #3b82f6;
color: white;
}
.btnPrimary:hover {
background: #2563eb;
}
.btnPrimary:active {
transform: scale(0.98);
}
@media (max-width: 640px) {
.card {
padding: 1rem;
}
.metrics {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,107 @@
/**
* SchoolCard Component
* Displays school information with metrics and actions
*/
import Link from 'next/link';
import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend, getTrendColor } from '@/lib/utils';
import styles from './SchoolCard.module.css';
interface SchoolCardProps {
school: School;
onAddToCompare?: (school: School) => void;
showDistance?: boolean;
distance?: number;
}
export function SchoolCard({ school, onAddToCompare, showDistance, distance }: 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.header}>
<h3 className={styles.title}>
<Link href={`/school/${school.urn}`}>
{school.school_name}
</Link>
</h3>
{showDistance && distance !== undefined && (
<span className={styles.distance}>
{distance.toFixed(1)} km away
</span>
)}
</div>
<div className={styles.meta}>
{school.local_authority && (
<span className={styles.metaItem}>{school.local_authority}</span>
)}
{school.school_type && (
<span className={styles.metaItem}>{school.school_type}</span>
)}
{school.religious_denomination && school.religious_denomination !== 'Does not apply' && (
<span className={styles.metaItem}>{school.religious_denomination}</span>
)}
</div>
{(school.rwm_expected_pct !== null || school.reading_progress !== null) && (
<div className={styles.metrics}>
{school.rwm_expected_pct !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>RWM Expected</span>
<div className={styles.metricValue}>
<strong>{formatPercentage(school.rwm_expected_pct)}</strong>
{school.prev_rwm_expected_pct !== null && (
<span
className={styles.trend}
style={{ color: trendColor }}
title={`Previous: ${formatPercentage(school.prev_rwm_expected_pct)}`}
>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
</span>
)}
</div>
</div>
)}
{school.reading_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>Reading Progress</span>
<strong>{formatProgress(school.reading_progress)}</strong>
</div>
)}
{school.writing_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>Writing Progress</span>
<strong>{formatProgress(school.writing_progress)}</strong>
</div>
)}
{school.maths_progress !== null && (
<div className={styles.metric}>
<span className={styles.metricLabel}>Maths Progress</span>
<strong>{formatProgress(school.maths_progress)}</strong>
</div>
)}
</div>
)}
<div className={styles.actions}>
<Link href={`/school/${school.urn}`} className={styles.btnSecondary}>
View Details
</Link>
{onAddToCompare && (
<button
onClick={() => onAddToCompare(school)}
className={styles.btnPrimary}
>
Add to Compare
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
.container {
width: 100%;
}
/* Header Section */
.header {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
}
.titleSection {
flex: 1;
}
.schoolName {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.75rem;
line-height: 1.2;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.75rem;
}
.metaItem {
font-size: 0.9375rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.25rem;
}
.address {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
}
.actions {
display: flex;
gap: 0.75rem;
}
.btnAdd,
.btnRemove {
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btnAdd {
background: var(--primary);
color: white;
}
.btnAdd:hover {
background: var(--primary-dark);
}
.btnRemove {
background: var(--success);
color: white;
}
.btnRemove:hover {
opacity: 0.9;
}
/* Summary Section */
.summary {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.sectionTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-light);
}
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.metricCard {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 1.25rem;
text-align: center;
}
.metricLabel {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-weight: 500;
}
.metricValue {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.metricTrend {
font-size: 1.25rem;
color: var(--secondary);
}
/* Charts Section */
.chartsSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.chartContainer {
width: 100%;
height: 400px;
position: relative;
}
/* Detailed Metrics */
.detailedMetrics {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.metricGroup {
margin-bottom: 2rem;
}
.metricGroup:last-child {
margin-bottom: 0;
}
.metricGroupTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.metricTable {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.metricRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.metricName {
font-size: 0.9375rem;
color: var(--text-secondary);
}
.metricRow .metricValue {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
/* Absence Section */
.absenceSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.absenceGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.absenceCard {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 1.5rem;
text-align: center;
}
.absenceLabel {
font-size: 0.9375rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
font-weight: 500;
}
.absenceValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
/* Map Section */
.mapSection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.mapContainer {
width: 100%;
height: 400px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-light);
}
/* History Section */
.historySection {
background: white;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-sm);
}
.tableWrapper {
overflow-x: auto;
margin-top: 1rem;
}
.dataTable {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.dataTable thead {
background: var(--bg-secondary);
}
.dataTable th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border-medium);
}
.dataTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-secondary);
}
.dataTable tbody tr:last-child td {
border-bottom: none;
}
.dataTable tbody tr:hover {
background: var(--bg-secondary);
}
.yearCell {
font-weight: 600;
color: var(--text-primary);
}
/* Responsive Design */
@media (max-width: 768px) {
.headerContent {
flex-direction: column;
gap: 1.5rem;
}
.actions {
width: 100%;
}
.btnAdd,
.btnRemove {
flex: 1;
}
.schoolName {
font-size: 1.5rem;
}
.meta {
flex-direction: column;
gap: 0.5rem;
}
.metricsGrid {
grid-template-columns: 1fr;
}
.chartContainer {
height: 300px;
}
.mapContainer {
height: 300px;
}
.dataTable {
font-size: 0.875rem;
}
.dataTable th,
.dataTable td {
padding: 0.75rem 0.5rem;
}
}

View File

@@ -0,0 +1,321 @@
/**
* SchoolDetailView Component
* Displays comprehensive school information with performance charts
*/
'use client';
import { useComparison } from '@/hooks/useComparison';
import { PerformanceChart } from './PerformanceChart';
import { SchoolMap } from './SchoolMap';
import type { School, SchoolResult, AbsenceData } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
import styles from './SchoolDetailView.module.css';
interface SchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
absenceData: AbsenceData | null;
}
export function SchoolDetailView({ schoolInfo, yearlyData, absenceData }: SchoolDetailViewProps) {
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
// Get latest results
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Handle add/remove from comparison
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
} else {
addSchool(schoolInfo);
}
};
return (
<div className={styles.container}>
{/* Header Section */}
<header className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>
📍 {schoolInfo.local_authority}
</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>
🏫 {schoolInfo.school_type}
</span>
)}
<span className={styles.metaItem}>
🔢 URN: {schoolInfo.urn}
</span>
</div>
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}
{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
</p>
)}
</div>
<div className={styles.actions}>
<button
onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd}
>
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
</button>
</div>
</div>
</header>
{/* Latest Results Summary */}
{latestResults && (
<section className={styles.summary}>
<h2 className={styles.sectionTitle}>
Latest Results ({latestResults.year})
</h2>
<div className={styles.metricsGrid}>
{latestResults.rwm_expected_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
RWM Expected Standard
</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.rwm_expected_pct)}
</div>
</div>
)}
{latestResults.rwm_high_pct !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
RWM Higher Standard
</div>
<div className={styles.metricValue}>
{formatPercentage(latestResults.rwm_high_pct)}
</div>
</div>
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Reading Progress
</div>
<div className={styles.metricValue}>
{formatProgress(latestResults.reading_progress)}
</div>
</div>
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Writing Progress
</div>
<div className={styles.metricValue}>
{formatProgress(latestResults.writing_progress)}
</div>
</div>
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
Maths Progress
</div>
<div className={styles.metricValue}>
{formatProgress(latestResults.maths_progress)}
</div>
</div>
)}
</div>
</section>
)}
{/* Performance Over Time */}
{yearlyData.length > 0 && (
<section className={styles.chartsSection}>
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
<div className={styles.chartContainer}>
<PerformanceChart
data={yearlyData}
schoolName={schoolInfo.school_name}
/>
</div>
</section>
)}
{/* Detailed Metrics */}
{latestResults && (
<section className={styles.detailedMetrics}>
<h2 className={styles.sectionTitle}>Detailed Metrics</h2>
{/* Reading Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>📖 Reading</h3>
<div className={styles.metricTable}>
{latestResults.reading_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_expected_pct)}</span>
</div>
)}
{latestResults.reading_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
</div>
)}
{latestResults.reading_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress Score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.reading_progress)}</span>
</div>
)}
{latestResults.reading_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average Scaled Score</span>
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
{/* Writing Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}> Writing</h3>
<div className={styles.metricTable}>
{latestResults.writing_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_expected_pct)}</span>
</div>
)}
{latestResults.writing_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
</div>
)}
{latestResults.writing_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress Score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.writing_progress)}</span>
</div>
)}
</div>
</div>
{/* Maths Metrics */}
<div className={styles.metricGroup}>
<h3 className={styles.metricGroupTitle}>🔢 Mathematics</h3>
<div className={styles.metricTable}>
{latestResults.maths_expected_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Expected Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_expected_pct)}</span>
</div>
)}
{latestResults.maths_high_pct !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Higher Standard</span>
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
</div>
)}
{latestResults.maths_progress !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Progress Score</span>
<span className={styles.metricValue}>{formatProgress(latestResults.maths_progress)}</span>
</div>
)}
{latestResults.maths_avg_score !== null && (
<div className={styles.metricRow}>
<span className={styles.metricName}>Average Scaled Score</span>
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
</div>
)}
</div>
</div>
</section>
)}
{/* Absence Data */}
{absenceData && (
<section className={styles.absenceSection}>
<h2 className={styles.sectionTitle}>Absence Data</h2>
<div className={styles.absenceGrid}>
{absenceData.overall_absence_rate !== null && (
<div className={styles.absenceCard}>
<div className={styles.absenceLabel}>Overall Absence Rate</div>
<div className={styles.absenceValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
</div>
)}
{absenceData.persistent_absence_rate !== null && (
<div className={styles.absenceCard}>
<div className={styles.absenceLabel}>Persistent Absence</div>
<div className={styles.absenceValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
</div>
)}
</div>
</section>
)}
{/* Map */}
{schoolInfo.latitude && schoolInfo.longitude && (
<section className={styles.mapSection}>
<h2 className={styles.sectionTitle}>Location</h2>
<div className={styles.mapContainer}>
<SchoolMap
schools={[schoolInfo]}
center={[schoolInfo.latitude, schoolInfo.longitude]}
zoom={15}
/>
</div>
</section>
)}
{/* All Years Data Table */}
{yearlyData.length > 0 && (
<section className={styles.historySection}>
<h2 className={styles.sectionTitle}>Historical Data</h2>
<div className={styles.tableWrapper}>
<table className={styles.dataTable}>
<thead>
<tr>
<th>Year</th>
<th>RWM Expected</th>
<th>RWM Higher</th>
<th>Reading Progress</th>
<th>Writing Progress</th>
<th>Maths Progress</th>
<th>Avg. Scaled Score</th>
</tr>
</thead>
<tbody>
{yearlyData.map((result) => (
<tr key={result.year}>
<td className={styles.yearCell}>{result.year}</td>
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
<td>-</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
.mapWrapper {
width: 100%;
height: 100%;
position: relative;
}
.mapLoading {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border-radius: var(--radius-md);
gap: 1rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid rgba(59, 130, 246, 0.3);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mapLoading p {
color: var(--text-secondary);
font-size: 0.9375rem;
margin: 0;
}

View File

@@ -0,0 +1,57 @@
/**
* SchoolMap Component
* Client-side Leaflet map wrapper for displaying school locations
*/
'use client';
import dynamic from 'next/dynamic';
import type { School } from '@/lib/types';
import styles from './SchoolMap.module.css';
// Dynamic import to avoid SSR issues with Leaflet
const LeafletMap = dynamic(() => import('./LeafletMapInner'), {
ssr: false,
loading: () => (
<div className={styles.mapLoading}>
<div className={styles.spinner}></div>
<p>Loading map...</p>
</div>
),
});
interface SchoolMapProps {
schools: School[];
center?: [number, number];
zoom?: number;
onMarkerClick?: (school: School) => void;
}
export function SchoolMap({ schools, center, zoom = 13, onMarkerClick }: SchoolMapProps) {
// Calculate center if not provided
const mapCenter: [number, number] = center || (() => {
if (schools.length === 0) return [51.5074, -0.1278]; // Default to London
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
return [schools[0].latitude, schools[0].longitude];
}
// Calculate average position
const validSchools = schools.filter(s => s.latitude && s.longitude);
if (validSchools.length === 0) return [51.5074, -0.1278];
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
return [avgLat, avgLng];
})();
return (
<div className={styles.mapWrapper}>
<LeafletMap
schools={schools}
center={mapCenter}
zoom={zoom}
onMarkerClick={onMarkerClick}
/>
</div>
);
}

View File

@@ -0,0 +1,177 @@
.modalContent {
padding: 2rem;
max-width: 600px;
width: 90vw;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.5rem;
}
.warning {
background: #fef3c7;
border: 1px solid #fde047;
color: #92400e;
padding: 1rem;
border-radius: var(--radius-md);
margin-bottom: 1rem;
font-size: 0.9375rem;
}
.searchContainer {
position: relative;
margin-bottom: 1.5rem;
}
.searchInput {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
border: 2px solid var(--border-medium);
border-radius: var(--radius-md);
transition: all var(--transition);
}
.searchInput:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.searchSpinner {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(59, 130, 246, 0.3);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: translateY(-50%) rotate(360deg);
}
}
.results {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.resultItem {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
transition: all var(--transition);
}
.resultItem:hover {
background: white;
box-shadow: var(--shadow-sm);
}
.schoolInfo {
flex: 1;
min-width: 0;
}
.schoolName {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schoolMeta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.schoolMeta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.addButton {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
background: var(--primary);
color: white;
}
.addButton:hover:not(:disabled) {
background: var(--primary-dark);
}
.addButton:disabled {
background: var(--secondary);
cursor: not-allowed;
opacity: 0.6;
}
.noResults {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.9375rem;
}
.hint {
text-align: center;
padding: 2rem;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
/* Responsive */
@media (max-width: 768px) {
.modalContent {
padding: 1.5rem;
width: 95vw;
}
.title {
font-size: 1.25rem;
}
.resultItem {
flex-direction: column;
align-items: stretch;
}
.addButton {
width: 100%;
}
.schoolMeta {
flex-direction: column;
gap: 0.25rem;
}
}

View File

@@ -0,0 +1,142 @@
/**
* SchoolSearchModal Component
* Modal for searching and adding schools to comparison
*/
'use client';
import { useState, useMemo } from 'react';
import { Modal } from './Modal';
import { useComparison } from '@/hooks/useComparison';
import { debounce } from '@/lib/utils';
import type { School } from '@/lib/types';
import styles from './SchoolSearchModal.module.css';
interface SchoolSearchModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
const { addSchool, selectedSchools, canAddMore } = useComparison();
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState<School[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
// Debounced search function
const performSearch = useMemo(
() =>
debounce(async (term: string) => {
if (!term.trim()) {
setResults([]);
setHasSearched(false);
return;
}
setIsSearching(true);
try {
const response = await fetch(`/api/schools?search=${encodeURIComponent(term)}&page_size=10`);
const data = await response.json();
setResults(data.schools || []);
setHasSearched(true);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsSearching(false);
}
}, 300),
[]
);
const handleSearchChange = (value: string) => {
setSearchTerm(value);
performSearch(value);
};
const handleAddSchool = (school: School) => {
addSchool(school);
// Don't close modal, allow adding multiple schools
};
const isSchoolSelected = (urn: number) => {
return selectedSchools.some((s) => s.urn === urn);
};
const handleClose = () => {
setSearchTerm('');
setResults([]);
setHasSearched(false);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div className={styles.modalContent}>
<h2 className={styles.title}>Add School to Comparison</h2>
{!canAddMore && (
<div className={styles.warning}>
Maximum 5 schools can be compared. Remove a school to add another.
</div>
)}
{/* Search Input */}
<div className={styles.searchContainer}>
<input
type="text"
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by school name or location..."
className={styles.searchInput}
autoFocus
/>
{isSearching && <div className={styles.searchSpinner} />}
</div>
{/* Results */}
<div className={styles.results}>
{hasSearched && results.length === 0 && (
<div className={styles.noResults}>
No schools found matching "{searchTerm}"
</div>
)}
{results.map((school) => {
const alreadySelected = isSchoolSelected(school.urn);
return (
<div key={school.urn} className={styles.resultItem}>
<div className={styles.schoolInfo}>
<div className={styles.schoolName}>{school.school_name}</div>
<div className={styles.schoolMeta}>
{school.local_authority && (
<span>📍 {school.local_authority}</span>
)}
{school.school_type && (
<span>🏫 {school.school_type}</span>
)}
</div>
</div>
<button
onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore}
className={styles.addButton}
>
{alreadySelected ? '✓ Added' : '+ Add'}
</button>
</div>
);
})}
</div>
{!hasSearched && (
<div className={styles.hint}>
Start typing to search for schools...
</div>
)}
</div>
</Modal>
);
}