import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useMotionValueEvent,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "framer-motion";
interface ReadingProgressProps {
accent?: string;
/** Muestra los minutos de lectura que quedan. */
showTime?: boolean;
/** true: caja con scroll propio. false: usa el scroll de la página (la cabecera se queda pegada arriba). */
contained?: boolean;
}
const CHAPTERS = [
{
"title": "La commande",
"body": [
"On nous a confié une appli de réservation couverte de quatre ans de rustines. Elle marchait, mais personne ne savait vraiment pourquoi. L’objectif était clair : réserver une table devait être plus simple que d’appeler.",
"La première semaine, nous n’avons pas ouvert Figma. Nous nous sommes installés dans trois restaurants, avons noté chaque question des serveurs et chronométré le temps qu’il fallait pour trouver un simple bouton."
]
},
{
"title": "Esquisses",
"body": [
"Nous avons dessiné plus de quarante écrans au crayon. La plupart ont fini à la corbeille, et c’était exactement le but : se tromper vite et sans frais.",
"Une seule idée simple a survécu : un écran unique avec le jour, l’heure et le nombre de personnes, et un grand bouton qui disait exactement ce qui allait se passer."
]
},
{
"title": "Les détails",
"body": [
"Nous avons ajusté le rythme de chaque transition jusqu’à ce qu’elle passe inaperçue. Nous avons revu trois fois le ton des messages d’erreur pour qu’ils sonnent comme une aide, pas comme un reproche.",
"Nous avons aussi passé tout un après-midi à tester l’app d’une main, debout, dans un bus. Ce qui ne marchait pas là ne marchait nulle part."
]
},
{
"title": "Lancement",
"body": [
"Nous avons d’abord lancé dans deux villes. Dès la première semaine, les réservations finalisées ont augmenté de trente-huit pour cent et les appels aux restaurants ont diminué de moitié.",
"Le meilleur, ce ne sont pas les chiffres, mais le message d’une serveuse : « Je peux enfin poser mon téléphone et m’occuper des tables. » C’est là que nous avons considéré le projet comme terminé."
]
}
];
function Segment({
index,
title,
position,
accent,
current,
onJump,
}: {
index: number;
title: string;
position: MotionValue<number>;
accent: string;
current: boolean;
onJump: (i: number) => void;
}) {
const fill = useTransform(position, [index, index + 1], [0, 1]);
return (
<button
type="button"
onClick={() => onJump(index)}
aria-label={"Aller à «" + title + "»"}
aria-current={current ? "true" : undefined}
className="group flex-1 py-2"
>
<span className="block h-[3px] overflow-hidden rounded-full bg-[#2a251d]/10 transition-colors group-hover:bg-[#2a251d]/20 dark:bg-white/10 dark:group-hover:bg-white/20">
<motion.span className="block h-full origin-left rounded-full" style={{ scaleX: fill, background: accent }} />
</span>
</button>
);
}
export default function ReadingProgress({
accent = "#c2410c",
showTime = true,
contained = true,
}: ReadingProgressProps) {
const scroller = useRef<HTMLDivElement>(null);
const header = useRef<HTMLElement>(null);
const refs = useRef<(HTMLElement | null)[]>([]);
const raw = useMotionValue(0);
const position = useSpring(raw, { stiffness: 260, damping: 40, restDelta: 0.001 });
const [active, setActive] = useState(0);
const [done, setDone] = useState(0);
const { scrollY } = useScroll(contained ? { container: scroller } : {});
const measure = useCallback(() => {
const root = contained ? scroller.current : null;
const top = root ? root.getBoundingClientRect().top : 0;
const height = root ? root.clientHeight : window.innerHeight;
const line = top + (header.current?.offsetHeight ?? 0) + height * 0.3;
let p = 0;
for (const el of refs.current) {
if (!el) continue;
const r = el.getBoundingClientRect();
p += Math.min(1, Math.max(0, (line - r.top) / r.height));
}
const end = root
? root.scrollTop + root.clientHeight >= root.scrollHeight - 4
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
if (end) p = CHAPTERS.length;
raw.set(p);
setActive(Math.min(CHAPTERS.length - 1, Math.floor(p)));
setDone(p / CHAPTERS.length);
}, [contained, raw]);
useMotionValueEvent(scrollY, "change", measure);
useEffect(() => measure(), [measure]);
const jump = (i: number) => {
const el = refs.current[i];
if (!el) return;
const offset = (header.current?.offsetHeight ?? 0) + 16;
const box = scroller.current;
if (contained && box) {
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset;
box.scrollTo({ top, behavior: "smooth" });
} else {
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior: "smooth" });
}
};
const minutes = Math.ceil(4 * (1 - done));
const vars = { "--accent": accent } as CSSProperties;
const content = (
<div style={vars} className="relative bg-[#fbf8f1] text-[#2a251d] dark:bg-[#15130f] dark:text-[#ece5d8]">
<header ref={header} className="sticky top-0 z-10 bg-[#fbf8f1]/85 px-5 pb-1.5 pt-4 backdrop-blur-md dark:bg-[#15130f]/85">
<div className="flex items-center justify-between gap-3">
<div className="relative h-5 flex-1 overflow-hidden">
<AnimatePresence initial={false}>
<motion.p
key={active}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className="absolute inset-0 truncate text-[14px] font-semibold tracking-[-0.01em]"
>
<span className="mr-2 tabular-nums" style={{ color: accent }}>
{String(active + 1).padStart(2, "0")}
</span>
{CHAPTERS[active]?.title}
</motion.p>
</AnimatePresence>
</div>
{showTime ? (
<span className="shrink-0 text-[12px] tabular-nums text-[#8c826f]">{minutes > 0 ? minutes + " min" : "Lu"}</span>
) : null}
</div>
<nav aria-label="Chapitres" className="mt-1 flex gap-1.5">
{CHAPTERS.map((c, i) => (
<Segment
key={c.title}
index={i}
title={c.title}
position={position}
accent={accent}
current={i === active}
onJump={jump}
/>
))}
</nav>
</header>
<article className="mx-auto max-w-prose px-5 pb-16 pt-6">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em]" style={{ color: accent }}>
Étude de cas
</p>
<h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
Comment nous avons repensé une app de réservation en six semaines
</h2>
<p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min de lecture</p>
{CHAPTERS.map((c, i) => (
<section
key={c.title}
ref={(el) => {
refs.current[i] = el;
}}
className="mt-10 font-serif"
>
<h3 className="text-[22px] font-semibold tracking-[-0.01em]">{c.title}</h3>
{c.body.map((p, j) => (
<p
key={j}
className={
i === 0 && j === 0
? "mt-3 text-[17px] leading-[1.7] first-letter:float-left first-letter:mr-2 first-letter:text-[3.2em] first-letter:font-semibold first-letter:leading-[0.85] first-letter:text-[var(--accent)]"
: "mt-3 text-[17px] leading-[1.7]"
}
>
{p}
</p>
))}
</section>
))}
</article>
</div>
);
if (!contained) return content;
return (
<div
ref={scroller}
className="relative h-[30rem] w-full overflow-y-auto rounded-3xl bg-[#fbf8f1] [scrollbar-width:none] dark:bg-[#15130f]"
>
{content}
</div>
);
}