Files
AgendaPro/src/components/landing/CostSection.tsx
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

150 lines
5.3 KiB
TypeScript

import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Cuatro escenas de un martes cualquiera, no cuatro estadísticas. Cada una arranca
* con la frase que la dueña diría en voz alta; el número viene después, como
* confirmación de algo que ya sospechaba.
*
* Ninguna menciona el producto todavía: vender antes de haber dolido no funciona.
*/
const ESCENAS = [
{
color: "var(--ld-rosa)",
escena: "«Se me hizo un hueco a las 3»",
cifra: 34,
sufijo: "%",
remate: "de tus horas se quedan solas",
detalle:
"Cuarenta minutos entre una clienta y otra. Nadie los llena porque nadie los ve a tiempo. Y la renta se paga igual.",
},
{
color: "var(--ld-naranja)",
escena: "«¿Y doña Carmen? Ya ni viene»",
cifra: 4,
sufijo: " meses",
remate: "sin aparecer, y sigue en tu libreta",
detalle:
"Antes venía cada tres semanas. Un día dejó de venir y nadie te avisó. Traerla de vuelta cuesta una llamada; conseguir una nueva cuesta mucho más.",
},
{
color: "var(--ld-purpura)",
escena: "«Ese servicio sí deja… ¿o no?»",
cifra: 1,
sufijo: " de 6",
remate: "servicios te cuesta más de lo que cobra",
detalle:
"Entre producto, la hora de silla y la comisión, hay uno que estás regalando. En la libreta se ve igual que los demás.",
},
{
color: "var(--ld-esmeralda)",
escena: "«El domingo cuadro las cuentas»",
cifra: 3,
sufijo: " horas",
remate: "de calculadora, y aun así no sabes",
detalle:
"Tu día de descanso se va sumando tickets. Y al final del rato sigues sin saber si la semana fue mejor o peor que la anterior.",
},
];
function Cifra({ valor, sufijo, color }: { valor: number; sufijo: string; color: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-15% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1100;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
setN(Math.round(valor * (1 - Math.pow(1 - p, 3))));
if (p < 1) raf = requestAnimationFrame(paso);
};
raf = requestAnimationFrame(paso);
return () => cancelAnimationFrame(raf);
}, [visible, valor, reduced]);
return (
<span ref={ref} className="ld-num text-5xl font-extrabold sm:text-6xl" style={{ color }}>
{n}
{sufijo}
</span>
);
}
export function CostSection() {
const reduced = useReducedMotion();
return (
// `scroll-mt-24` no es decorativo: el nav es fijo, y sin margen de scroll el
// salto desde «Ver cómo se ve» deja el titular escondido debajo de él.
<section id="dolor" data-ld-section className="landing-nube ld-section scroll-mt-24">
<div className="ld-wrap">
<motion.div
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-rosa)" }}
variants={revealUp(reduced, 12)}
>
Esto te va a sonar
</motion.p>
<motion.h2 className="ld-title max-w-3xl" variants={revealUp(reduced, 24)}>
No es que trabajes poco. Es que nadie te está llevando la cuenta.
</motion.h2>
<motion.p
className="ld-lead mt-6 max-w-xl"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Cuatro cosas que pasan en tu salón cada semana. Ninguna aparece en el banco con nombre
y apellido. Todas salen de ahí.
</motion.p>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-5 sm:grid-cols-2">
{ESCENAS.map((e, i) => (
<motion.div
key={e.escena}
className="grid grid-cols-1 gap-3 rounded-3xl bg-white/85 p-6 ring-1 ring-black/[0.04] sm:p-7"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.7, delay: reduced ? 0 : i * 0.09 }}
whileHover={reduced ? undefined : { y: -6 }}
>
<span
className="ld-body font-extrabold italic"
style={{ color: e.color, fontFamily: "Fraunces, Georgia, serif" }}
>
{e.escena}
</span>
<p className="flex flex-wrap items-baseline gap-x-2.5">
<Cifra valor={e.cifra} sufijo={e.sufijo} color={e.color} />
<span className="text-lg font-extrabold leading-snug">{e.remate}</span>
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{e.detalle}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}