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": "El encargo",
"body": [
"Nos llegó una app de reservas con cuatro años de parches encima. Funcionaba, pero nadie sabía muy bien por qué. El objetivo era claro: que reservar mesa costara menos que llamar por teléfono.",
"La primera semana no abrimos Figma. Nos sentamos en tres restaurantes, apuntamos cada duda de los camareros y cronometramos cuánto tardaba la gente en encontrar un simple botón."
]
},
{
"title": "Bocetos",
"body": [
"Dibujamos más de cuarenta pantallas a lápiz. La mayoría acabaron en la papelera, y eso era justo lo que buscábamos: equivocarnos rápido y barato.",
"De todas ellas sobrevivió una idea sencilla. Una sola pantalla con el día, la hora y el número de personas, y un botón grande que decía exactamente lo que iba a pasar."
]
},
{
"title": "Los detalles",
"body": [
"Ajustamos los tiempos de cada transición hasta que dejaron de notarse. Cambiamos tres veces el tono de los errores para que sonaran a ayuda y no a regañina.",
"También pasamos una tarde entera probando la app con una mano, de pie y en un autobús. Lo que no funcionaba ahí, no funcionaba en ningún sitio."
]
},
{
"title": "Lanzamiento",
"body": [
"Salimos primero en dos ciudades. En la primera semana, las reservas completadas subieron un treinta y ocho por ciento y las llamadas al restaurante bajaron a la mitad.",
"Lo mejor no fueron los números, sino un mensaje de una camarera: «Por fin puedo dejar el teléfono y atender mesas». Con eso dimos el proyecto por terminado."
]
}
];
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={"Ir a «" + 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" : "Leído"}</span>
) : null}
</div>
<nav aria-label="Capítulos" 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 }}>
Caso de estudio
</p>
<h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
Cómo rediseñamos una app de reservas en seis semanas
</h2>
<p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min de lectura</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>
);
}