fact_performance's total_pupils is the cohort a year's results were measured on. For a secondary that is Year 11 alone, and the list API sent it as the card's "pupils": Burntwood showed 245 in search and 1,462 on its page. The list and place payloads now carry the register's whole-school count, and nothing when the register has none. Map popups and the compare basket read the same field. The header and the wellbeing section fell back to the same results figure when the census had no record. They now fall back to the register count. The shell took yearlyData only for that fallback, so the prop is gone and the results array no longer ships to the client for the chrome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
465 lines
19 KiB
TypeScript
465 lines
19 KiB
TypeScript
/**
|
|
* 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;
|
|
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, census, navItems, children,
|
|
}: SchoolDetailShellProps) {
|
|
const router = useRouter();
|
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
|
const isInComparison = isSelected(schoolInfo.urn);
|
|
|
|
const [activeSection, setActiveSection] = useState<string>('');
|
|
// 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<HTMLDivElement | null>(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<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);
|
|
// 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 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<string, number> = {};
|
|
|
|
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 (
|
|
<div className={styles.container}>
|
|
{/* 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). */}
|
|
<button type="button" onClick={handleBack} className={styles.topBack}>
|
|
<span aria-hidden="true">←</span> Back
|
|
</button>
|
|
|
|
{/* 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>
|
|
<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>
|
|
)}
|
|
{isAllThrough && (
|
|
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
|
)}
|
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
|
)}
|
|
{schoolInfo.age_range && (
|
|
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
|
)}
|
|
{schoolInfo.nursery_provision && (
|
|
<span className={styles.metaItem}>Nursery</span>
|
|
)}
|
|
{schoolInfo.has_sixth_form && (
|
|
<span className={styles.metaItem}>Sixth form</span>
|
|
)}
|
|
</div>
|
|
{isProposedToClose(schoolInfo) && (
|
|
<div className={styles.closingStrip} role="note">
|
|
<strong>⚠ Proposed to close.</strong> Check with the local authority before
|
|
applying.
|
|
</div>
|
|
)}
|
|
{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>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.detailsToggle}
|
|
aria-expanded={detailsOpen}
|
|
aria-controls="school-header-details"
|
|
onClick={() => setDetailsOpen((o) => !o)}
|
|
>
|
|
{detailsOpen ? 'Hide details' : 'Show all details'}
|
|
<span aria-hidden="true">{detailsOpen ? '▴' : '▾'}</span>
|
|
</button>
|
|
<div
|
|
id="school-header-details"
|
|
className={`${styles.headerDetails}${detailsOpen ? ` ${styles.headerDetailsOpen}` : ''}`}
|
|
>
|
|
{schoolInfo.headteacher_name && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
|
</span>
|
|
)}
|
|
{schoolInfo.website && (
|
|
<span className={styles.headerDetail}>
|
|
<a
|
|
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
data-umami-event="external_link_clicked"
|
|
data-umami-event-target="school_website"
|
|
>
|
|
School website ↗
|
|
</a>
|
|
</span>
|
|
)}
|
|
{(() => {
|
|
// Never latestResults.total_pupils: that is the results
|
|
// cohort, which for a secondary is the GCSE year group alone.
|
|
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
|
if (total == null) return null;
|
|
return (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Pupils:</strong> {total.toLocaleString()}
|
|
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
|
</span>
|
|
);
|
|
})()}
|
|
{schoolInfo.trust_name && (
|
|
<span className={styles.headerDetail}>
|
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
|
</span>
|
|
)}
|
|
{schoolInfo.telephone && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Phone:</strong>{' '}
|
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
|
{schoolInfo.telephone}
|
|
</a>
|
|
</span>
|
|
)}
|
|
{schoolInfo.religious_denomination && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Religious character:</strong>{' '}
|
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
|
? 'None'
|
|
: schoolInfo.religious_denomination}
|
|
</span>
|
|
)}
|
|
{schoolInfo.county && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>County:</strong> {schoolInfo.county}
|
|
</span>
|
|
)}
|
|
{schoolInfo.parliamentary_constituency && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className={styles.actions} ref={heroActionsRef}>
|
|
<button
|
|
onClick={handleComparisonToggle}
|
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
|
>
|
|
{/* On phones the map hero shows only the glyph (nav-bar style). */}
|
|
<span className={styles.btnCompareLabel}>
|
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
|
</span>
|
|
<span className={styles.btnCompareGlyph} aria-hidden="true">
|
|
{isInComparison ? '✓' : '+'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Sticky Section Navigation — docks under the global header */}
|
|
<nav
|
|
className={`${styles.sectionNav}${sectionsOpen ? ` ${styles.sectionNavSheetOpen}` : ''}`}
|
|
aria-label="Page sections"
|
|
>
|
|
<button onClick={scrollToTop} className={styles.sectionNavBack} aria-label="Back to top">
|
|
<span aria-hidden="true">↑</span>
|
|
<span className={styles.sectionNavBackLabel}>Top</span>
|
|
</button>
|
|
|
|
{/* Desktop: scrolling section links */}
|
|
<div
|
|
ref={sectionLinksRef}
|
|
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
|
>
|
|
{navItems.map(({ id, label }) => (
|
|
<a
|
|
key={id}
|
|
href={`#${id}`}
|
|
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
|
onClick={() => track('section_nav_used', { section: id })}
|
|
>
|
|
{label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
|
|
{/* Mobile: a single "section" menu button that opens the jump sheet */}
|
|
{navItems.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className={styles.sectionNavMenu}
|
|
aria-haspopup="menu"
|
|
aria-expanded={sectionsOpen}
|
|
onClick={() => setSectionsOpen((o) => !o)}
|
|
>
|
|
<span className={styles.sectionNavMenuCur}>
|
|
<span className={styles.sectionNavMenuEyebrow}>Section</span>
|
|
<span className={styles.sectionNavMenuNow}>{activeNavLabel}</span>
|
|
</span>
|
|
<span className={styles.sectionNavMenuChev} aria-hidden="true">▾</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* The hero's Compare CTA, carried in once it scrolls out of view.
|
|
Desktop shows a labelled pill; mobile a compact icon. */}
|
|
{!heroCtaVisible && (
|
|
<>
|
|
<button
|
|
onClick={handleComparisonToggle}
|
|
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
|
|
>
|
|
{isInComparison ? '✓ Comparing' : '+ Compare'}
|
|
</button>
|
|
<button
|
|
onClick={handleComparisonToggle}
|
|
className={`${styles.sectionNavCompareIcon}${isInComparison ? ` ${styles.sectionNavCompareIconIn}` : ''}`}
|
|
aria-label={isInComparison ? 'In comparison' : 'Add to compare'}
|
|
title={isInComparison ? 'In comparison' : 'Add to compare'}
|
|
>
|
|
{isInComparison ? (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="m5 12 5 5 9-11" />
|
|
</svg>
|
|
) : (
|
|
<>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
<path d="M4 7h13l-3-3" />
|
|
<path d="M20 17H7l3 3" />
|
|
</svg>
|
|
<span className={styles.sectionNavCompareBadge} aria-hidden="true">+</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Desktop: "All ▾" trigger (same sheet as the mobile section menu) */}
|
|
{navItems.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className={styles.sectionNavAll}
|
|
aria-haspopup="menu"
|
|
aria-expanded={sectionsOpen}
|
|
onClick={() => setSectionsOpen((o) => !o)}
|
|
>
|
|
All <span aria-hidden="true">▾</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Shared jump-to-section sheet (dropdown on desktop, bottom sheet on mobile) */}
|
|
{sectionsOpen && (
|
|
<>
|
|
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
|
|
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
|
|
<div className={styles.sectionsPanelHead}>Jump to section</div>
|
|
{navItems.map(({ id, label }) => (
|
|
<a
|
|
key={id}
|
|
href={`#${id}`}
|
|
role="menuitem"
|
|
className={`${styles.sectionsItem}${activeSection === id ? ` ${styles.sectionsItemActive}` : ''}`}
|
|
onClick={() => {
|
|
setSectionsOpen(false);
|
|
track('section_nav_used', { section: id, via: 'all_menu' });
|
|
}}
|
|
>
|
|
<span>{label}</span>
|
|
{activeSection === id && <span className={styles.sectionsTick} aria-hidden="true">✓</span>}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|