Files
aragrp/components/roadmap/timeline-item.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

290 lines
8.4 KiB
TypeScript

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