2026-08-02 21:39:21 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-08-02 21:54:59 +01:00
|
|
|
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
|
|
|
|
|
import { formatAgeRange, isProposedToClose } from '@/lib/utils';
|
|
|
|
|
import type { NavItem } from '@/lib/schoolSections';
|
2026-08-02 21:39:21 +01:00
|
|
|
import { track, getNavigationSource } from '@/lib/analytics';
|
|
|
|
|
import styles from './SchoolDetailShell.module.css';
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 21:54:59 +01:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-02 21:39:21 +01:00
|
|
|
export interface SchoolDetailShellProps {
|
|
|
|
|
schoolInfo: School;
|
2026-08-02 21:54:59 +01:00
|
|
|
/** Only for the header's pupil-count fallback. */
|
2026-08-02 21:39:21 +01:00
|
|
|
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({
|
2026-08-02 21:54:59 +01:00
|
|
|
schoolInfo, yearlyData, census, navItems, children,
|
2026-08-02 21:39:21 +01:00
|
|
|
}: 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]);
|
|
|
|
|
|
2026-08-02 21:54:59 +01:00
|
|
|
// 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;
|
2026-08-02 21:39:21 +01:00
|
|
|
const phase = schoolInfo.phase ?? '';
|
2026-08-02 21:54:59 +01:00
|
|
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
|
|
|
|
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
2026-08-02 21:39:21 +01:00
|
|
|
|
|
|
|
|
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> — this school is proposed for closure,
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
{(() => {
|
|
|
|
|
const total = census?.total_pupils ?? latestResults?.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} 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>
|
|
|
|
|
);
|
|
|
|
|
}
|