The row of grey chips under the name mixed the council, a GIAS governance term, the age range and provision, with no labels. Parents could not tell what "Wandsworth" or "Nursery" meant, and selective, faith and fees were missing or buried in the details. The header now prints one fact line (phase pill · ages · type · pupils), then flags only for what applies, in a fixed order: who can apply or what it costs (Fee-paying, Selective, Girls'/Boys' school, the religious character as recorded), then what the school offers (Nursery class, Sixth form). Flags carry no hue: they are facts, not verdicts. The council joins the address unless the address already names it. The details keep headteacher, website, phone, academy trust (hidden when it has the school's own name) and capacity; county, constituency and religious character leave the header. The proposed-to-close strip is unchanged. lib/schoolFacts holds the type label and the flags so the search rows can print the same words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
19 KiB
TypeScript
470 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, SchoolCensus } from '@/lib/types';
|
|
import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
|
|
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
|
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;
|
|
}
|
|
|
|
/** Equal ignoring case and punctuation: "TIFFIN SCHOOL" is Tiffin School. */
|
|
function sameName(a: string, b: string): boolean {
|
|
const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
return key(a) === key(b);
|
|
}
|
|
|
|
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, capacity) 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 few. 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 hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
|
|
|
// Header facts: each is one register value in a fixed slot (lib/schoolFacts).
|
|
const phasePill = getPhaseStyle(schoolInfo.phase);
|
|
// Never latestResults.total_pupils: that is the results cohort, which for a
|
|
// secondary is the GCSE year group alone.
|
|
const pupils = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
|
const facts = [
|
|
formatAgeRange(schoolInfo.age_range),
|
|
schoolTypeLabel(schoolInfo),
|
|
pupils != null ? `${pupils.toLocaleString()} pupils` : null,
|
|
].filter((fact): fact is string => !!fact);
|
|
const flags = schoolFlags(schoolInfo);
|
|
// The council, unless the address already names it.
|
|
const council = schoolInfo.local_authority
|
|
&& !(schoolInfo.address ?? '').toLowerCase().includes(schoolInfo.local_authority.toLowerCase())
|
|
? schoolInfo.local_authority
|
|
: null;
|
|
// A single-academy trust carries the school's own name, which says nothing.
|
|
const trust = schoolInfo.trust_name && !sameName(schoolInfo.trust_name, schoolInfo.school_name)
|
|
? schoolInfo.trust_name
|
|
: 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>
|
|
{(phasePill.label || facts.length > 0) && (
|
|
<div className={styles.facts}>
|
|
{phasePill.label && (
|
|
<span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
|
|
{phasePill.label}
|
|
</span>
|
|
)}
|
|
{facts.map((fact) => (
|
|
<span key={fact} className={styles.fact}>{fact}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{flags.length > 0 && (
|
|
// role="list": list-style: none drops list semantics in Safari.
|
|
<ul role="list" className={styles.flags} aria-label="Admission and provision">
|
|
{flags.map((flag) => (
|
|
<li
|
|
key={flag.label}
|
|
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
|
|
>
|
|
{flag.label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{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}`}
|
|
{council && ` · ${council}`}
|
|
{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>
|
|
)}
|
|
{schoolInfo.telephone && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Phone:</strong>{' '}
|
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
|
{schoolInfo.telephone}
|
|
</a>
|
|
</span>
|
|
)}
|
|
{trust && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Academy trust:</strong> {trust}
|
|
</span>
|
|
)}
|
|
{schoolInfo.capacity != null && (
|
|
<span className={styles.headerDetail}>
|
|
<strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
|
|
</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>
|
|
);
|
|
}
|