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>
290 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|