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>
206 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|