refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.
The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.
Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.
Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
"Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed
The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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<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]);
|
||||
|
||||
// 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<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(',')]);
|
||||
|
||||
// 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user