01El encargo

4 min

Caso de estudio

Cómo rediseñamos una app de reservas en seis semanas

Lucía Ferrer · 4 min de lectura

El encargo

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.

Bocetos

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.

Los detalles

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.

Lanzamiento

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.

React + MotionTexte et défilement

Progression de lecture par chapitres

Un article dont l’en-tête indique le chapitre en cours, avec une barre par chapitre qui se remplit.

Réglages

Code

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>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Texte et défilement

Où l’utiliser

Les textes longs perdent leurs lecteurs à mi-chemin s’ils ne voient pas la fin arriver. Cette barre de progression de lecture se divise en un segment par chapitre : une étude de cas, un long article de blog ou une page de documentation montrent où l’on est et ce qui reste. Touchez une barre pour sauter au chapitre.

Elle vaut le coup à partir de cinq minutes de lecture ; sur un billet court, c’est de la décoration. Chaque chapitre a besoin d’un titre clair, c’est lui qu’affiche l’en-tête. Masquez les minutes restantes si votre estimation est trop approximative.