feat(school-detail): dock section nav, pin Back, carry Compare CTA, add All ▾ menu
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 14s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 56s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Reworks the sticky section bar on the school detail page so mobile users can perceive the page's breadth and get back to their results without hunting: - Dock the bar directly under the global header (top: 56px mobile / 64px desktop) so it's reachable immediately instead of buried below the tall hero. - Pin "← Back" so it never scrolls off; only the section links scroll, keeping the existing right-edge fade. Back uses router.back() with a /search fallback so deep-links never dead-end. - Carry the hero's "Add to Compare" CTA into the bar as a compact pill once the hero button scrolls out of view, so the conversion action survives. - Add an "All ▾" menu (dropdown on desktop, bottom sheet on mobile) listing every section with the active one ticked — full breadth without horizontal scrolling. Reuses the site's existing pill / coral / edge-fade / sheet vocabulary rather than introducing a new navigation paradigm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
This commit is contained in:
@@ -87,11 +87,27 @@ export function SchoolDetailView({
|
||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||
const sectionNavRef = useRef<HTMLElement | null>(null);
|
||||
// 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);
|
||||
// "All ▾" jump menu listing every section.
|
||||
const [sectionsOpen, setSectionsOpen] = 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');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionNavRef.current;
|
||||
const el = sectionLinksRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const overflow = el.scrollWidth - el.clientWidth;
|
||||
@@ -111,6 +127,26 @@ export function SchoolDetailView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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]);
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
// Phase detection
|
||||
@@ -314,7 +350,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.actions} ref={heroActionsRef}>
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||
@@ -412,15 +448,14 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sticky Section Navigation */}
|
||||
<nav
|
||||
ref={sectionNavRef}
|
||||
className={`${styles.sectionNav}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||
aria-label="Page sections"
|
||||
>
|
||||
<div className={styles.sectionNavInner}>
|
||||
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
||||
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
||||
{/* Sticky Section Navigation — docks under the global header */}
|
||||
<nav className={styles.sectionNav} aria-label="Page sections">
|
||||
<button onClick={handleBack} className={styles.sectionNavBack}>← Back</button>
|
||||
|
||||
<div
|
||||
ref={sectionLinksRef}
|
||||
className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||
>
|
||||
{navItems.map(({ id, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
@@ -432,6 +467,53 @@ export function SchoolDetailView({
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The hero's Compare CTA, carried in once it scrolls out of view */}
|
||||
{!heroCtaVisible && (
|
||||
<button
|
||||
onClick={handleComparisonToggle}
|
||||
className={`${styles.sectionNavCompare}${isInComparison ? ` ${styles.sectionNavCompareIn}` : ''}`}
|
||||
>
|
||||
{isInComparison ? '✓ Comparing' : '+ Compare'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{navItems.length > 0 && (
|
||||
<div className={styles.sectionNavAllWrap}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sectionNavAll}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sectionsOpen}
|
||||
onClick={() => setSectionsOpen((o) => !o)}
|
||||
>
|
||||
All <span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{sectionsOpen && (
|
||||
<>
|
||||
<div className={styles.sectionsBackdrop} onClick={() => setSectionsOpen(false)} />
|
||||
<div className={styles.sectionsPanel} role="menu" aria-label="Jump to section">
|
||||
<div className={styles.sectionsPanelHead}>On this page</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Ofsted Rating / Report Card */}
|
||||
|
||||
Reference in New Issue
Block a user