Files
aragrp/components/roadmap/roadmap-section.tsx
T
ar.badieiandCursor c2376b3b9e
Build & Push Docker Image / build (push) Has been cancelled
feat: redesign landing page with brand system and section blocks
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:00:55 +03:30

206 lines
6.7 KiB
TypeScript

"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>
);
}