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": "Der Auftrag",
"body": [
"Wir haben eine Reservierungs-App übernommen, auf der vier Jahre Flickwerk lagen. Sie lief, aber keiner wusste so recht, warum. Das Ziel war klar: Einen Tisch zu reservieren sollte weniger Aufwand sein als anzurufen.",
"In der ersten Woche haben wir Figma nicht geöffnet. Wir saßen in drei Restaurants, notierten jede Frage des Servicepersonals und stoppten, wie lange Gäste brauchten, um einen einfachen Button zu finden."
]
},
{
"title": "Skizzen",
"body": [
"Wir haben über vierzig Screens mit Bleistift skizziert. Die meisten landeten im Papierkorb – und genau darum ging es: schnell und günstig Fehler machen.",
"Übrig blieb eine einfache Idee: ein einziger Screen mit Tag, Uhrzeit und Personenzahl – und ein großer Button, der genau sagte, was als Nächstes passiert."
]
},
{
"title": "Die Details",
"body": [
"Wir haben das Timing jeder Transition so lange angepasst, bis man sie nicht mehr bemerkt hat. Den Ton der Fehlermeldungen haben wir dreimal geändert, damit sie nach Hilfe klingen und nicht nach Tadel.",
"Außerdem haben wir einen ganzen Nachmittag lang die App mit einer Hand getestet – im Stehen, im Bus. Was dort nicht funktionierte, funktionierte nirgends."
]
},
{
"title": "Launch",
"body": [
"Wir sind zuerst in zwei Städten gestartet. In der ersten Woche stiegen die abgeschlossenen Reservierungen um achtunddreißig Prozent, und die Anrufe bei den Restaurants halbierten sich.",
"Das Beste waren nicht die Zahlen, sondern die Nachricht einer Kellnerin: „Endlich kann ich das Handy weglegen und mich um die Tische kümmern.“ Damit war das Projekt für uns abgeschlossen."
]
}
];
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={"Gehe zu «" + 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" : "Gelesen"}</span>
) : null}
</div>
<nav aria-label="Kapitel" 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 }}>
Fallstudie
</p>
<h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
Wie wir eine Buchungs-App in sechs Wochen neu gestaltet haben
</h2>
<p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 Min. Lesezeit</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>
);
}