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 + MotionText & Scroll

Lesefortschritt nach Kapiteln

Ein Artikel mit einer Kopfzeile, die zeigt, in welchem Kapitel du bist, und einem Balken pro Kapitel, der sich beim Lesen füllt.

Einstellungen

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

Prompt für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Text & Scroll

Wofür du ihn nutzen kannst

Lange Texte verlieren Leser auf halber Strecke, wenn das Ende nicht absehbar ist. Diese Lesefortschrittsanzeige teilt sich in einen Balken pro Kapitel, so zeigen eine Case Study, ein langer Blogartikel oder eine Doku-Seite, wo man steht und was noch kommt. Ein Tipp auf einen Balken springt ins Kapitel, oben stehen auf Wunsch die restlichen Minuten.

Das lohnt sich ab etwa fünf Minuten Lesezeit, bei kurzen Beiträgen ist es nur Deko. Jedes Kapitel braucht einen klaren Titel, denn den zeigt die Kopfzeile. Blende die Minuten aus, wenn deine Schätzung zu grob wäre.