feat: redesign landing page with brand system and section blocks
Build & Push Docker Image / build (push) Has been cancelled

Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ar.badiei
2026-07-23 17:00:55 +03:30
co-authored by Cursor
parent f0c290786e
commit c2376b3b9e
352 changed files with 34442 additions and 2760 deletions
+51
View File
@@ -0,0 +1,51 @@
export type Milestone = {
id: number;
numeral: string;
title: string;
description: string;
};
export const milestones: Milestone[] = [
{
id: 1,
numeral: "۱",
title: "تماس و بررسی نیازهای کارفرما",
description:
"نیازهای کارفرما را از طریق وب‌سایت دریافت و بررسی می‌کنیم و اهداف پروژه را مشخص می‌کنیم.",
},
{
id: 2,
numeral: "۲",
title: "جلسه با کارفرما",
description:
"در جلسه اختصاصی، جزئیات پروژه، بودجه و زمان‌بندی را با کارفرما هماهنگ می‌کنیم.",
},
{
id: 3,
numeral: "۳",
title: "طراحی وایرفریم",
description:
"ساختار صفحات و تجربه کاربری را پیش از طراحی نهایی به‌صورت وایرفریم ارائه می‌دهیم.",
},
{
id: 4,
numeral: "۴",
title: "بررسی وایرفریم با کارفرما",
description:
"وایرفریم را همراه کارفرما مرور می‌کنیم و بازخوردها را برای ادامه مسیر اعمال می‌کنیم.",
},
{
id: 5,
numeral: "۵",
title: "لانچ اولیه",
description:
"نسخه اولیه وب‌سایت را مستقر می‌کنیم و عملکرد آن را پیش از تحویل نهایی تست می‌کنیم.",
},
{
id: 6,
numeral: "۶",
title: "تحویل لانچ نهایی به کارفرما",
description:
"نسخه نهایی پروژه را همراه آموزش و پشتیبانی اولیه به کارفرما تحویل می‌دهیم.",
},
];
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { milestones } from "@/components/roadmap/milestones";
import { TimelineItem } from "@/components/roadmap/timeline-item";
import { TimelineRail } from "@/components/roadmap/timeline-rail";
import {
getMilestoneSide,
} from "@/components/roadmap/timeline-config";
import { useActiveTimelineItem } from "@/components/roadmap/use-active-timeline-item";
import { Container } from "@/components/container";
import { MotionReveal } from "@/components/motion-reveal";
import { layout } from "@/lib/layout";
import { type } from "@/lib/typography";
import { cn } from "@/lib/utils";
type RoadmapSectionProps = {
className?: string;
};
/** Stacked timeline (nodes above cards) below Tailwind `lg`. */
const STACKED_RAIL_MQ = "(max-width: 1023px)";
export function RoadmapSection({ className }: RoadmapSectionProps) {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const nodeRefs = useRef<(HTMLDivElement | null)[]>([]);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
const [railMetrics, setRailMetrics] = useState({
offsetTop: 0,
trackHeight: 0,
progressHeight: 0,
});
const { activeIndex, setItemRef, selectActiveIndex } = useActiveTimelineItem(
milestones.length,
sectionRef
);
const setNodeRef = useCallback(
(index: number) => (element: HTMLDivElement | null) => {
nodeRefs.current[index] = element;
},
[]
);
const setCardRef = useCallback(
(index: number) => (element: HTMLElement | null) => {
cardRefs.current[index] = element;
},
[]
);
const updateRailMetrics = useCallback(() => {
const track = trackRef.current;
const firstNode = nodeRefs.current[0];
const lastNode = nodeRefs.current[milestones.length - 1];
const activeNode = nodeRefs.current[activeIndex];
if (!track || !firstNode || !lastNode || !activeNode) return;
const trackTop = track.getBoundingClientRect().top;
const stacked =
typeof window !== "undefined" &&
window.matchMedia(STACKED_RAIL_MQ).matches;
if (stacked) {
const firstCard = cardRefs.current[0];
const lastCard = cardRefs.current[milestones.length - 1];
const activeCard = cardRefs.current[activeIndex];
if (!firstCard || !lastCard || !activeCard) return;
// Card centers keep the rail behind end cards — never above the first
// or below the last (nodes sit above cards in the stacked layout).
const firstRect = firstCard.getBoundingClientRect();
const lastRect = lastCard.getBoundingClientRect();
const activeRect = activeCard.getBoundingClientRect();
const railStart =
firstRect.top + firstRect.height / 2 - trackTop;
const railEnd = lastRect.top + lastRect.height / 2 - trackTop;
const activeCenter =
activeRect.top + activeRect.height / 2 - trackTop;
setRailMetrics({
offsetTop: railStart,
trackHeight: Math.max(0, railEnd - railStart),
progressHeight: Math.max(0, activeCenter - railStart),
});
return;
}
const firstCenter =
firstNode.getBoundingClientRect().top + firstNode.offsetHeight / 2;
const lastCenter =
lastNode.getBoundingClientRect().top + lastNode.offsetHeight / 2;
const activeCenter =
activeNode.getBoundingClientRect().top + activeNode.offsetHeight / 2;
setRailMetrics({
offsetTop: firstCenter - trackTop,
trackHeight: Math.max(0, lastCenter - firstCenter),
progressHeight: Math.max(0, activeCenter - firstCenter),
});
}, [activeIndex]);
useLayoutEffect(() => {
updateRailMetrics();
window.addEventListener("resize", updateRailMetrics, { passive: true });
window.addEventListener("scroll", updateRailMetrics, { passive: true });
const media = window.matchMedia(STACKED_RAIL_MQ);
const onMediaChange = () => updateRailMetrics();
media.addEventListener("change", onMediaChange);
const track = trackRef.current;
const observer =
track && typeof ResizeObserver !== "undefined"
? new ResizeObserver(updateRailMetrics)
: null;
if (track) observer?.observe(track);
return () => {
window.removeEventListener("resize", updateRailMetrics);
window.removeEventListener("scroll", updateRailMetrics);
media.removeEventListener("change", onMediaChange);
observer?.disconnect();
};
}, [updateRailMetrics]);
return (
<section
ref={sectionRef}
id="roadmap"
className={cn(
"timeline-section section-shell bg-section-canvas",
layout.sectionY,
layout.scrollMargin,
className
)}
>
<Container className="section-content">
<MotionReveal>
<header className="relative mb-8 text-center md:mb-10">
<div className="local-highlight local-highlight--soft" aria-hidden />
<p
className={cn(
type.eyebrow,
"relative mb-2 text-center text-primary"
)}
>
مسیر همکاری
</p>
<h2
className={cn(
type.h2,
"relative text-center text-balance text-foreground"
)}
>
نقشه راه امین رایان
</h2>
<p
className={cn(
type["body-sm"],
"relative mx-auto mt-3 max-w-md text-center text-muted-foreground"
)}
>
هر مرحله با اسکرول یا کلیک فعال می‌شود
</p>
</header>
</MotionReveal>
{/* dir=ltr keeps physical left/right card alternation consistent */}
<div
ref={trackRef}
dir="ltr"
className="timeline-track relative mx-auto max-w-4xl"
>
<TimelineRail
offsetTop={railMetrics.offsetTop}
trackHeight={railMetrics.trackHeight}
progressHeight={railMetrics.progressHeight}
className="left-1/2"
/>
<ol className="relative z-10 m-0 list-none p-0">
{milestones.map((milestone, index) => (
<TimelineItem
key={milestone.id}
milestone={milestone}
index={index}
side={getMilestoneSide(index)}
active={activeIndex === index}
completed={index < activeIndex}
itemRef={setItemRef(index)}
nodeRef={setNodeRef(index)}
cardRef={setCardRef(index)}
onSelect={() => selectActiveIndex(index)}
/>
))}
</ol>
</div>
</Container>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
export const timelineConfig = {
/** Vertical gap between steps */
stepGap: {
mobile: "1.75rem",
tablet: "2.25rem",
desktop: "2.75rem",
},
/** Fixed card width on desktop/tablet */
cardWidth: {
tablet: "18rem",
desktop: "20.5rem",
},
/** Horizontal connector length (rail → card edge) */
connectorLength: {
mobile: "1.25rem",
tablet: "2.5rem",
desktop: "3rem",
},
/** Center rail width */
railWidth: 2,
/** Node dot size */
nodeSize: {
mobile: 12,
desktop: 14,
},
} as const;
export function getMilestoneSide(index: number): "left" | "right" {
return index % 2 === 0 ? "left" : "right";
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import type { Milestone } from "@/components/roadmap/milestones";
import { blockEase } from "@/lib/block-motion";
import { type } from "@/lib/typography";
import { cn } from "@/lib/utils";
type TimelineItemProps = {
milestone: Milestone;
index: number;
side: "left" | "right";
active: boolean;
completed: boolean;
itemRef: (element: HTMLElement | null) => void;
nodeRef: (element: HTMLDivElement | null) => void;
/** Mobile stacked card — used to clip the rail to card bounds */
cardRef: (element: HTMLElement | null) => void;
onSelect: () => void;
};
const cardSpring = {
type: "spring" as const,
stiffness: 280,
damping: 28,
mass: 0.85,
};
function TimelineConnector({
side,
active,
completed,
}: {
side: "left" | "right";
active: boolean;
completed: boolean;
}) {
const lit = active || completed;
return (
<svg
className={cn(
"timeline-connector pointer-events-none absolute top-1/2 z-[5] -translate-y-1/2",
side === "left"
? "right-1/2 hidden h-4 w-[var(--timeline-connector)] origin-right lg:block"
: "left-1/2 hidden h-4 w-[var(--timeline-connector)] origin-left lg:block"
)}
viewBox="0 0 48 16"
preserveAspectRatio="none"
aria-hidden
>
<path
d={
side === "left"
? "M 48 8 H 16 Q 8 8 8 8 L 8 8"
: "M 0 8 H 32 Q 40 8 40 8 L 40 8"
}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"text-foreground/12 transition-colors duration-500 ease-out",
lit && "text-primary/30"
)}
/>
<path
d={
side === "left"
? "M 48 8 H 18 Q 10 8 10 8"
: "M 0 8 H 30 Q 38 8 38 8"
}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={cn(
"transition-all duration-500 ease-out",
active
? "text-[color:var(--brand-highlight)] opacity-100"
: completed
? "text-primary/55 opacity-80"
: "text-primary/0 opacity-0"
)}
style={
active
? {
filter:
"drop-shadow(0 0 5px color-mix(in srgb, var(--brand-highlight) 75%, transparent))",
}
: undefined
}
/>
</svg>
);
}
function TimelineCard({
milestone,
side,
active,
onSelect,
}: {
milestone: Milestone;
side: "left" | "right";
active: boolean;
onSelect: () => void;
}) {
const cardRef = useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion();
const inView = useInView(cardRef, {
once: true,
amount: 0.35,
margin: "0px 0px -8% 0px",
});
const enterX = side === "left" ? -28 : 28;
return (
<motion.button
ref={cardRef}
type="button"
dir="rtl"
aria-current={active ? "step" : undefined}
aria-label={`${milestone.title}. ${milestone.description}`}
onClick={onSelect}
initial={false}
animate={
reduceMotion
? { opacity: 1, x: 0, y: 0, scale: 1 }
: inView
? {
opacity: 1,
x: 0,
y: active ? -4 : 0,
scale: active ? 1.02 : 1,
}
: { opacity: 0, x: enterX, y: 18, scale: 0.98 }
}
whileHover={
reduceMotion
? undefined
: {
y: active ? -6 : -3,
transition: { duration: 0.2, ease: "easeOut" },
}
}
whileTap={
reduceMotion
? undefined
: { scale: 0.985, transition: { duration: 0.15, ease: "easeOut" } }
}
transition={
reduceMotion
? { duration: 0 }
: inView
? cardSpring
: { duration: 0.5, ease: blockEase }
}
className={cn(
"timeline-card relative z-20 block w-full border-0 bg-transparent p-0 text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent lg:w-[var(--timeline-card-width)]",
active ? "timeline-card--active" : "timeline-card--idle"
)}
>
<motion.div
className="timeline-card__ambient pointer-events-none absolute start-1/2 top-1/2 -z-10 size-[120%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-glow-primary blur-3xl"
aria-hidden
initial={false}
animate={
reduceMotion
? { opacity: active ? 0.3 : 0 }
: { opacity: active ? 0.3 : 0, scale: active ? 1 : 0.85 }
}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.5, ease: blockEase }
}
/>
<div className="timeline-card__surface relative isolate overflow-hidden rounded-2xl border px-4 py-3 backdrop-blur-md sm:px-5 sm:py-3.5">
<div
className="timeline-card__sheen pointer-events-none absolute inset-0 rounded-2xl"
aria-hidden
/>
<div className="relative min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="timeline-card__step grid size-6 shrink-0 place-items-center rounded-full text-[0.8125rem] font-semibold leading-none transition-all duration-500 ease-out">
{milestone.numeral}
</span>
<h3
className={cn(
type.h4,
"timeline-card__title transition-colors duration-500 ease-out"
)}
>
{milestone.title}
</h3>
</div>
<p
className={cn(
type["body-sm"],
"timeline-card__desc leading-relaxed transition-all duration-500 ease-out"
)}
>
{milestone.description}
</p>
</div>
</div>
</motion.button>
);
}
export function TimelineItem({
milestone,
index,
side,
active,
completed,
itemRef,
nodeRef,
cardRef,
onSelect,
}: TimelineItemProps) {
return (
<li
ref={itemRef}
data-timeline-index={index}
className="timeline-item relative"
>
{/*
Mobile/tablet: stacked — centered node, full-width card over the rail.
Desktop (lg+): alternating left/right cards with horizontal connectors.
*/}
<div className="relative flex flex-col lg:grid lg:grid-cols-[1fr_auto_1fr] lg:items-center lg:gap-0">
{/* Desktop — left card */}
<div
className={cn(
"relative hidden justify-end pe-[var(--timeline-connector)] lg:flex",
side === "left" ? "lg:flex" : "lg:invisible lg:pointer-events-none"
)}
>
{side === "left" ? (
<TimelineCard
milestone={milestone}
side={side}
active={active}
onSelect={onSelect}
/>
) : null}
</div>
{/* Center node — centered above card on mobile/tablet; rail column on desktop */}
<div className="relative z-10 order-first mx-auto mb-3 flex w-8 justify-center lg:order-none lg:col-start-2 lg:row-start-1 lg:mx-0 lg:mb-0 lg:w-auto">
<div
ref={nodeRef}
className="timeline-node"
data-active={active ? "true" : "false"}
data-completed={completed ? "true" : "false"}
>
<span className="timeline-node__ring" aria-hidden />
<span className="timeline-node__core" aria-hidden />
</div>
<TimelineConnector side={side} active={active} completed={completed} />
</div>
{/* Mobile/tablet card (above rail) + desktop right card.
Ref stays on the static wrapper so motion transforms don't skew rail math. */}
<div
ref={cardRef}
className={cn(
"relative z-20 min-w-0 lg:col-start-3 lg:row-start-1 lg:flex lg:justify-start lg:ps-[var(--timeline-connector)]",
side === "right" ? "lg:block" : "lg:hidden"
)}
>
<TimelineCard
milestone={milestone}
side={side}
active={active}
onSelect={onSelect}
/>
</div>
</div>
</li>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { cn } from "@/lib/utils";
type TimelineRailProps = {
/** Distance from track top to rail start */
offsetTop: number;
/** Rail length (desktop: first→last node; stacked: first card top→last card bottom) */
trackHeight: number;
/** Progress fill within the rail (0 → trackHeight) */
progressHeight: number;
className?: string;
};
export function TimelineRail({
offsetTop,
trackHeight,
progressHeight,
className,
}: TimelineRailProps) {
const clampedTrack = Math.max(0, trackHeight);
const clampedProgress = Math.min(Math.max(0, progressHeight), clampedTrack);
return (
<div
className={cn("timeline-rail pointer-events-none absolute z-0", className)}
style={{ top: offsetTop, height: clampedTrack }}
aria-hidden
>
{/* Dim track — ends at the last node, not past the final card */}
<div className="timeline-rail__track absolute inset-0 w-[2px] -translate-x-1/2 rounded-full bg-foreground/12" />
{/* Soft progress accent */}
<div
className="timeline-rail__progress absolute top-0 w-[2px] -translate-x-1/2 rounded-full bg-gradient-to-b from-primary/35 via-primary to-[color:var(--brand-highlight)] transition-[height,box-shadow] duration-500 ease-out"
style={{
height: clampedProgress,
boxShadow:
clampedProgress > 0
? "0 0 10px color-mix(in srgb, var(--brand-primary) 40%, transparent), 0 0 22px color-mix(in srgb, var(--brand-highlight) 18%, transparent)"
: "none",
}}
/>
</div>
);
}
@@ -0,0 +1,272 @@
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
/** Min time between step changes from wheel / proximity. */
const STEP_COOLDOWN_MS = 550;
/** Accumulate trackpad deltas before treating as one step intent. */
const WHEEL_INTENT_PX = 36;
/** Section must cover this band of the viewport to capture wheel. */
const LOCK_INSET = 0.18;
/** Proximity fallback hysteresis (touch / reduced-motion). */
const HYSTERESIS_PX = 48;
/** After click, ignore proximity / wheel stepping briefly. */
const MANUAL_LOCK_MS = 700;
function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* Activates timeline steps one-at-a-time.
* While the section is in view, wheel/trackpad scroll is captured so each
* intentional scroll advances or retreats a single step (no skipping).
* Page scroll is released at the first/last step so the user can leave.
*/
export function useActiveTimelineItem(
itemCount: number,
sectionRef: RefObject<HTMLElement | null>
) {
const [activeIndex, setActiveIndex] = useState(0);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const activeIndexRef = useRef(0);
const rafRef = useRef<number | null>(null);
const cooldownUntilRef = useRef(0);
const wheelAccumRef = useRef(0);
const manualLockRef = useRef(false);
const manualLockTimeoutRef = useRef<number | null>(null);
const engagedRef = useRef(false);
const setItemRef = useCallback(
(index: number) => (element: HTMLElement | null) => {
itemRefs.current[index] = element;
},
[]
);
const scrollStepIntoView = useCallback((index: number) => {
const el = itemRefs.current[index];
if (!el) return;
el.scrollIntoView({
behavior: prefersReducedMotion() ? "auto" : "smooth",
block: "center",
});
}, []);
const goToIndex = useCallback(
(index: number, opts?: { scroll?: boolean; cooldown?: boolean }) => {
const nextIndex = Math.min(Math.max(index, 0), itemCount - 1);
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
if (opts?.scroll !== false) {
scrollStepIntoView(nextIndex);
}
if (opts?.cooldown !== false) {
cooldownUntilRef.current = performance.now() + STEP_COOLDOWN_MS;
}
},
[itemCount, scrollStepIntoView]
);
// Wheel lock — one scroll gesture → exactly one step (no skip)
useEffect(() => {
const section = sectionRef.current;
if (!section || itemCount < 1) return;
if (prefersReducedMotion()) return;
const isSectionEngaged = () => {
const rect = section.getBoundingClientRect();
const vh = window.innerHeight;
return (
rect.top < vh * (1 - LOCK_INSET) && rect.bottom > vh * LOCK_INSET
);
};
let lastScrollDir: 1 | -1 = 1;
const onScroll = () => {
const engaged = isSectionEngaged();
if (engaged && !engagedRef.current) {
// Enter at the edge step for this direction — no snap scroll, no cooldown
// so the next wheel gesture immediately advances one step (not two-phase).
const edge = lastScrollDir >= 0 ? 0 : itemCount - 1;
activeIndexRef.current = edge;
setActiveIndex(edge);
wheelAccumRef.current = 0;
cooldownUntilRef.current = 0;
}
if (!engaged) {
wheelAccumRef.current = 0;
}
engagedRef.current = engaged;
};
const onWheel = (event: WheelEvent) => {
if (manualLockRef.current) return;
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
if (event.deltaY !== 0) {
lastScrollDir = event.deltaY > 0 ? 1 : -1;
}
if (!isSectionEngaged()) {
wheelAccumRef.current = 0;
engagedRef.current = false;
return;
}
engagedRef.current = true;
const now = performance.now();
const current = activeIndexRef.current;
const goingDown = event.deltaY > 0;
// At ends, release so the page can leave the section.
if (goingDown && current >= itemCount - 1) {
wheelAccumRef.current = 0;
return;
}
if (!goingDown && current <= 0) {
wheelAccumRef.current = 0;
return;
}
// Capture scroll while mid-timeline — prevents skipping steps.
event.preventDefault();
if (now < cooldownUntilRef.current) {
wheelAccumRef.current = 0;
return;
}
wheelAccumRef.current += event.deltaY;
if (Math.abs(wheelAccumRef.current) < WHEEL_INTENT_PX) return;
const direction = wheelAccumRef.current > 0 ? 1 : -1;
wheelAccumRef.current = 0;
goToIndex(current + direction);
};
window.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
};
}, [goToIndex, itemCount, sectionRef]);
// Proximity fallback for touch, keyboard, and reduced-motion (max ±1 step)
useEffect(() => {
const section = sectionRef.current;
const elements = itemRefs.current.filter(Boolean) as HTMLElement[];
if (!section || !elements.length) return;
const pickActiveIndex = () => {
if (manualLockRef.current) return;
if (performance.now() < cooldownUntilRef.current) return;
// Wheel lock owns step changes while the section is engaged.
if (!prefersReducedMotion()) {
const rect = section.getBoundingClientRect();
const vh = window.innerHeight;
const engaged =
rect.top < vh * (1 - LOCK_INSET) && rect.bottom > vh * LOCK_INSET;
if (engaged) return;
}
const viewportCenter = window.innerHeight / 2;
const sectionRect = section.getBoundingClientRect();
const sectionVisible =
sectionRect.top < window.innerHeight * 0.92 &&
sectionRect.bottom > window.innerHeight * 0.08;
if (!sectionVisible) return;
let closestIndex = 0;
let closestDistance = Infinity;
elements.forEach((element, index) => {
const rect = element.getBoundingClientRect();
const elementCenter = rect.top + rect.height / 2;
const distance = Math.abs(elementCenter - viewportCenter);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
setActiveIndex((prev) => {
if (closestIndex === prev) return prev;
const prevElement = elements[prev];
if (!prevElement) {
activeIndexRef.current = closestIndex;
return closestIndex;
}
const prevRect = prevElement.getBoundingClientRect();
const prevCenter = prevRect.top + prevRect.height / 2;
const prevDistance = Math.abs(prevCenter - viewportCenter);
if (closestDistance > prevDistance - HYSTERESIS_PX) return prev;
let next = prev;
if (closestIndex > prev) next = prev + 1;
else if (closestIndex < prev) next = prev - 1;
activeIndexRef.current = next;
return next;
});
};
const scheduleUpdate = () => {
if (rafRef.current !== null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
pickActiveIndex();
});
};
pickActiveIndex();
window.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate, { passive: true });
return () => {
window.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
}
};
}, [itemCount, sectionRef]);
const selectActiveIndex = useCallback(
(index: number) => {
goToIndex(index, { scroll: true, cooldown: true });
manualLockRef.current = true;
if (manualLockTimeoutRef.current !== null) {
window.clearTimeout(manualLockTimeoutRef.current);
}
manualLockTimeoutRef.current = window.setTimeout(() => {
manualLockRef.current = false;
manualLockTimeoutRef.current = null;
}, MANUAL_LOCK_MS);
},
[goToIndex]
);
useEffect(() => {
return () => {
if (manualLockTimeoutRef.current !== null) {
window.clearTimeout(manualLockTimeoutRef.current);
}
};
}, []);
return { activeIndex, setItemRef, selectActiveIndex };
}