From 758f902b6659c1f6078424ccad2951f3afc1422e Mon Sep 17 00:00:00 2001 From: Tudor Date: Tue, 30 Jun 2026 22:39:48 +0100 Subject: [PATCH] =?UTF-8?q?feat(school-detail):=20dock=20section=20nav,=20?= =?UTF-8?q?pin=20Back,=20carry=20Compare=20CTA,=20add=20All=20=E2=96=BE=20?= =?UTF-8?q?menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF --- .../components/SchoolDetailView.module.css | 225 +++++++++++++++--- nextjs-app/components/SchoolDetailView.tsx | 106 ++++++++- 2 files changed, 285 insertions(+), 46 deletions(-) diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index c963585..9d74c1c 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -175,51 +175,34 @@ } /* ── Sticky Section Navigation ──────────────────────── */ +/* Docks directly under the global header; Back and "All" stay pinned while + only the section links scroll. */ .sectionNav { position: sticky; - top: 4rem; + top: 64px; /* global header height on desktop */ z-index: 10; background: var(--bg-card, white); border: 1px solid var(--border-color, #e5dfd5); border-top: none; border-radius: 0 0 10px 10px; - padding: 0.5rem 1rem; + padding: 0.5rem 0.75rem; 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); - scroll-snap-type: x proximity; - scroll-padding-inline: 1rem; -} - -.sectionNav::-webkit-scrollbar { - display: none; -} - -.sectionNavInner { - display: inline-flex; - gap: 0.375rem; + display: flex; align-items: center; + gap: 0.5rem; } -/* Right-edge fade so users see there's more to scroll to. */ @media (max-width: 640px) { .sectionNav { - -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); - mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); - padding-right: 1.75rem; - } - - /* When scrolled to the end, drop the fade so the last item isn't dimmed. */ - .sectionNav.atEnd { - -webkit-mask-image: none; - mask-image: none; + top: 56px; /* global header is shorter on mobile */ + padding: 0.4rem 0.6rem; + gap: 0.375rem; } } .sectionNavBack { + flex: none; display: inline-flex; align-items: center; padding: 0.3rem 0.625rem; @@ -232,7 +215,6 @@ cursor: pointer; white-space: nowrap; transition: all 0.15s ease; - margin-right: 0.25rem; } .sectionNavBack:hover { @@ -240,12 +222,37 @@ border-color: var(--accent-coral, #e07256); } -.sectionNavDivider { - width: 1px; - height: 1rem; - background: var(--border-color, #e5dfd5); - margin: 0 0.25rem; - flex-shrink: 0; +/* The scrolling middle: section links only. */ +.sectionNavLinks { + flex: 1; + min-width: 0; + display: flex; + 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 { @@ -287,6 +294,156 @@ 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 */ .card { background: var(--bg-card, white); diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 5ac6716..0f9b19c 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -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(null); + // Only the section links scroll horizontally; Back and "All" stay pinned. + const sectionLinksRef = useRef(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(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({ )} -
+
- {navItems.length > 0 &&