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]>
150 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|