From 1b0d6edb98b5c36d260537878c90674e97412e34 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 4 Feb 2026 10:05:31 +0000 Subject: [PATCH] Add map view for location search results Implemented split-view map layout for postcode searches: - List/Map toggle appears when doing location search - Map view shows interactive map with school markers on left - Compact school list on right with distance badges, stats, actions - Mobile responsive: stacks vertically with map on top - Updated School type to include distance and total_pupils fields Co-Authored-By: Claude Opus 4.5 --- nextjs-app/components/HomeView.module.css | 272 ++++++++++++++++++++- nextjs-app/components/HomeView.tsx | 123 +++++++++- nextjs-app/components/SchoolMap.module.css | 4 +- nextjs-app/lib/types.ts | 6 + 4 files changed, 389 insertions(+), 16 deletions(-) diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css index 770e46c..cf51638 100644 --- a/nextjs-app/components/HomeView.module.css +++ b/nextjs-app/components/HomeView.module.css @@ -2,15 +2,23 @@ width: 100%; } +.locationBannerWrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + .locationBanner { display: flex; align-items: center; gap: 0.75rem; - padding: 1rem 1.5rem; + padding: 0.75rem 1.25rem; background: rgba(45, 125, 125, 0.1); border: 1px solid rgba(45, 125, 125, 0.3); - border-radius: 12px; - margin-bottom: 2rem; + border-radius: 10px; font-size: 0.9375rem; color: var(--accent-teal, #2d7d7d); font-weight: 500; @@ -21,10 +29,227 @@ color: var(--accent-teal, #2d7d7d); } +/* View Toggle */ +.viewToggle { + display: flex; + gap: 0.25rem; + background: var(--bg-secondary, #f3ede4); + padding: 0.25rem; + border-radius: 8px; +} + +.viewToggleBtn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + font-size: 0.875rem; + font-weight: 500; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + color: var(--text-secondary, #5c564d); + transition: all 0.2s ease; +} + +.viewToggleBtn:hover { + color: var(--text-primary, #1a1612); +} + +.viewToggleBtn.active { + background: var(--bg-card, white); + color: var(--accent-coral, #e07256); + box-shadow: 0 2px 4px rgba(26, 22, 18, 0.08); +} + +.viewToggleBtn svg { + flex-shrink: 0; +} + .results { margin-top: 2rem; } +.mapViewResults { + margin-top: 0; +} + +/* Map View Layout */ +.mapViewContainer { + display: grid; + grid-template-columns: 1fr 380px; + gap: 1.5rem; + height: 550px; +} + +.mapContainer { + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border-color, #e5dfd5); + height: 100%; +} + +.compactList { + display: flex; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; + height: 100%; + padding-right: 0.5rem; +} + +.compactList::-webkit-scrollbar { + width: 6px; +} + +.compactList::-webkit-scrollbar-track { + background: var(--bg-secondary, #f3ede4); + border-radius: 3px; +} + +.compactList::-webkit-scrollbar-thumb { + background: var(--border-color, #e5dfd5); + border-radius: 3px; +} + +.compactList::-webkit-scrollbar-thumb:hover { + background: var(--text-muted, #8a847a); +} + +/* Compact School Item */ +.compactItem { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + background: var(--bg-card, white); + border: 1px solid var(--border-color, #e5dfd5); + border-radius: 10px; + transition: all 0.2s ease; +} + +.compactItem:hover { + border-color: var(--accent-coral, #e07256); + box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06); +} + +.compactItemContent { + flex: 1; + min-width: 0; +} + +.compactItemHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.compactItemName { + font-weight: 600; + font-size: 0.9375rem; + color: var(--text-primary, #1a1612); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.compactItemName:hover { + color: var(--accent-coral, #e07256); +} + +.distanceBadge { + flex-shrink: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + background: var(--accent-teal, #2d7d7d); + color: white; + border-radius: 4px; +} + +.compactItemMeta { + display: flex; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--text-secondary, #5c564d); + margin-bottom: 0.375rem; +} + +.compactItemMeta span:not(:last-child)::after { + content: 'ยท'; + margin-left: 0.5rem; + color: var(--text-muted, #8a847a); +} + +.compactItemStats { + display: flex; + gap: 1rem; + font-size: 0.8125rem; + color: var(--text-secondary, #5c564d); +} + +.compactStat strong { + color: var(--text-primary, #1a1612); +} + +.compactItemActions { + display: flex; + flex-direction: column; + gap: 0.375rem; + flex-shrink: 0; +} + +.compactBtn { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + background: var(--accent-coral, #e07256); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.compactBtn:hover { + background: var(--accent-coral-dark, #c45a3f); +} + +.compactBtn.compactBtnActive { + background: var(--bg-secondary, #f3ede4); + color: var(--text-secondary, #5c564d); + border: 1px solid var(--border-color, #e5dfd5); +} + +.compactBtn.compactBtnActive:hover { + background: var(--border-color, #e5dfd5); + color: var(--text-primary, #1a1612); +} + +.compactBtnSecondary { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + background: transparent; + color: var(--text-secondary, #5c564d); + border: 1px solid var(--border-color, #e5dfd5); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + text-align: center; +} + +.compactBtnSecondary:hover { + background: var(--bg-secondary, #f3ede4); + color: var(--text-primary, #1a1612); +} + .sectionHeader { margin-bottom: 2rem; } @@ -142,12 +367,51 @@ grid-template-columns: 1fr; } + .locationBannerWrapper { + flex-direction: column; + align-items: stretch; + } + .locationBanner { - padding: 0.875rem 1rem; + padding: 0.75rem 1rem; font-size: 0.875rem; border-radius: 8px; } + .viewToggle { + justify-content: center; + } + + .mapViewContainer { + grid-template-columns: 1fr; + grid-template-rows: 300px auto; + height: auto; + } + + .mapContainer { + height: 300px; + } + + .compactList { + height: auto; + max-height: 400px; + padding-right: 0; + } + + .compactItem { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .compactItemActions { + flex-direction: row; + } + + .compactItemActions > * { + flex: 1; + } + .emptyState { padding: 3rem 1.5rem; } diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index a541e80..4e14001 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -5,13 +5,15 @@ 'use client'; +import { useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { FilterBar } from './FilterBar'; import { SchoolCard } from './SchoolCard'; +import { SchoolMap } from './SchoolMap'; import { Pagination } from './Pagination'; import { EmptyState } from './EmptyState'; import { useComparisonContext } from '@/context/ComparisonContext'; -import type { SchoolsResponse, Filters } from '@/lib/types'; +import type { SchoolsResponse, Filters, School } from '@/lib/types'; import styles from './HomeView.module.css'; interface HomeViewProps { @@ -21,7 +23,8 @@ interface HomeViewProps { export function HomeView({ initialSchools, filters }: HomeViewProps) { const searchParams = useSearchParams(); - const { addSchool } = useComparisonContext(); + const { addSchool, selectedSchools } = useComparisonContext(); + const [resultsView, setResultsView] = useState<'list' | 'map'>('list'); const hasSearch = searchParams.get('search') || searchParams.get('postcode'); const isLocationSearch = !!searchParams.get('postcode'); @@ -36,18 +39,48 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) { heroDescription="Search and compare KS2 results for thousands of schools across England" /> - {/* Location Info Banner */} + {/* Location Info Banner with View Toggle */} {isLocationSearch && initialSchools.location_info && ( -
- - Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '} - {initialSchools.location_info.postcode} - +
+
+ + Showing schools within {(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of{' '} + {initialSchools.location_info.postcode} + +
+ {initialSchools.schools.length > 0 && ( +
+ + +
+ )}
)} {/* Results Section */} -
+
{!hasSearch && initialSchools.schools.length > 0 && (

Featured Schools

@@ -57,7 +90,7 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
)} - {hasSearch && ( + {hasSearch && resultsView === 'list' && (

{initialSchools.total.toLocaleString()} school @@ -85,7 +118,28 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) { : undefined } /> + ) : resultsView === 'map' && isLocationSearch ? ( + /* Map View Layout */ +
+
+ +
+
+ {initialSchools.schools.map((school) => ( + s.urn === school.urn)} + /> + ))} +
+
) : ( + /* List View Layout */ <>
{initialSchools.schools.map((school) => ( @@ -110,3 +164,52 @@ export function HomeView({ initialSchools, filters }: HomeViewProps) {
); } + +/* Compact School Item for Map View */ +interface CompactSchoolItemProps { + school: School; + onAddToCompare: (school: School) => void; + isInCompare: boolean; +} + +function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoolItemProps) { + return ( +
+
+
+ + {school.school_name} + + {school.distance !== undefined && school.distance !== null && ( + + {school.distance.toFixed(1)} mi + + )} +
+
+ {school.school_type && {school.school_type}} + {school.local_authority && {school.local_authority}} +
+
+ + {school.rwm_expected_pct !== null ? `${school.rwm_expected_pct}%` : '-'} RWM + + + {school.total_pupils || '-'} pupils + +
+
+
+ + + Details + +
+
+ ); +} diff --git a/nextjs-app/components/SchoolMap.module.css b/nextjs-app/components/SchoolMap.module.css index 38788a1..0f780c1 100644 --- a/nextjs-app/components/SchoolMap.module.css +++ b/nextjs-app/components/SchoolMap.module.css @@ -19,9 +19,9 @@ .spinner { width: 2rem; height: 2rem; - border: 3px solid rgba(59, 130, 246, 0.3); + border: 3px solid rgba(224, 114, 86, 0.3); border-radius: 50%; - border-top-color: var(--primary); + border-top-color: var(--accent-coral, #e07256); animation: spin 0.8s linear infinite; } diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 7b3d2b6..cb7c3cb 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -29,6 +29,9 @@ export interface School { latitude: number | null; longitude: number | null; + // School context + total_pupils?: number | null; + // Latest year metrics (for search/list views) rwm_expected_pct?: number | null; reading_expected_pct?: number | null; @@ -41,6 +44,9 @@ export interface School { // Trend indicators (for list views) prev_rwm_expected_pct?: number | null; trend?: 'up' | 'down' | 'stable'; + + // Location search fields + distance?: number | null; } // ============================================================================