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:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
{/* 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}` : ''}`}
|
||||
>
|
||||
<div className={styles.sectionNavInner}>
|
||||
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
||||
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
||||
{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