/** * SchoolDetailShell — the interactive chrome of a school detail page. * * The ONLY large client component on the route. Everything below the sticky * nav is server-rendered and arrives as `children`, composed in * app/school/[slug]/page.tsx. That indirection is required: a server component * imported by a client component becomes a client component, so the sections * cannot be imported here. * * What stays client-side is genuinely interactive: router.back(), the header * details reveal, the hero map, the compare CTA, the nav overflow fade, the * Escape-to-close jump sheet, and the scroll-spy. The scroll-spy finds * sections with document.getElementById, so it works unchanged against * server-rendered children. */ 'use client'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import { useComparison } from '@/hooks/useComparison'; import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap'; import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, } from '@/lib/utils'; import { computeSchoolFlags, type NavItem } from '@/lib/schoolSections'; import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SchoolDetailShell.module.css'; export interface SchoolDetailShellProps { schoolInfo: School; yearlyData: SchoolResult[]; absenceData: AbsenceData | null; ofsted: OfstedInspection | null; census: SchoolCensus | null; admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; /** Fetched on the server so the England-comparison deltas are in the * initial HTML; null when the endpoint is unavailable. */ nationalAvg: NationalAverages | null; /** Section list for the sticky nav, computed on the server. */ navItems: NavItem[]; /** The server-rendered sections. */ children: ReactNode; } export function SchoolDetailShell({ schoolInfo, yearlyData, absenceData, ofsted, census, admissions, admissionsHistory, deprivation, finance, nationalAvg, navItems, children, }: SchoolDetailShellProps) { const router = useRouter(); const { addSchool, removeSchool, isSelected } = useComparison(); const isInComparison = isSelected(schoolInfo.urn); const [activeSection, setActiveSection] = useState(''); // Admissions view state moved to AdmissionsViewToggle, the client island // inside the (server-rendered) admissions section. // Only the section links scroll horizontally; Back and "All" stay pinned. const sectionLinksRef = useRef(null); const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false); // Carry the "Add to Compare" CTA into the sticky bar once the hero's button leaves. const heroActionsRef = useRef(null); const [heroCtaVisible, setHeroCtaVisible] = useState(true); // Hero map — the "View on map" link opens its fullscreen view. const heroMapRef = useRef(null); // "All ▾" jump menu listing every section. const [sectionsOpen, setSectionsOpen] = useState(false); // Header details (headteacher, contact, trust, area) collapse behind a // "Show all details" link on mobile/tablet, where they're below the fold. const [detailsOpen, setDetailsOpen] = useState(false); // Back returns to wherever the user came from; deep-links (no in-app history) // fall back to search so the button never dead-ends or leaves the site. const handleBack = () => { if (typeof window !== 'undefined' && window.history.length > 1) { router.back(); } else { router.push('/search'); } }; const scrollToTop = () => { if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' }); }; useEffect(() => { const el = sectionLinksRef.current; if (!el) return; const update = () => { const overflow = el.scrollWidth - el.clientWidth; // No overflow → treat as "at end" so the fade is hidden. if (overflow <= 1) { setSectionNavAtEnd(true); return; } setSectionNavAtEnd(el.scrollLeft >= overflow - 2); }; update(); el.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); return () => { el.removeEventListener('scroll', update); window.removeEventListener('resize', update); }; }, []); // Track whether the hero's "Add to Compare" button is still on screen. useEffect(() => { const el = heroActionsRef.current; if (!el) return; const obs = new IntersectionObserver( ([entry]) => setHeroCtaVisible(entry.isIntersecting), { rootMargin: '-64px 0px 0px 0px' }, ); obs.observe(el); return () => obs.disconnect(); }, []); // Close the "All ▾" menu on Escape. useEffect(() => { if (!sectionsOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSectionsOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [sectionsOpen]); // Derived data-shape logic lives in lib/schoolSections so the server route // can compute the section list without importing this client component. const flags = computeSchoolFlags({ schoolInfo, yearlyData, absenceData, census, deprivation, finance, }); const { latestResults, isAllThrough, isSecondary, isPrimary, hasGenderSplit, hasInclusionData, hasSchoolLife, hasDeprivation, hasFinance, hasLocation, hasKS2Results, hasKS4Results, hasAnyResults, isSpecial, ks2Placeholder, suppressKs2Comparison, suppressKs4Comparison, } = flags; const phase = schoolInfo.phase ?? ''; const primaryAvg = nationalAvg?.primary ?? {}; const secondaryAvg = nationalAvg?.secondary ?? {}; const handleComparisonToggle = () => { if (isInComparison) { removeSchool(schoolInfo.urn); track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' }); } else { addSchool(schoolInfo); track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' }); } }; // Page-view event with funnel attribution. Fires once per mount. useEffect(() => { track('school_viewed', { urn: schoolInfo.urn, phase: phase || 'unknown', local_authority: schoolInfo.local_authority || 'unknown', from: getNavigationSource(), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [schoolInfo.urn]); const deprivationDesc = (decile: number) => { if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`; if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`; return `This school is in one of England's less deprived areas (decile ${decile}/10).`; }; // Track active section as user scrolls useEffect(() => { const ids = navItems.map(n => n.id); if (!ids.length) return; const observers: IntersectionObserver[] = []; const ratioMap: Record = {}; const pickActive = () => { const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0]; setActiveSection(top?.[1] > 0 ? top[0] : ''); }; ids.forEach(id => { const el = document.getElementById(id); if (!el) return; ratioMap[id] = 0; const obs = new IntersectionObserver( ([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); }, { threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' }, ); obs.observe(el); observers.push(obs); }); return () => observers.forEach(o => o.disconnect()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [navItems.map(n => n.id).join(',')]); // A report card is identified by the presence of report-card area // judgements, NOT by `framework` — the API sets `framework` to the raw // event grouping (e.g. "Schools - S5") even for report-card schools, so // the old `framework === 'ReportCard'` test never matched and report cards // were rendered as legacy ratings dated to a pre-Nov-2025 inspection. const isReportCard = !!( ofsted?.report_card && Object.keys(ofsted.report_card).length > 0 ); // A report card is dated by its own inspection (rc_inspection_date); the // legacy inspection_date belongs to an older inspection and must never // date a report card (report cards exist only from Nov 2025). const ofstedInspectedDate = isReportCard ? ofsted?.rc_inspection_date ?? null : ofsted?.inspection_date ?? null; // ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : []; const oeifAllSameGrade = !!ofsted && !isReportCard && oeifAreas.length >= 3 && oeifAreas.every((a) => a.value === ofsted.overall_effectiveness); // Label shown in the mobile "section" menu button — the section in view. const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? ''; return (
{/* Standalone back link, above the header — returns to wherever the user came from. Scrolls away with the page (the sticky bar keeps a "back to top" control in its place). */} {/* Header — the location map band blends down into the school title. */}
{hasLocation && ( )}

{schoolInfo.school_name}

{schoolInfo.local_authority && ( {schoolInfo.local_authority} )} {schoolInfo.school_type && ( {schoolInfo.school_type} )} {isAllThrough && ( All-through (primary & secondary) )} {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender}'s school )} {schoolInfo.age_range && ( {formatAgeRange(schoolInfo.age_range)} )} {schoolInfo.nursery_provision && ( Nursery )} {schoolInfo.has_sixth_form && ( Sixth form )}
{isProposedToClose(schoolInfo) && (
⚠ Proposed to close — this school is proposed for closure, check with the local authority before applying.
)} {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {hasLocation && ( <> {' · '} )}

)}
{schoolInfo.headteacher_name && ( Headteacher: {schoolInfo.headteacher_name} )} {schoolInfo.website && ( School website ↗ )} {(() => { const total = census?.total_pupils ?? latestResults?.total_pupils ?? null; if (total == null) return null; return ( Pupils: {total.toLocaleString()} {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} ); })()} {schoolInfo.trust_name && ( Part of {schoolInfo.trust_name} )} {schoolInfo.telephone && ( Phone:{' '} {schoolInfo.telephone} )} {schoolInfo.religious_denomination && ( Religious character:{' '} {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) ? 'None' : schoolInfo.religious_denomination} )} {schoolInfo.county && ( County: {schoolInfo.county} )} {schoolInfo.parliamentary_constituency && ( Constituency: {schoolInfo.parliamentary_constituency} )}
{/* Sticky Section Navigation — docks under the global header */} {children}
); }