/** * 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, SchoolCensus } from '@/lib/types'; import { formatAgeRange, isProposedToClose } from '@/lib/utils'; import type { NavItem } from '@/lib/schoolSections'; import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SchoolDetailShell.module.css'; /** * Only what the chrome itself renders. Everything the sections need — Ofsted, * admissions, deprivation, finance, national averages — goes straight to the * section composers in page.tsx and never reaches the client. */ export interface SchoolDetailShellProps { schoolInfo: School; /** Only for the header's pupil-count fallback. */ yearlyData: SchoolResult[]; census: SchoolCensus | null; /** Section list for the sticky nav, computed on the server. */ navItems: NavItem[]; /** The server-rendered sections. */ children: ReactNode; } export function SchoolDetailShell({ schoolInfo, yearlyData, census, 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]); // The chrome needs only these four. The section-shape flags are computed // once on the server (lib/schoolSections) and consumed by the section // composers; recomputing them here would duplicate that work for values // this component never renders. const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const phase = schoolInfo.phase ?? ''; const isAllThrough = phase.toLowerCase() === 'all-through'; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; 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]); // 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(',')]); // 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}
); }