212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { Reveal } from "@/components/reveal";
|
|
|
|
export type RoadmapStatus = "done" | "in-progress" | "next" | "planned";
|
|
|
|
export type RoadmapItem = {
|
|
title: string;
|
|
description?: string;
|
|
status: RoadmapStatus;
|
|
date?: string;
|
|
bullets?: string[];
|
|
};
|
|
|
|
function statusLabel(status: RoadmapStatus) {
|
|
switch (status) {
|
|
case "done":
|
|
return "Shipped";
|
|
case "in-progress":
|
|
return "In progress";
|
|
case "next":
|
|
return "Next up";
|
|
case "planned":
|
|
return "Planned";
|
|
}
|
|
}
|
|
|
|
function statusWeight(status: RoadmapStatus) {
|
|
// Progress-style weighting:
|
|
// done = 1.0, in-progress = 0.6, next = 0.25, planned = 0.0
|
|
switch (status) {
|
|
case "done":
|
|
return 1.0;
|
|
case "in-progress":
|
|
return 0.6;
|
|
case "next":
|
|
return 0.25;
|
|
case "planned":
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
function statusDotClass(status: RoadmapStatus) {
|
|
// Keep neutral, glassy, specular (no hard colors).
|
|
// Use opacity + ring + subtle fill to communicate state.
|
|
switch (status) {
|
|
case "done":
|
|
return "bg-white/80 ring-1 ring-white/40 shadow-sm";
|
|
case "in-progress":
|
|
return "bg-white/60 ring-1 ring-white/30 shadow-sm";
|
|
case "next":
|
|
return "bg-white/35 ring-1 ring-white/25";
|
|
case "planned":
|
|
return "bg-white/20 ring-1 ring-white/20";
|
|
}
|
|
}
|
|
|
|
function statusPillClass(status: RoadmapStatus) {
|
|
switch (status) {
|
|
case "done":
|
|
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/70";
|
|
case "in-progress":
|
|
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/70";
|
|
case "next":
|
|
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/60";
|
|
case "planned":
|
|
return "glass px-2.5 py-1 text-[11px] tracking-wide text-black/55";
|
|
}
|
|
}
|
|
|
|
export function RoadmapTimeline({
|
|
title = "Roadmap",
|
|
subtitle = "Build plan and delivery cadence for this system.",
|
|
items,
|
|
}: {
|
|
title?: string;
|
|
subtitle?: string;
|
|
items: RoadmapItem[];
|
|
}) {
|
|
const total = Math.max(items.length, 1);
|
|
const progress =
|
|
Math.min(
|
|
1,
|
|
Math.max(
|
|
0,
|
|
items.reduce((acc, it) => acc + statusWeight(it.status), 0) / total
|
|
)
|
|
) || 0;
|
|
|
|
const progressPct = Math.round(progress * 100);
|
|
|
|
return (
|
|
<section className="relative overflow-hidden">
|
|
<div className="glass-strong noise-overlay rounded-3xl p-6 sm:p-8 shadow-sm">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
|
{title}
|
|
</h2>
|
|
<p className="mt-1 text-sm sm:text-[15px] text-black/60">
|
|
{subtitle}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="shrink-0 text-right">
|
|
<div className="text-[11px] uppercase tracking-[0.18em] text-black/45">
|
|
Progress
|
|
</div>
|
|
<div className="mt-1 text-lg font-semibold tabular-nums">
|
|
{progressPct}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3">
|
|
<div className="glass rounded-2xl p-3">
|
|
<div className="flex items-center justify-between text-[12px] text-black/55">
|
|
<span>Planned → Shipped</span>
|
|
<span className="tabular-nums">{progressPct}%</span>
|
|
</div>
|
|
|
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-black/[0.06]">
|
|
<motion.div
|
|
className="h-full rounded-full bg-white/70"
|
|
initial={{ width: 0 }}
|
|
whileInView={{ width: `${progressPct}%` }}
|
|
viewport={{ once: true, margin: "-20% 0px -20% 0px" }}
|
|
transition={{ type: "spring", stiffness: 80, damping: 18 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 sm:mt-8">
|
|
<div className="relative">
|
|
{/* Timeline spine */}
|
|
<div className="pointer-events-none absolute left-[11px] top-1 bottom-1 w-px bg-black/10" />
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{items.map((it, idx) => (
|
|
<Reveal key={`${it.title}-${idx}`}>
|
|
<div className="relative pl-10">
|
|
<div
|
|
className={[
|
|
"absolute left-[2px] top-[10px] h-[18px] w-[18px] rounded-full",
|
|
statusDotClass(it.status),
|
|
].join(" ")}
|
|
/>
|
|
|
|
<div className="glass rounded-3xl p-4 sm:p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-[15px] sm:text-base font-semibold tracking-tight">
|
|
{it.title}
|
|
</h3>
|
|
<span
|
|
className={[
|
|
"rounded-full",
|
|
statusPillClass(it.status),
|
|
].join(" ")}
|
|
>
|
|
{statusLabel(it.status)}
|
|
</span>
|
|
</div>
|
|
|
|
{(it.date || it.description) && (
|
|
<div className="mt-1 text-sm text-black/60">
|
|
{it.date ? (
|
|
<span className="tabular-nums">{it.date}</span>
|
|
) : null}
|
|
{it.date && it.description ? (
|
|
<span className="mx-2 text-black/25">•</span>
|
|
) : null}
|
|
{it.description ? <span>{it.description}</span> : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 text-[12px] text-black/45 tabular-nums">
|
|
{String(idx + 1).padStart(2, "0")}/{String(items.length).padStart(2, "0")}
|
|
</div>
|
|
</div>
|
|
|
|
{it.bullets?.length ? (
|
|
<ul className="mt-3 grid gap-2 text-sm text-black/70">
|
|
{it.bullets.map((b, bi) => (
|
|
<li key={`${it.title}-b-${bi}`} className="flex gap-2">
|
|
<span className="mt-[7px] h-1.5 w-1.5 rounded-full bg-black/25" />
|
|
<span className="min-w-0">{b}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Specular highlight */}
|
|
<div className="pointer-events-none specular absolute -top-24 -right-24 h-64 w-64 rounded-full opacity-50 blur-2xl" />
|
|
</div>
|
|
</section>
|
|
);
|
|
} |