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

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:
Tudor
2026-06-30 22:39:48 +01:00
co-authored by Claude Opus 4.8
parent c40a6a949c
commit 758f902b66
2 changed files with 285 additions and 46 deletions
+191 -34
View File
@@ -175,51 +175,34 @@
} }
/* ── Sticky Section Navigation ──────────────────────── */ /* ── Sticky Section Navigation ──────────────────────── */
/* Docks directly under the global header; Back and "All" stay pinned while
only the section links scroll. */
.sectionNav { .sectionNav {
position: sticky; position: sticky;
top: 4rem; top: 64px; /* global header height on desktop */
z-index: 10; z-index: 10;
background: var(--bg-card, white); background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5); border: 1px solid var(--border-color, #e5dfd5);
border-top: none; border-top: none;
border-radius: 0 0 10px 10px; border-radius: 0 0 10px 10px;
padding: 0.5rem 1rem; padding: 0.5rem 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
scroll-snap-type: x proximity; display: flex;
scroll-padding-inline: 1rem;
}
.sectionNav::-webkit-scrollbar {
display: none;
}
.sectionNavInner {
display: inline-flex;
gap: 0.375rem;
align-items: center; align-items: center;
gap: 0.5rem;
} }
/* Right-edge fade so users see there's more to scroll to. */
@media (max-width: 640px) { @media (max-width: 640px) {
.sectionNav { .sectionNav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); top: 56px; /* global header is shorter on mobile */
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); padding: 0.4rem 0.6rem;
padding-right: 1.75rem; gap: 0.375rem;
}
/* When scrolled to the end, drop the fade so the last item isn't dimmed. */
.sectionNav.atEnd {
-webkit-mask-image: none;
mask-image: none;
} }
} }
.sectionNavBack { .sectionNavBack {
flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0.3rem 0.625rem; padding: 0.3rem 0.625rem;
@@ -232,7 +215,6 @@
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: all 0.15s ease; transition: all 0.15s ease;
margin-right: 0.25rem;
} }
.sectionNavBack:hover { .sectionNavBack:hover {
@@ -240,12 +222,37 @@
border-color: var(--accent-coral, #e07256); border-color: var(--accent-coral, #e07256);
} }
.sectionNavDivider { /* The scrolling middle: section links only. */
width: 1px; .sectionNavLinks {
height: 1rem; flex: 1;
background: var(--border-color, #e5dfd5); min-width: 0;
margin: 0 0.25rem; display: flex;
flex-shrink: 0; align-items: center;
gap: 0.375rem;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scroll-snap-type: x proximity;
scroll-padding-inline: 0.5rem;
}
.sectionNavLinks::-webkit-scrollbar {
display: none;
}
/* Right-edge fade so users see there's more to scroll to. */
@media (max-width: 640px) {
.sectionNavLinks {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
}
/* When scrolled to the end, drop the fade so the last item isn't dimmed. */
.sectionNavLinks.atEnd {
-webkit-mask-image: none;
mask-image: none;
}
} }
.sectionNavLink { .sectionNavLink {
@@ -287,6 +294,156 @@
color: white; color: white;
} }
/* Compare CTA carried into the bar once the hero's button scrolls away. */
.sectionNavCompare {
flex: none;
display: inline-flex;
align-items: center;
padding: 0.34rem 0.7rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral, #e07256);
border: 1px solid var(--accent-coral, #e07256);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sectionNavCompare:hover {
background: var(--accent-coral-dark, #c45a3f);
border-color: var(--accent-coral-dark, #c45a3f);
}
.sectionNavCompareIn {
background: var(--bg-card, white);
color: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
}
.sectionNavCompareIn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-teal, #2d7d7d);
}
@media (max-width: 640px) {
.sectionNavCompare {
min-height: 36px;
}
}
/* "All ▾" jump menu. */
.sectionNavAllWrap {
flex: none;
position: relative;
}
.sectionNavAll {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.34rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease;
}
.sectionNavAll:hover {
background: var(--border-color, #e5dfd5);
}
@media (max-width: 640px) {
.sectionNavAll {
min-height: 36px;
}
}
.sectionsBackdrop {
position: fixed;
inset: 0;
z-index: 1500;
background: rgba(26, 22, 18, 0.28);
}
.sectionsPanel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1600;
width: 230px;
max-height: min(70vh, 460px);
overflow-y: auto;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
padding: 0.35rem;
}
.sectionsPanelHead {
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
padding: 0.4rem 0.6rem 0.5rem;
}
.sectionsItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
text-decoration: none;
transition: background 0.12s ease;
}
.sectionsItem:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
}
.sectionsItemActive {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral-dark, #c45a3f);
font-weight: 600;
}
.sectionsTick {
color: var(--accent-coral, #e07256);
}
/* On phones the menu becomes a bottom sheet. */
@media (max-width: 640px) {
.sectionsPanel {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
max-height: 74vh;
border-radius: 16px 16px 0 0;
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0));
box-shadow: 0 -10px 40px rgba(26, 22, 18, 0.25);
}
.sectionsItem {
padding: 0.7rem 0.6rem;
font-size: 0.9rem;
}
}
/* Unified card for all content sections */ /* Unified card for all content sections */
.card { .card {
background: var(--bg-card, white); background: var(--bg-card, white);
+94 -12
View File
@@ -87,11 +87,27 @@ export function SchoolDetailView({
// Trend toggle only appears with ≥2 years carrying an offer rate. // Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2; 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); 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(() => { useEffect(() => {
const el = sectionNavRef.current; const el = sectionLinksRef.current;
if (!el) return; if (!el) return;
const update = () => { const update = () => {
const overflow = el.scrollWidth - el.clientWidth; 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; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection // Phase detection
@@ -314,7 +350,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions} ref={heroActionsRef}>
<button <button
onClick={handleComparisonToggle} onClick={handleComparisonToggle}
className={isInComparison ? styles.btnRemove : styles.btnAdd} className={isInComparison ? styles.btnRemove : styles.btnAdd}
@@ -412,15 +448,14 @@ export function SchoolDetailView({
)} )}
</header> </header>
{/* Sticky Section Navigation */} {/* Sticky Section Navigation — docks under the global header */}
<nav <nav className={styles.sectionNav} aria-label="Page sections">
ref={sectionNavRef} <button onClick={handleBack} className={styles.sectionNavBack}> Back</button>
className={`${styles.sectionNav}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
aria-label="Page sections" <div
> ref={sectionLinksRef}
<div className={styles.sectionNavInner}> className={`${styles.sectionNavLinks}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
<button onClick={() => router.back()} className={styles.sectionNavBack}> Back</button> >
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
{navItems.map(({ id, label }) => ( {navItems.map(({ id, label }) => (
<a <a
key={id} key={id}
@@ -432,6 +467,53 @@ export function SchoolDetailView({
</a> </a>
))} ))}
</div> </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> </nav>
{/* Ofsted Rating / Report Card */} {/* Ofsted Rating / Report Card */}