feat(school-detail): map-blended hero, remove at-a-glance stats
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 52s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Replace the header's at-a-glance stats row with a location map that sits atop the hero and blends into the school title. The map is a static, non-interactive preview (never traps page scroll) with a coral pin; the whole band — or the inline "View on map ↗" link by the address — opens a fullscreen, interactive map. Compare floats glassy over the band. The separate "Location" section (and its nav item) is removed; the map now lives only in the hero. Schools without lat/long render the header with no map band, as before. New: SchoolHeroMap (fullscreen wrapper, forwardRef open handle) + LeafletHeroMapInner (minimal single-school map with interaction toggle). Applied to both primary and secondary detail views; dead heroStats/tone/ mapContainer CSS removed (shared .heroStat* card classes kept). Also drops the orphaned "Latest data" note and does not reintroduce an Ofsted strip in the hero (it would duplicate the Ofsted section directly below). Mockup kept at mockups/header-map-hero.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>School header — “Emerge” map hero (desktop + mobile)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<style>
|
||||
:root{
|
||||
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
||||
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
||||
--accent-coral:#e07256; --accent-coral-dark:#c45a3f; --accent-teal:#2d7d7d;
|
||||
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06);
|
||||
--serif:'Playfair Display',Georgia,serif; --sans:'DM Sans',-apple-system,sans-serif;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.55;padding:40px 20px 120px}
|
||||
.wrap{max-width:1120px;margin:0 auto}
|
||||
.pagehead h1{font-family:var(--serif);font-weight:700;font-size:30px;letter-spacing:-.01em}
|
||||
.pagehead p{color:var(--text-secondary);margin-top:8px;font-size:15px;max-width:74ch}
|
||||
.optlabel{display:flex;align-items:center;gap:10px;font-size:12px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--accent-teal);margin:52px 0 6px}
|
||||
.optdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:74ch}
|
||||
|
||||
.stage{display:flex;gap:44px;align-items:flex-start;flex-wrap:wrap}
|
||||
.col-desktop{flex:1 1 560px;min-width:420px}
|
||||
.col-mobile{flex:0 0 auto}
|
||||
.caption{font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);margin-bottom:12px}
|
||||
|
||||
.topBack{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 12px;font-size:17px;font-weight:600;color:var(--accent-coral)}
|
||||
|
||||
/* ── hero shell ── */
|
||||
.hero{position:relative;background:var(--bg-card);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
|
||||
.map{width:100%;background:#dfe6e2}
|
||||
.map .leaflet-control-attribution{font-size:9px;background:rgba(255,255,255,.55)}
|
||||
|
||||
/* coral teardrop pin */
|
||||
.pin{position:relative;width:26px;height:26px}
|
||||
.pin svg{filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))}
|
||||
.pin .ring{position:absolute;left:50%;top:72%;width:34px;height:34px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.28);animation:pulse 2.4s ease-out infinite}
|
||||
@keyframes pulse{0%{transform:translate(-50%,-50%) scale(.5);opacity:.9}100%{transform:translate(-50%,-50%) scale(1.6);opacity:0}}
|
||||
|
||||
.compareBtn{position:absolute;z-index:500;top:14px;right:14px;font-size:13px;font-weight:600;padding:9px 15px;border-radius:8px;cursor:pointer;border:none;
|
||||
background:rgba(255,255,255,.88);color:var(--accent-coral);backdrop-filter:blur(6px);box-shadow:0 2px 10px rgba(0,0,0,.16)}
|
||||
|
||||
/* the diffuse blend: tall gradient that reaches solid white well before the title */
|
||||
.fade{position:absolute;left:0;right:0;z-index:400;pointer-events:none;
|
||||
background:linear-gradient(to bottom,
|
||||
rgba(255,255,255,0) 0%,
|
||||
rgba(255,255,255,.35) 35%,
|
||||
rgba(255,255,255,.75) 62%,
|
||||
rgba(255,255,255,.95) 82%,
|
||||
var(--bg-card) 100%)}
|
||||
|
||||
.body{position:relative;z-index:410}
|
||||
.name{font-family:var(--serif);font-weight:700;line-height:1.08;letter-spacing:-.01em}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||
.chip{background:var(--bg-secondary);color:var(--text-secondary);font-size:13px;font-weight:500;padding:4px 11px;border-radius:6px}
|
||||
.addr{display:flex;align-items:center;flex-wrap:wrap;gap:7px;color:var(--text-secondary);font-size:15px;margin-top:14px}
|
||||
.addr .maplink{color:var(--accent-coral);font-weight:600;text-decoration:none;white-space:nowrap}
|
||||
.details{display:flex;flex-wrap:wrap;gap:6px 22px;margin-top:14px;font-size:14px;color:var(--text-secondary)}
|
||||
.details a{color:var(--accent-teal);font-weight:600;text-decoration:none}
|
||||
.details b{color:var(--text-primary)}
|
||||
.ofstedStrip{display:inline-block;margin-top:16px;padding:9px 14px;border-radius:8px;background:rgba(45,125,125,.10);border-left:3px solid var(--accent-teal)}
|
||||
.ofstedStrip b{font-size:14px}.ofstedStrip span{display:block;font-size:12px;color:var(--text-muted)}
|
||||
|
||||
/* desktop sizing */
|
||||
.col-desktop .map{height:210px}
|
||||
.col-desktop .fade{top:70px;height:190px} /* white by ~map bottom (210) */
|
||||
.col-desktop .body{margin-top:-6px;padding:0 26px 22px}
|
||||
.col-desktop .name{font-size:clamp(30px,3.6vw,44px)}
|
||||
|
||||
/* mobile phone frame */
|
||||
.phone{width:390px;border:10px solid #1a1612;border-radius:38px;overflow:hidden;box-shadow:0 18px 50px rgba(26,22,18,.22);background:var(--bg-primary)}
|
||||
.phone .notch{height:26px;background:#1a1612;position:relative}
|
||||
.phone .notch::after{content:"";position:absolute;left:50%;top:7px;transform:translateX(-50%);width:120px;height:20px;background:#1a1612;border-radius:0 0 14px 14px}
|
||||
.phone .globalnav{height:46px;background:#fff;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;font-family:var(--serif);font-weight:700;font-size:17px}
|
||||
.phone .screen{padding:14px 12px 26px}
|
||||
.phone .hero .map{height:150px}
|
||||
.phone .hero .fade{top:52px;height:132px} /* white by ~map bottom (150) */
|
||||
.phone .hero .body{margin-top:-4px;padding:0 18px 18px}
|
||||
.phone .hero .name{font-size:29px}
|
||||
.phone .hero .compareBtn{top:12px;right:12px;padding:8px 12px;font-size:12.5px}
|
||||
.phone .addr{font-size:14px}
|
||||
.phone .details{font-size:13px;gap:4px 16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="pagehead">
|
||||
<h1>“Emerge” map hero — refined</h1>
|
||||
<p>Transition made more diffuse: the map dissolves gradually and reaches solid white before the school name, so the title sits cleanly on white with only a soft memory of the map above it. Redundant floating pill removed — the only map CTA is the inline “View on map ↗”. Desktop and mobile shown side by side.</p>
|
||||
</div>
|
||||
|
||||
<div class="optlabel">Option A · “Emerge” (refined)</div>
|
||||
<p class="optdesc">Same school throughout (Our Lady Queen of Heaven, SW19 6AD). Live, non-interactive preview maps; the CTA opens the full map.</p>
|
||||
|
||||
<div class="stage">
|
||||
<!-- ── DESKTOP ── -->
|
||||
<div class="col-desktop">
|
||||
<div class="caption">Desktop</div>
|
||||
<a class="topBack">← Back</a>
|
||||
<div class="hero" data-hero>
|
||||
<button class="compareBtn">+ Add to Compare</button>
|
||||
<div class="map" id="mapD"></div>
|
||||
<div class="fade"></div>
|
||||
<div class="body">
|
||||
<h1 class="name">Our Lady Queen of Heaven RC School</h1>
|
||||
<div class="meta"><span class="chip">Wandsworth</span><span class="chip">Voluntary aided school</span></div>
|
||||
<div class="addr">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#e07256" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
Victoria Drive, Southfields, London, SW19 6AD · <a class="maplink" data-open>View on map ↗</a>
|
||||
</div>
|
||||
<div class="details">
|
||||
<span><b>Headteacher:</b> Mr Jeremy Tuke</span>
|
||||
<a>School website ↗</a>
|
||||
<span><b>Pupils:</b> 221 (capacity: 232)</span>
|
||||
</div>
|
||||
<div class="ofstedStrip"><b>Ofsted Outstanding</b><span>Inspected November 2023</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── MOBILE ── -->
|
||||
<div class="col-mobile">
|
||||
<div class="caption">Mobile</div>
|
||||
<div class="phone">
|
||||
<div class="notch"></div>
|
||||
<div class="globalnav">SchoolCompare</div>
|
||||
<div class="screen">
|
||||
<a class="topBack" style="font-size:16px">← Back</a>
|
||||
<div class="hero" data-hero>
|
||||
<button class="compareBtn">+ Compare</button>
|
||||
<div class="map" id="mapM"></div>
|
||||
<div class="fade"></div>
|
||||
<div class="body">
|
||||
<h1 class="name">Our Lady Queen of Heaven RC School</h1>
|
||||
<div class="meta"><span class="chip">Wandsworth</span><span class="chip">Voluntary aided</span></div>
|
||||
<div class="addr">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#e07256" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
Victoria Drive, Southfields, SW19 6AD
|
||||
</div>
|
||||
<div style="margin-top:8px"><a class="maplink" data-open style="color:var(--accent-coral);font-weight:600;text-decoration:none;font-size:14px">View on map ↗</a></div>
|
||||
<div class="details">
|
||||
<span><b>Headteacher:</b> Mr Jeremy Tuke</span>
|
||||
<a>School website ↗</a>
|
||||
<span><b>Pupils:</b> 221 (cap. 232)</span>
|
||||
</div>
|
||||
<div class="ofstedStrip"><b>Ofsted Outstanding</b><span>Inspected November 2023</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="optdesc" style="margin-top:26px"><b style="color:var(--text-secondary)">No-location fallback</b> — schools without lat/long simply render the hero with no map band (title at the top as today), so the component degrades cleanly.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const LAT = 51.4459, LNG = -0.2076;
|
||||
const pinIcon = L.divIcon({
|
||||
className:'', iconSize:[26,34], iconAnchor:[13,30],
|
||||
html:'<div class="pin"><span class="ring"></span><svg width="26" height="34" viewBox="0 0 26 34"><path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/><circle cx="13" cy="13" r="5" fill="#fff"/></svg></div>'
|
||||
});
|
||||
function makePreview(id){
|
||||
const m = L.map(id,{zoomControl:false,attributionControl:true,dragging:false,scrollWheelZoom:false,doubleClickZoom:false,boxZoom:false,keyboard:false,touchZoom:false}).setView([LAT,LNG],15);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OpenStreetMap'}).addTo(m);
|
||||
L.marker([LAT,LNG],{icon:pinIcon}).addTo(m);
|
||||
setTimeout(()=>m.invalidateSize(),60);
|
||||
}
|
||||
['mapD','mapM'].forEach(makePreview);
|
||||
document.querySelectorAll('[data-open]').forEach(el=>el.addEventListener('click',e=>{e.preventDefault();alert('→ opens the full interactive map');}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* LeafletHeroMapInner
|
||||
* Minimal single-school map for the detail hero. Renders as a static preview
|
||||
* (all interaction disabled so it never traps page scroll); when `interactive`
|
||||
* flips true — i.e. the hero map has gone fullscreen — pan/zoom are enabled and
|
||||
* a zoom control appears.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface LeafletHeroMapInnerProps {
|
||||
lat: number;
|
||||
lng: number;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
// Coral teardrop pin with a soft halo — inline styles only, so it renders
|
||||
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
|
||||
const PIN_HTML = `
|
||||
<div style="position:relative;width:26px;height:26px">
|
||||
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
|
||||
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
|
||||
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
|
||||
<circle cx="13" cy="13" r="5" fill="#fff"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
export default function LeafletHeroMapInner({ lat, lng, interactive }: LeafletHeroMapInnerProps) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||||
|
||||
// Create the map once.
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return;
|
||||
const map = L.map(elRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
touchZoom: false,
|
||||
}).setView([lat, lng], 15);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
L.marker([lat, lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [26, 34], iconAnchor: [13, 30], html: PIN_HTML }),
|
||||
keyboard: false,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
setTimeout(() => map.invalidateSize(), 60);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
zoomCtrlRef.current = null;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
|
||||
// Toggle interaction (and the zoom control) when entering/leaving fullscreen,
|
||||
// and re-measure since the container size changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const handlers = [
|
||||
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||||
];
|
||||
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||||
|
||||
if (interactive && !zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||||
zoomCtrlRef.current.addTo(map);
|
||||
} else if (!interactive && zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current.remove();
|
||||
zoomCtrlRef.current = null;
|
||||
}
|
||||
|
||||
if (!interactive) map.setView([lat, lng], 15);
|
||||
setTimeout(() => map.invalidateSize(), 80);
|
||||
}, [interactive, lat, lng]);
|
||||
|
||||
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
@@ -29,12 +29,15 @@
|
||||
|
||||
/* Header Section */
|
||||
.header {
|
||||
position: relative;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
@@ -42,6 +45,55 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
/* With a map band above, slide the title up under the fade so map and title
|
||||
read as one object; the Compare button floats glassy over the map. */
|
||||
.headerHasMap .headerContent {
|
||||
padding-top: 0;
|
||||
margin-top: -0.5rem;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.headerHasMap .actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerHasMap .actions .btnAdd {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--accent-coral, #e07256);
|
||||
border-color: transparent;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.headerHasMap .actions .btnAdd:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Inline "View on map ↗" trigger next to the address. */
|
||||
.mapLink {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral, #e07256);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapLink:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
@@ -841,18 +893,6 @@
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.mapContainer {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
isolation: isolate;
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* History Table */
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -1179,10 +1219,6 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -1218,75 +1254,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero tone scheme — colour tokens applied to the scorecard's serif Ofsted
|
||||
number (colour only) without bleeding into the .ofstedGrade{N} badges. */
|
||||
.tone-teal {
|
||||
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.tone-green {
|
||||
--hero-tone: #3c8c3c;
|
||||
}
|
||||
.tone-gold {
|
||||
--hero-tone: var(--accent-gold, #c9a227);
|
||||
}
|
||||
.tone-coral {
|
||||
--hero-tone: var(--accent-coral, #e07256);
|
||||
}
|
||||
.tone-neutral {
|
||||
--hero-tone: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
/* ── Hero at-a-glance stats (A3) ─────────────────────────────────────── */
|
||||
.heroStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem 3rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.heroStat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.heroStatNumber,
|
||||
.heroStatNumberSerif {
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary, #1a1612);
|
||||
/* Fixed height so every stat's label row sits at the same Y, regardless
|
||||
of whether the content is a short numeral or a longer word. Each
|
||||
stat's content is bottom-aligned within this box. */
|
||||
min-height: clamp(2rem, 4vw, 2.75rem);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.heroStatNumber {
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.heroStatNumberSerif {
|
||||
/* Slightly smaller so long words like "Requires Improvement" still fit,
|
||||
but aligned on the same bottom baseline as the numeric stats. */
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||
}
|
||||
|
||||
.heroStatNumberSerif.tone-teal,
|
||||
.heroStatNumberSerif.tone-green,
|
||||
.heroStatNumberSerif.tone-gold,
|
||||
.heroStatNumberSerif.tone-coral,
|
||||
.heroStatNumberSerif.tone-neutral {
|
||||
color: var(--hero-tone);
|
||||
}
|
||||
|
||||
/* .heroStatLabel is shared by the SATs / Pupils stat cards below. */
|
||||
.heroStatLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
@@ -1295,22 +1263,7 @@
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
|
||||
.heroStatFoot {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.heroDataNote {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.heroStats {
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroSummary {
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData,
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
formatPercentage, formatProgress, formatAcademicYear,
|
||||
buildOfstedHeroChip,
|
||||
} from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
|
||||
@@ -93,6 +92,8 @@ export function SchoolDetailView({
|
||||
// Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves.
|
||||
const heroActionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [heroCtaVisible, setHeroCtaVisible] = useState(true);
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
// "All ▾" jump menu listing every section.
|
||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||
|
||||
@@ -236,7 +237,6 @@ export function SchoolDetailView({
|
||||
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
|
||||
navItems.push({ id: 'parents', label: 'Parents' });
|
||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||
if (hasLocation) navItems.push({ id: 'location', label: 'Location' });
|
||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
|
||||
@@ -285,26 +285,6 @@ export function SchoolDetailView({
|
||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
||||
})();
|
||||
|
||||
// ── Hero: framework-aware signal chip + narrative summary ─────────────
|
||||
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
|
||||
|
||||
// KS2 headline numbers for the at-a-glance row
|
||||
const heroRwm = isPrimary ? latestResults?.rwm_expected_pct ?? null : null;
|
||||
const heroRwmNat = primaryAvg.rwm_expected_pct ?? null;
|
||||
|
||||
// KS4 headline number for secondary/all-through schools
|
||||
const heroAtt8 = isSecondary ? latestResults?.attainment_8_score ?? null : null;
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
|
||||
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
|
||||
|
||||
// Scorecard renders if any tile has content, so a results-less school still
|
||||
// shows its Ofsted signal (previously carried by the now-removed chip strip).
|
||||
const hasHeroStats = heroRwm != null
|
||||
|| heroAtt8 != null
|
||||
|| ofsted != null
|
||||
|| admissions?.first_preference_offer_pct != null;
|
||||
|
||||
// Label shown in the mobile "section" menu button — the section in view.
|
||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||
|
||||
@@ -317,8 +297,11 @@ export function SchoolDetailView({
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<header className={styles.header}>
|
||||
{/* Header — the location map band blends down into the school title. */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
@@ -336,6 +319,18 @@ export function SchoolDetailView({
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mapLink}
|
||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||
>
|
||||
View on map ↗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.headerDetails}>
|
||||
@@ -383,69 +378,6 @@ export function SchoolDetailView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* At-a-glance stats row — the single home for the headline numbers.
|
||||
Shows whenever any tile has content (results, Ofsted, or admissions),
|
||||
so schools without KS2/KS4 results still carry their Ofsted signal. */}
|
||||
{hasHeroStats && (
|
||||
<div className={styles.heroStats}>
|
||||
{isPrimary && heroRwm != null && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={styles.heroStatNumber}>{Math.round(heroRwm)}%</div>
|
||||
<div className={styles.heroStatLabel}>Reading, Writing & Maths</div>
|
||||
{heroRwmNat != null && (
|
||||
<DeltaChip value={heroRwm} baseline={heroRwmNat} unit="pts" suffix="vs national" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSecondary && heroAtt8 != null && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
|
||||
<div className={styles.heroStatLabel}>Attainment 8 score</div>
|
||||
{heroAtt8Nat != null && (
|
||||
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ofsted && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={`${styles.heroStatNumberSerif} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||||
{ofstedHeroChip.state === 'oeif'
|
||||
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
|
||||
: ofstedHeroChip.state === 'reportCard'
|
||||
? 'Report Card'
|
||||
: '—'}
|
||||
</div>
|
||||
<div className={styles.heroStatLabel}>
|
||||
{ofstedHeroChip.subtitle}
|
||||
</div>
|
||||
{ofstedHeroChip.detail && (
|
||||
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{admissions?.first_preference_offer_pct != null && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={styles.heroStatNumber}>
|
||||
{Math.round(admissions.first_preference_offer_pct)}%
|
||||
</div>
|
||||
<div className={styles.heroStatLabel}>First-choice offer rate</div>
|
||||
{admissions.oversubscribed && (
|
||||
<div className={styles.heroStatFoot}>Oversubscribed</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heroAcademicYear && (
|
||||
<p className={styles.heroDataNote}>
|
||||
Latest data: {heroAcademicYear}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sticky Section Navigation — docks under the global header */}
|
||||
@@ -1266,20 +1198,6 @@ export function SchoolDetailView({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{hasLocation && (
|
||||
<section id="location" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Location</h2>
|
||||
<div className={styles.mapContainer}>
|
||||
<SchoolMap
|
||||
schools={[schoolInfo]}
|
||||
center={[schoolInfo.latitude!, schoolInfo.longitude!]}
|
||||
zoom={15}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Local Area Context */}
|
||||
{hasDeprivation && deprivation && (
|
||||
<section id="local-area" className={styles.card}>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* Preview band that blends into the hero title; expands to fullscreen. */
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
background: #dfe6e2;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrapper {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen: the Fullscreen API promotes this element to fill the viewport. */
|
||||
.wrapper[data-fullscreen] {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Full-band click target. Transparent so the map shows through; reveals a
|
||||
hint pill on hover/focus. Sits above the map but below the header's fade
|
||||
and Compare button (which carry higher stacking in the header). */
|
||||
.openBtn {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.openHint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.openBtn:hover .openHint,
|
||||
.openBtn:focus-visible .openHint {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Diffuse fade: map dissolves to the card background before the title. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 33%;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
var(--bg-card, #fff) 100%);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--text-primary, #1a1612);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: #fff;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* SchoolHeroMap
|
||||
* The location map that sits atop the school-detail hero. Shows a static
|
||||
* preview (pin + tiles) that blends down into the title; the whole band — or
|
||||
* the parent's "View on map" link, via the imperative `open()` handle — expands
|
||||
* it to a fullscreen, interactive map.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import styles from './SchoolHeroMap.module.css';
|
||||
|
||||
const LeafletHeroMap = dynamic(() => import('./LeafletHeroMapInner'), {
|
||||
ssr: false,
|
||||
loading: () => <div className={styles.skeleton} aria-hidden="true" />,
|
||||
});
|
||||
|
||||
export interface SchoolHeroMapHandle {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
interface SchoolHeroMapProps {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
|
||||
function SchoolHeroMap({ lat, lng }, ref) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const open = useCallback(() => {
|
||||
wrapperRef.current?.requestFullscreen?.().catch(() => {});
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ open }), [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
|
||||
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
|
||||
|
||||
{isFullscreen ? (
|
||||
<button type="button" className={styles.closeBtn} onClick={close} aria-label="Close map">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Whole-band click target that opens the full map. */}
|
||||
<button type="button" className={styles.openBtn} onClick={open} aria-label="Open full map">
|
||||
<span className={styles.openHint}>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</svg>
|
||||
Open full map
|
||||
</span>
|
||||
</button>
|
||||
{/* Diffuse blend into the hero body below. */}
|
||||
<div className={styles.fade} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -29,12 +29,15 @@
|
||||
|
||||
/* ── Header ──────────────────────────────────────────── */
|
||||
.header {
|
||||
position: relative;
|
||||
background: var(--bg-card, white);
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* Padding lives on .headerContent so the map band can bleed to the edges. */
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
@@ -42,6 +45,55 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
/* With a map band above, slide the title up under the fade so map and title
|
||||
read as one object; the Compare button floats glassy over the map. */
|
||||
.headerHasMap .headerContent {
|
||||
padding-top: 0;
|
||||
margin-top: -0.5rem;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.headerHasMap .actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerHasMap .actions .btnAdd {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--accent-coral, #e07256);
|
||||
border-color: transparent;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.headerHasMap .actions .btnAdd:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Inline "View on map ↗" trigger next to the address. */
|
||||
.mapLink {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral, #e07256);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapLink:hover {
|
||||
color: var(--accent-coral-dark, #c45a3f);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
@@ -414,13 +466,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── History table ───────────────────────────────────── */
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -725,70 +770,7 @@
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
/* Hero tone system — colour tokens applied to the scorecard's serif Ofsted
|
||||
number (colour only), independent of the .ofstedGrade{N} badges. */
|
||||
.tone-teal {
|
||||
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||
}
|
||||
.tone-green {
|
||||
--hero-tone: #3c8c3c;
|
||||
}
|
||||
.tone-gold {
|
||||
--hero-tone: var(--accent-gold, #c9a227);
|
||||
}
|
||||
.tone-coral {
|
||||
--hero-tone: var(--accent-coral, #e07256);
|
||||
}
|
||||
.tone-neutral {
|
||||
--hero-tone: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
/* ── Hero at-a-glance stats ──────────────────────────── */
|
||||
.heroStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem 3rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.heroStat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.heroStatNumber,
|
||||
.heroStatNumberSerif {
|
||||
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary, #1a1612);
|
||||
min-height: clamp(2rem, 4vw, 2.75rem);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.heroStatNumber {
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.heroStatNumberSerif {
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||
}
|
||||
|
||||
.heroStatNumberSerif.tone-teal,
|
||||
.heroStatNumberSerif.tone-green,
|
||||
.heroStatNumberSerif.tone-gold,
|
||||
.heroStatNumberSerif.tone-coral,
|
||||
.heroStatNumberSerif.tone-neutral {
|
||||
color: var(--hero-tone);
|
||||
}
|
||||
|
||||
/* .heroStatLabel is shared by the GCSE / Pupils stat cards below. */
|
||||
.heroStatLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
@@ -797,17 +779,6 @@
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
|
||||
.heroStatFoot {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.heroDataNote {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
/* ── GCSE hero stat cards (mirrors primary heroStatCard) ─ */
|
||||
.heroStatGrid {
|
||||
display: grid;
|
||||
@@ -1138,10 +1109,6 @@
|
||||
.admissionsTypeBadge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.heroStats {
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { MetricTooltip } from './MetricTooltip';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
@@ -79,6 +79,8 @@ export function SecondarySchoolDetailView({
|
||||
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||
}: SecondarySchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
// Hero map — the "View on map" link opens its fullscreen view.
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
@@ -159,7 +161,6 @@ export function SecondarySchoolDetailView({
|
||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
|
||||
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||
if (hasLocation) navItems.push({ id: 'location', label: 'Location' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
|
||||
// Track active section as user scrolls
|
||||
@@ -200,17 +201,8 @@ export function SecondarySchoolDetailView({
|
||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
||||
})();
|
||||
|
||||
// ── Hero signal chip & stats ─────────────────────────────────────────
|
||||
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
|
||||
const heroAtt8 = latestResults?.attainment_8_score ?? null;
|
||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
|
||||
|
||||
// Scorecard renders if any tile has content, so a results-less school still
|
||||
// shows its Ofsted signal (previously carried by the now-removed chip strip).
|
||||
const hasHeroStats = heroAtt8 != null
|
||||
|| ofsted != null
|
||||
|| admissions?.first_preference_offer_pct != null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -220,8 +212,11 @@ export function SecondarySchoolDetailView({
|
||||
<span aria-hidden="true">←</span> Back
|
||||
</button>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────── */}
|
||||
<header className={styles.header}>
|
||||
{/* ── Header — the location map band blends into the school title ── */}
|
||||
<header className={`${styles.header}${hasLocation ? ` ${styles.headerHasMap}` : ''}`}>
|
||||
{hasLocation && (
|
||||
<SchoolHeroMap ref={heroMapRef} lat={schoolInfo.latitude!} lng={schoolInfo.longitude!} />
|
||||
)}
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
@@ -247,6 +242,18 @@ export function SecondarySchoolDetailView({
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mapLink}
|
||||
onClick={() => { heroMapRef.current?.open(); track('section_nav_used', { section: 'location', via: 'hero_link' }); }}
|
||||
>
|
||||
View on map ↗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.headerDetails}>
|
||||
@@ -290,55 +297,6 @@ export function SecondarySchoolDetailView({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* At-a-glance stats row — the single home for the headline numbers.
|
||||
Shows whenever any tile has content (results, Ofsted, or admissions),
|
||||
so schools without KS4 results still carry their Ofsted signal. */}
|
||||
{hasHeroStats && (
|
||||
<div className={styles.heroStats}>
|
||||
{heroAtt8 != null && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
|
||||
<div className={styles.heroStatLabel}>Attainment 8 score</div>
|
||||
{heroAtt8Nat != null && (
|
||||
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ofsted && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={`${styles.heroStatNumberSerif} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||||
{ofstedHeroChip.state === 'oeif'
|
||||
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
|
||||
: ofstedHeroChip.state === 'reportCard'
|
||||
? 'Report Card'
|
||||
: '—'}
|
||||
</div>
|
||||
<div className={styles.heroStatLabel}>{ofstedHeroChip.subtitle}</div>
|
||||
{ofstedHeroChip.detail && (
|
||||
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{admissions?.first_preference_offer_pct != null && (
|
||||
<div className={styles.heroStat}>
|
||||
<div className={styles.heroStatNumber}>
|
||||
{Math.round(admissions.first_preference_offer_pct)}%
|
||||
</div>
|
||||
<div className={styles.heroStatLabel}>First-choice offer rate</div>
|
||||
{admissions.oversubscribed && (
|
||||
<div className={styles.heroStatFoot}>Oversubscribed</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heroAcademicYear && (
|
||||
<p className={styles.heroDataNote}>Latest data: {heroAcademicYear}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── Sticky section navigation ─────────────────────── */}
|
||||
@@ -943,20 +901,6 @@ export function SecondarySchoolDetailView({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Location ───────────────────────────────────── */}
|
||||
{hasLocation && (
|
||||
<section id="location" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Location</h2>
|
||||
<div className={styles.mapContainer}>
|
||||
<SchoolMap
|
||||
schools={[schoolInfo]}
|
||||
center={[schoolInfo.latitude!, schoolInfo.longitude!]}
|
||||
zoom={15}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Finances ───────────────────────────────────── */}
|
||||
{hasFinance && finance && (
|
||||
<section id="finances" className={styles.card}>
|
||||
|
||||
Reference in New Issue
Block a user