feat: redesign landing page with brand system and section blocks
Build & Push Docker Image / build (push) Has been cancelled
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:
@@ -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:
|
||||
"نسخه نهایی پروژه را همراه آموزش و پشتیبانی اولیه به کارفرما تحویل میدهیم.",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user