'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { EASE, useReducedMotion } from '@/lib/motion'; interface LandingScrollStageProps { hero: ReactNode; below: ReactNode; } type Panel = 0 | 1; const TRANSITION_MS = 700; // Trackpads emit a stream of tiny wheel deltas; ignore sub-pixel jitter so a // resting finger never triggers a panel change. const WHEEL_MIN_DELTA = 6; // Minimum vertical swipe (px) before a touch gesture flips the panel. const TOUCH_THRESHOLD = 36; const EASE_CSS = `cubic-bezier(${EASE.inOut.join(',')})`; const DOWN_KEYS = new Set(['ArrowDown', 'PageDown', ' ', 'Spacebar']); const UP_KEYS = new Set(['ArrowUp', 'PageUp']); /** * Two-panel scroll stage for the landing page. The hero owns the first * viewport; a single wheel / touch / key gesture slide-and-fades down to the * second panel (features + metrics + footer), which then scrolls on its own. * Scrolling back to the top of the second panel and pushing up returns to the * hero. * * The native document never scrolls -- the outer box is clipped and the two * stacked panels ride a transformed track -- so every transition is fully * controlled. Because the slide is a CSS transition, the browser re-runs its * intersection observations each frame, so the `whileInView` reveals inside the * second panel still fire as it arrives. Under reduced motion the same snap * happens instantly, with no travel and no fade. */ export function LandingScrollStage({ hero, below }: LandingScrollStageProps) { const [panel, setPanel] = useState(0); const panelRef = useRef(0); const lockRef = useRef(false); const belowRef = useRef(null); const reduced = useReducedMotion(); const go = useCallback( (next: Panel) => { if (lockRef.current || panelRef.current === next) return; lockRef.current = true; panelRef.current = next; setPanel(next); window.setTimeout( () => { lockRef.current = false; }, reduced ? 0 : TRANSITION_MS, ); }, [reduced], ); useEffect(() => { const atBelowTop = () => (belowRef.current?.scrollTop ?? 0) <= 0; const onWheel = (e: WheelEvent) => { if (Math.abs(e.deltaY) < WHEEL_MIN_DELTA) return; if (panelRef.current === 0) { if (e.deltaY > 0) { e.preventDefault(); go(1); } } else if (e.deltaY < 0 && atBelowTop()) { e.preventDefault(); go(0); } }; let touchStartY = 0; const onTouchStart = (e: TouchEvent) => { touchStartY = e.touches[0]?.clientY ?? 0; }; const onTouchMove = (e: TouchEvent) => { // Positive delta = finger travelling up = intent to move down a panel. const delta = touchStartY - (e.touches[0]?.clientY ?? 0); if (panelRef.current === 0) { e.preventDefault(); // the hero has nothing to scroll natively if (delta > TOUCH_THRESHOLD) go(1); } else if (delta < -TOUCH_THRESHOLD && atBelowTop()) { e.preventDefault(); go(0); } }; const keyChunk = (key: string) => key === 'ArrowUp' || key === 'ArrowDown' ? 80 : window.innerHeight * 0.9; const onKeyDown = (e: KeyboardEvent) => { if (panelRef.current === 0) { if (DOWN_KEYS.has(e.key)) { e.preventDefault(); go(1); } return; } const el = belowRef.current; if (!el) return; if (UP_KEYS.has(e.key)) { e.preventDefault(); if (el.scrollTop <= 0) go(0); else el.scrollBy({ top: -keyChunk(e.key), behavior: 'smooth' }); } else if (DOWN_KEYS.has(e.key)) { e.preventDefault(); el.scrollBy({ top: keyChunk(e.key), behavior: 'smooth' }); } }; window.addEventListener('wheel', onWheel, { passive: false }); window.addEventListener('touchstart', onTouchStart, { passive: true }); window.addEventListener('touchmove', onTouchMove, { passive: false }); window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('touchstart', onTouchStart); window.removeEventListener('touchmove', onTouchMove); window.removeEventListener('keydown', onKeyDown); }; }, [go]); const duration = reduced ? 0 : TRANSITION_MS; const panelOpacity = (own: Panel) => (reduced ? 1 : panel === own ? 1 : 0); return (
{/* Panel 0 -- hero. */}
{hero}
{/* Panel 1 -- features + metrics + footer, self-scrolling. */}
{below}
); }