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]>
This commit is contained in:
AgendaPro Dev
2026-07-28 14:25:58 -06:00
co-authored by Claude Opus 5
parent 0b466f33f3
commit 4c19244df9
53 changed files with 7310 additions and 525 deletions
+109
View File
@@ -0,0 +1,109 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
// Las ocho muestras del abanico, ya en fila: `PIE_COLORS` del panel, mismo orden.
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
export function ClosingSection() {
const reduced = useReducedMotion();
return (
// El degradado va en la sección misma y no en un hijo con `-z-10`: ahí quedaba
// detrás del fondo opaco de `.landing` y no se veía nada.
<section
data-ld-section
className="relative overflow-hidden"
// Lavados del propio panel: orange-50 → brand-50. Nada inventado.
style={{ background: "linear-gradient(170deg, #fff7ed 0%, #eef4ff 60%, #f6f7fb 100%)" }}
>
<motion.div
className="ld-wrap ld-section text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
{/* Cinta de tintes: las ocho muestras del hero, ya en fila. */}
<motion.div
className="mx-auto mb-10 flex w-fit gap-1.5"
variants={revealUp(reduced, 14)}
aria-hidden="true"
>
{TINTES.map((c, i) => (
<motion.span
key={c}
className="h-11 w-4 rounded-full sm:h-14 sm:w-5"
style={{ background: c }}
animate={reduced ? undefined : { scaleY: [1, 0.78, 1] }}
transition={{
duration: 2.6,
repeat: Infinity,
delay: i * 0.11,
ease: "easeInOut",
}}
/>
))}
</motion.div>
<motion.h2 className="ld-display mx-auto max-w-3xl" variants={revealUp(reduced, 28)}>
Deja de <span className="ld-gradient-text">adivinar</span>.
</motion.h2>
<motion.p
className="ld-lead mx-auto mt-7 max-w-lg"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Ábrelo con una cuenta que ya está lista y mira tu salón como se ve cuando alguien te
lleva la cuenta.
</motion.p>
<motion.div className="mt-11" variants={revealUp(reduced)}>
<Link data-ld-closing-cta to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
</motion.div>
<motion.p
className="mt-6 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro.
</motion.p>
</motion.div>
<footer
className="ld-wrap flex flex-col items-center justify-between gap-4 border-t px-5 py-8 text-[0.82rem] font-semibold sm:flex-row"
style={{ borderColor: "var(--ld-hairline)", color: "var(--ld-muted)" }}
>
<div className="flex items-center gap-2" style={{ color: "var(--ld-tinta)" }}>
<BrandMark className="h-6 w-6" />
<span className="font-extrabold">AgendaMax</span>
</div>
<span>© {new Date().getFullYear()} AgendaMax · Salón de práctica</span>
{/* `inline-flex` no es decorativo: `.tap-target` solo fija min-height, y en un
enlace inline la altura mínima no aplica. */}
<Link
to="/login"
className="tap-target inline-flex items-center underline-offset-4 hover:underline"
>
Entrar
</Link>
</footer>
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
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>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { ArrowRight, ChevronDown } from "lucide-react";
import { EASE_EXPO, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { SwatchFanVisual } from "./visuals/SwatchFanVisual";
import { FloatingMomentsLayer, MomentsStack } from "./visuals/FloatingMomentsVisual";
export function HeroSection() {
const reduced = useReducedMotion();
return (
<section
data-ld-section
className="landing-papel relative isolate overflow-hidden pt-24 ld-section sm:pt-28"
>
{/* Lavados de color. Los tres tonos son los que el panel ya usa como fondo suave:
brand-50 (#eef4ff, el "hoy" del calendario), orange-50 (#fff7ed) y
emerald-50 (#ecfdf5). Van detrás de todo y no llevan texto, así que no
compiten con la lectura. */}
<div className="pointer-events-none absolute inset-0 -z-10">
<div
className="absolute -left-24 -top-24 h-[26rem] w-[26rem] rounded-full opacity-90 blur-3xl"
style={{ background: "radial-gradient(circle, #eef4ff 0%, transparent 70%)" }}
/>
<div
className="absolute -right-20 top-24 h-[30rem] w-[30rem] rounded-full opacity-85 blur-3xl"
style={{ background: "radial-gradient(circle, #fff7ed 0%, transparent 70%)" }}
/>
<div
className="absolute bottom-0 left-1/3 h-[24rem] w-[24rem] rounded-full opacity-80 blur-3xl"
style={{ background: "radial-gradient(circle, #ecfdf5 0%, transparent 70%)" }}
/>
</div>
<div className="ld-wrap grid grid-cols-1 items-center gap-14 lg:grid-cols-[1.05fr_1fr] lg:gap-10">
{/* ---- Columna de texto ---- */}
<motion.div initial="hidden" animate="show" variants={revealStagger(reduced, 0.09)}>
<motion.p
className="ld-eyebrow mb-5 inline-flex items-center gap-2 rounded-full bg-white/80 py-2 pl-2 pr-4 ring-1 ring-black/[0.05]"
variants={revealUp(reduced, 12)}
>
{/* Los cuatro primeros de la paleta del panel, en su orden. */}
<span className="flex gap-1" aria-hidden="true">
{["var(--ld-azul)", "var(--ld-naranja)", "var(--ld-esmeralda)", "var(--ld-purpura)"].map(
(c) => (
<span key={c} className="h-3 w-3 rounded-full" style={{ background: c }} />
)
)}
</span>
Salones · Barberías · Spas
</motion.p>
<h1 data-ld-hero-title className="ld-display">
<motion.span className="block" variants={revealUp(reduced, 26)}>
Tu agenda llena.
</motion.span>
<motion.span className="ld-gradient-text block" variants={revealUp(reduced, 26)}>
Tu domingo libre.
</motion.span>
</h1>
<motion.p className="ld-lead mt-7 max-w-lg" variants={revealUp(reduced)}>
Guarda la libreta y la calculadora. AgendaMax te dice quién viene hoy, quién dejó de
venir y cuánto llevas ganando este mes. Todo en una pantalla.
</motion.p>
<motion.div
className="mt-9 grid grid-cols-1 gap-3 sm:flex sm:items-center"
variants={revealUp(reduced)}
>
<Link to="/login" className="ld-cta">
Entrar y probarlo
<ArrowRight className="h-4 w-4" />
</Link>
<a href="#dolor" className="ld-cta-ghost">
Ver cómo se ve
<ChevronDown className="h-4 w-4" />
</a>
</motion.div>
<motion.p
className="mt-5 text-[0.9rem] font-semibold"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced, 8)}
>
No pide tarjeta. No pide registro. Entras y ya está adentro.
</motion.p>
</motion.div>
{/* ---- Columna del muestrario ----
El padding vertical en lg no es decorativo: reserva la banda superior e
inferior donde se posan los avisos flotantes. Sin él se estrellan contra
el abanico, que es la parte más ancha del dibujo. */}
<div className="relative lg:py-16">
<SwatchFanVisual />
{/* Los avisos flotan solo cuando hay espacio real alrededor del abanico.
Por debajo de lg se apilan bajo él: superpuestos en 390px taparían el
muestrario completo. */}
<FloatingMomentsLayer className="hidden lg:block" />
<MomentsStack className="mt-8 lg:hidden" />
</div>
</div>
{/* Indicador de scroll: respira, y se apaga con movimiento reducido. */}
{!reduced && (
<motion.div
className="mx-auto mt-14 flex w-fit items-center gap-2 text-[0.8rem] font-bold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
animate={{ y: [0, 7, 0], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2.6, repeat: Infinity, ease: EASE_EXPO }}
>
Sigue bajando
<ChevronDown className="h-4 w-4" />
</motion.div>
)}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { motion, useScroll, useTransform } from "framer-motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { BrandMark } from "../BrandMark";
/**
* Nav flotante. Arranca casi transparente sobre el hero y se condensa al bajar:
* fondo y desenfoque suben juntos, que es lo que evita el salto brusco de un
* `position: fixed` con fondo sólido desde el primer píxel.
*/
export function LandingNav() {
const reduced = useReducedMotion();
const { scrollY } = useScroll();
const fondo = useTransform(scrollY, [0, 140], ["rgba(255,251,247,0)", "rgba(255,251,247,0.86)"]);
const desenfoque = useTransform(scrollY, [0, 140], ["blur(0px)", "blur(16px)"]);
const borde = useTransform(scrollY, [0, 140], ["rgba(27,21,51,0)", "rgba(27,21,51,0.1)"]);
return (
<motion.header
className="safe-top fixed inset-x-0 top-0 z-50 border-b"
style={
reduced
? { background: "rgba(255,251,247,0.92)", borderColor: "rgba(27,21,51,0.1)" }
: { background: fondo, backdropFilter: desenfoque, borderColor: borde }
}
>
<nav className="ld-wrap flex items-center justify-between gap-4 px-5 py-3">
<Link
to="/"
className="tap-target flex items-center gap-2.5 text-[1.05rem] font-extrabold tracking-tight"
>
<BrandMark className="h-8 w-8" />
AgendaMax
</Link>
<Link
to="/login"
className="ld-cta"
style={{ minHeight: 46, paddingInline: "1.35rem", fontSize: "0.9rem" }}
>
Entrar
</Link>
</nav>
</motion.header>
);
}
@@ -0,0 +1,80 @@
import { motion } from "framer-motion";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
/**
* Las tres frases que una dueña dice de verdad cuando le enseñas un sistema. Están
* en su registro, no en el nuestro: «no le sé a la tecnología», no «tengo baja
* alfabetización digital». La respuesta también: corta y sin tecnicismos.
*/
const OBJECIONES = [
{
color: "var(--ld-rosa)",
dice: "No le sé a la tecnología.",
responde:
"Si contestas WhatsApp, ya sabes usarlo. Se toca y se arrastra, nada más. No hay que instalar ni configurar nada, y no hay curso que tomar.",
},
{
color: "var(--ld-naranja)",
dice: "Mi gente no lo va a usar.",
responde:
"Cada quien ve nomás su día y sus propias cuentas. No administran nada. Es menos trabajo que preguntarte a ti a qué hora entran.",
},
{
color: "var(--ld-purpura)",
dice: "Mi libreta me funciona bien.",
responde:
"Tu libreta te dice la hora de la cita, y eso lo hace bien. Lo que no te dice es que doña Carmen lleva cuatro meses sin venir, ni cuál de tus servicios estás regalando.",
},
];
export function ObjectionsSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-papel ld-section">
<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-muted)" }}
variants={revealUp(reduced, 12)}
>
Lo que estás pensando
</motion.p>
<motion.h2 className="ld-title max-w-2xl" variants={revealUp(reduced, 24)}>
Tres motivos para no hacerlo. Y por qué ninguno aguanta.
</motion.h2>
</motion.div>
<div className="mt-14 grid grid-cols-1 gap-4">
{OBJECIONES.map((o, i) => (
<motion.div
key={o.dice}
className="grid grid-cols-1 items-start gap-4 rounded-3xl bg-white p-7 ring-1 ring-black/[0.05] sm:grid-cols-[1fr_1.45fr] sm:gap-10 sm:p-9"
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 22 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.65, delay: reduced ? 0 : i * 0.1 }}
>
<p
className="text-2xl font-extrabold italic leading-snug sm:text-[1.75rem]"
style={{ fontFamily: "Fraunces, Georgia, serif", color: o.color }}
>
«{o.dice}»
</p>
<p className="ld-body" style={{ color: "var(--ld-muted)" }}>
{o.responde}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { useRef, useState } from "react";
import { motion, useMotionValueEvent, useScroll } from "framer-motion";
import { CalendarDays, HandCoins, Users } from "lucide-react";
import { EASE_EXPO } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
import { RevenueLineVisual } from "./visuals/RevenueLineVisual";
import { TeamLoadVisual } from "./visuals/TeamLoadVisual";
/**
* Tres actos, escritos como se los explicarías a una amiga que abre su salón. Cero
* palabras de sistema: ni «datos», ni «métricas», ni «panel». Lo que ella gana,
* dicho con sus palabras.
*/
const ACTOS = [
{
icono: CalendarDays,
rotulo: "Tu día",
color: "var(--ld-rosa)",
titulo: "Quién viene, a qué hora y con quién.",
copy: "Se mueve una cita con el dedo, como arrastrar una foto. Y no te deja poner a dos clientas en la misma silla a la misma hora, aunque se lo pidas.",
visual: <CalendarGridVisual density={0.48} />,
},
{
icono: HandCoins,
rotulo: "Tu dinero",
color: "var(--ld-naranja)",
titulo: "Cuánto entró hoy. Ya sumado.",
copy: "Sin calculadora y sin esperar al domingo. Cuánto llevas hoy, qué servicio te deja más y si esta semana va mejor que la pasada.",
visual: <RevenueLineVisual />,
},
{
icono: Users,
rotulo: "Tu gente",
color: "var(--ld-purpura)",
titulo: "Quién está hasta el tope y quién tiene hueco.",
copy: "De un golpe, sin preguntarle a nadie ni revisar tres libretas. Así reparte el trabajo sin que nadie se sienta cargada de más.",
visual: <TeamLoadVisual />,
},
];
function Encabezado({ a }: { a: (typeof ACTOS)[number] }) {
const Icono = a.icono;
return (
<>
<div className="mb-4 inline-flex items-center gap-2.5">
<span
className="flex h-9 w-9 items-center justify-center rounded-xl"
style={{ background: a.color }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</span>
<span className="ld-eyebrow" style={{ color: a.color }}>
{a.rotulo}
</span>
</div>
<h3 className="ld-title">{a.titulo}</h3>
<p className="ld-lead mt-5 max-w-lg" style={{ color: "var(--ld-muted)" }}>
{a.copy}
</p>
</>
);
}
function Panel({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.25)] ring-1 ring-black/[0.05] sm:p-7">
{children}
</div>
);
}
export function ProductSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState(0);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
useMotionValueEvent(scrollYProgress, "change", (p) => {
// Tres tramos iguales, acotados a los extremos para que el final del recorrido
// no devuelva un índice fuera de rango.
setActivo(Math.min(ACTOS.length - 1, Math.max(0, Math.floor(p * ACTOS.length))));
});
// Con movimiento reducido no hay panel fijo: los tres actos se apilan enteros.
if (reduced) {
return (
<section data-ld-section className="landing-papel ld-section">
<div className="ld-wrap grid grid-cols-1 gap-20">
{ACTOS.map((a) => (
<div key={a.rotulo} className="grid grid-cols-1 items-center gap-8 lg:grid-cols-2">
<div>
<Encabezado a={a} />
</div>
<Panel>{a.visual}</Panel>
</div>
))}
</div>
</section>
);
}
return (
<section data-ld-section className="landing-papel">
<div ref={ref} className="relative" style={{ height: `${ACTOS.length * 100}vh` }}>
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
{/* Texto: los tres actos ocupan la misma caja y se relevan. */}
<div className="relative min-h-[20rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, y: activo === i ? 0 : 20 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Encabezado a={a} />
</motion.div>
))}
</div>
{/* Panel: cambia de visual con el acto. */}
<div className="relative min-h-[21rem] sm:min-h-[25rem]">
{ACTOS.map((a, i) => (
<motion.div
key={a.rotulo}
className="absolute inset-0 grid grid-cols-1 content-center"
animate={{ opacity: activo === i ? 1 : 0, scale: activo === i ? 1 : 0.96 }}
transition={{ duration: 0.5, ease: EASE_EXPO }}
style={{ pointerEvents: activo === i ? "auto" : "none" }}
aria-hidden={activo !== i}
>
<Panel>{a.visual}</Panel>
</motion.div>
))}
</div>
</div>
</div>
</div>
{/* Avance entre actos: la píldora activa toma el color del acto. */}
<div className="ld-wrap flex items-center justify-center gap-2 pb-8">
{ACTOS.map((a, i) => (
<motion.span
key={a.rotulo}
className="h-1.5 rounded-full"
animate={{
width: activo === i ? 34 : 12,
backgroundColor: activo === i ? a.color : "rgba(27,21,51,0.15)",
}}
transition={{ duration: 0.4, ease: EASE_EXPO }}
/>
))}
</div>
</section>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { motion } from "framer-motion";
import { MousePointerClick } from "lucide-react";
import { inView, revealStagger, revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { DashboardMockVisual } from "./visuals/DashboardMockVisual";
export function ProofSection() {
const reduced = useReducedMotion();
return (
<section data-ld-section className="landing-nube ld-section">
<div className="ld-wrap">
<motion.div
className="mx-auto max-w-2xl text-center"
initial="hidden"
whileInView="show"
viewport={inView}
variants={revealStagger(reduced)}
>
<motion.p
className="ld-eyebrow mb-5"
style={{ color: "var(--ld-esmeralda)" }}
variants={revealUp(reduced, 12)}
>
Esto es de verdad
</motion.p>
<motion.h2 className="ld-title" variants={revealUp(reduced, 24)}>
No es un dibujito. Es el salón que te presta la demo.
</motion.h2>
<motion.p
className="ld-lead mt-6"
style={{ color: "var(--ld-muted)" }}
variants={revealUp(reduced)}
>
Entra, mueve una cita y mira cómo cambian las cuentas. No vas a romper nada: es un
salón de práctica que se puede volver a llenar cuando quieras.
</motion.p>
</motion.div>
<DashboardMockVisual className="mt-14" />
<motion.p
className="ld-body mx-auto mt-8 flex max-w-xl items-center justify-center gap-2.5 text-center font-semibold"
style={{ color: "var(--ld-muted)" }}
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.6, delay: reduced ? 0 : 0.3 }}
>
<MousePointerClick className="h-5 w-5 shrink-0" style={{ color: "var(--ld-esmeralda)" }} />
Un clic y estás dentro. No hay formulario que llenar.
</motion.p>
</div>
</section>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { revealUp } from "../../lib/motion";
import { useReducedMotion } from "../../lib/useReducedMotion";
import { NotebookVisual } from "./visuals/NotebookVisual";
import { CalendarGridVisual } from "./visuals/CalendarGridVisual";
function Texto() {
return (
<>
<p className="ld-eyebrow mb-5" style={{ color: "var(--ld-naranja)" }}>
El cambio
</p>
<h2 className="ld-title">
Tu libreta de siempre,
<br />
<span className="ld-gradient-text">pero que te contesta.</span>
</h2>
<p className="ld-lead mt-6 max-w-md" style={{ color: "var(--ld-muted)" }}>
El mismo día, las mismas clientas, las mismas muchachas. La diferencia es que ahora puedes
verlo todo de un golpe, y ella te avisa antes de que se te pase.
</p>
<p
className="ld-body mt-6 flex items-center gap-2 font-bold"
style={{ color: "var(--ld-tinta)" }}
>
Libreta
<ArrowRight className="h-4 w-4" style={{ color: "var(--ld-naranja)" }} />
Pantalla
</p>
</>
);
}
/** El tablero al que se transforma la libreta. */
function Tablero() {
return (
<div className="rounded-3xl bg-white p-5 shadow-[0_30px_70px_-30px_rgba(27,21,51,0.28)] ring-1 ring-black/[0.05] sm:p-6">
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-widest"
style={{ color: "var(--ld-muted)" }}
>
Tu semana, completa
</div>
<CalendarGridVisual />
</div>
);
}
/**
* El cambio, contado con el scroll: la libreta se va y el tablero llega. Es la única
* sección con scrub (el avance ligado a la posición), y por eso es la que hace que
* bajar por la página se sienta como una película y no como una lista.
*
* Con movimiento reducido se colapsa a una pila estática con las dos piezas visibles.
*/
export function ShiftSection() {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const notaOpacidad = useTransform(scrollYProgress, [0.05, 0.42], [1, 0]);
const notaEscala = useTransform(scrollYProgress, [0.05, 0.42], [1, 0.9]);
const notaGiro = useTransform(scrollYProgress, [0.05, 0.42], [-3, -9]);
const panelOpacidad = useTransform(scrollYProgress, [0.32, 0.62], [0, 1]);
const panelY = useTransform(scrollYProgress, [0.32, 0.62], [44, 0]);
const panelEscala = useTransform(scrollYProgress, [0.32, 0.62], [0.94, 1]);
if (reduced) {
return (
<section data-ld-section className="landing-crema ld-section">
<div className="ld-wrap grid grid-cols-1 gap-12">
<div>
<Texto />
</div>
<NotebookVisual className="max-w-md" />
<Tablero />
</div>
</section>
);
}
return (
<section data-ld-section className="landing-crema">
<div ref={ref} className="relative h-[250vh]">
<div className="sticky top-0 flex min-h-screen-safe items-center ld-section">
<div className="ld-wrap grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={revealUp(false)}
>
<Texto />
</motion.div>
{/* Escenario: las dos piezas ocupan la misma caja y se relevan. */}
<div className="relative min-h-[23rem] sm:min-h-[27rem]">
<motion.div
className="absolute inset-0"
style={{ opacity: notaOpacidad, scale: notaEscala, rotate: notaGiro }}
>
<NotebookVisual />
</motion.div>
<motion.div
className="absolute inset-0"
style={{ opacity: panelOpacidad, y: panelY, scale: panelEscala }}
>
<Tablero />
</motion.div>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
import { motion } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const COLS = 7;
const ROWS = 9;
const DIAS = ["L", "M", "M", "J", "V", "S", "D"];
/**
* Los colores de las citas son los del muestrario del hero, que a su vez son
* `PIE_COLORS` del panel. No es una coincidencia estética: es el remate del hilo de la
* página — los colores con los que trabaja son los colores con los que lee su semana,
* y son los mismos que verá dentro del producto.
*/
const TINTES = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899"];
/**
* Retícula de agenda que se puebla sola. No es una captura: es la promesa del
* producto en movimiento — el día pasando de vacío a lleno.
*
* `density` es la fracción de celdas que se convierten en cita (0..1).
*/
export function CalendarGridVisual({
className = "",
density = 0.42,
}: {
className?: string;
density?: number;
}) {
const reduced = useReducedMotion();
const total = COLS * ROWS;
// Patrón determinista: un pseudo-aleatorio con semilla fija da la misma agenda
// en cada render y en cada corrida del audit visual, que si no capturaría
// pantallas distintas cada vez y reportaría diferencias falsas.
const celdas = Array.from({ length: total }, (_, i) => {
const h = ((i * 2654435761) % 1000) / 1000;
const ocupada = h < density;
return { ocupada, tinte: TINTES[i % TINTES.length] };
});
return (
<div className={`select-none ${className}`} aria-hidden="true">
<div className="mb-2 grid grid-cols-7 gap-1.5">
{DIAS.map((d, i) => (
<div
key={`${d}-${i}`}
className="text-center text-[0.6rem] font-bold uppercase tracking-widest opacity-40"
>
{d}
</div>
))}
</div>
<motion.div
className="grid grid-cols-7 gap-1.5"
initial="hidden"
whileInView="show"
viewport={{ once: true }}
variants={{
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.012 } },
}}
>
{celdas.map((c, i) => (
<motion.div
key={i}
className="h-5 rounded-[0.3rem] sm:h-6"
style={{ background: c.ocupada ? c.tinte : "currentColor" }}
variants={{
hidden: reduced
? { scaleY: 1, opacity: c.ocupada ? 1 : 0.07 }
: { scaleY: 0.25, opacity: 0 },
show: {
scaleY: 1,
opacity: c.ocupada ? 1 : 0.07,
transition: { duration: 0.45, ease: EASE_EXPO },
},
}}
/>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,142 @@
import { motion, useInView } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
import { RevenueLineVisual } from "./RevenueLineVisual";
// Etiquetas en el idioma del salón: «lo que entró», no «ingresos brutos». Los colores
// son los que el panel ya asigna a estas mismas tarjetas de resumen.
const KPIS = [
{ etiqueta: "Lo que entró este mes", valor: 184300, prefijo: "$", sufijo: "", color: "#10b981" },
{ etiqueta: "Clientas atendidas", valor: 412, prefijo: "", sufijo: "", color: "#3b66ff" },
{ etiqueta: "Sillas ocupadas", valor: 78, prefijo: "", sufijo: "%", color: "#a855f7" },
];
// Los tres primeros de PIE_COLORS, en el mismo orden en que el panel colorea la dona
// de servicios.
const TOP = [
{ nombre: "Tinte completo", monto: 42800, color: "#3b66ff" },
{ nombre: "Corte y peinado", monto: 38150, color: "#f17616" },
{ nombre: "Uñas de gel", monto: 24600, color: "#10b981" },
];
/** Contador que interpola de 0 al valor. Se salta la animación si hay reduced motion. */
function Contador({ valor, prefijo, sufijo }: { valor: number; prefijo: string; sufijo: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLSpanElement>(null);
const visible = useInView(ref, { once: true, margin: "-10% 0px" });
const [n, setN] = useState(reduced ? valor : 0);
useEffect(() => {
if (reduced) {
setN(valor);
return;
}
if (!visible) return;
const DUR = 1400;
let raf = 0;
let inicio = 0;
const paso = (t: number) => {
if (!inicio) inicio = t;
const p = Math.min(1, (t - inicio) / DUR);
// ease-out cúbico: llega rápido y frena, igual que EASE_EXPO en CSS.
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">
{prefijo}
{n.toLocaleString("es-MX")}
{sufijo}
</span>
);
}
/** El panel real, a escala. Es la prueba: datos de la cuenta demo. */
export function DashboardMockVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<motion.div
className={`grid grid-cols-1 gap-4 rounded-[1.75rem] bg-white p-5 shadow-[0_44px_100px_-40px_rgba(27,21,51,0.32)] ring-1 ring-black/[0.05] sm:p-7 ${className}`}
initial={{ opacity: reduced ? 1 : 0, y: reduced ? 0 : 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={inView}
transition={{ duration: 0.8, ease: EASE_EXPO }}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{KPIS.map((k) => (
<div
key={k.etiqueta}
className="rounded-2xl p-4"
style={{ background: "var(--ld-nube)" }}
>
<div
className="text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
{k.etiqueta}
</div>
<div
className="mt-1.5 text-2xl font-extrabold tracking-tight sm:text-[1.8rem]"
style={{ color: k.color }}
>
<Contador valor={k.valor} prefijo={k.prefijo} sufijo={k.sufijo} />
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.6fr_1fr]">
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-2 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que entró · últimos 12 meses
</div>
<RevenueLineVisual />
</div>
<div className="rounded-2xl p-4" style={{ background: "var(--ld-crema)" }}>
<div
className="mb-3 text-[0.72rem] font-extrabold uppercase tracking-wider"
style={{ color: "var(--ld-muted)" }}
>
Lo que más te deja
</div>
<div className="grid grid-cols-1 gap-2.5">
{TOP.map((s, i) => (
<motion.div
key={s.nombre}
className="flex items-baseline justify-between gap-3"
initial={{ opacity: reduced ? 1 : 0, x: reduced ? 0 : -12 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={inView}
transition={{
duration: 0.5,
delay: reduced ? 0 : 0.4 + i * 0.1,
ease: EASE_EXPO,
}}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="h-4 w-1.5 shrink-0 rounded-full"
style={{ background: s.color }}
/>
<span className="truncate text-[0.92rem] font-bold">{s.nombre}</span>
</span>
<span className="ld-num shrink-0 text-[0.92rem] font-extrabold">
${s.monto.toLocaleString("es-MX")}
</span>
</motion.div>
))}
</div>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,122 @@
import { motion } from "framer-motion";
import { BellRing, Check, HandCoins } from "lucide-react";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Los tres momentos que la dueña recibe de vuelta. No son features: son avisos
* concretos, escritos como los leería en su teléfono. Flotan sobre el muestrario
* porque eso es lo que el producto pone encima de su día de trabajo.
*
* `pos` los coloca en las tres zonas libres alrededor del abanico: el abanico es un
* semicírculo que abre hacia arriba, así que el hueco está en las dos esquinas
* superiores y en la inferior izquierda, junto al pivote. Ponerlos a media altura
* los estrella contra la parte más ancha del abanico.
*
* El detalle se mantiene corto a propósito: la tarjeta es `truncate`, y una frase
* larga se corta con puntos suspensivos, que se lee como un error.
*
* En móvil no se usa nada de esto: los avisos se apilan (`MomentsStack`).
*/
const MOMENTOS = [
{
icono: Check,
color: "var(--ld-esmeralda)",
titulo: "Valentina confirmó",
detalle: "11:30 · Tinte y corte",
pos: "left-0 top-0",
fase: 0,
},
{
icono: HandCoins,
color: "var(--ld-naranja)",
titulo: "Llevas $4,280 hoy",
detalle: "9 servicios cobrados",
pos: "right-0 top-[13%]",
fase: 1.4,
},
{
icono: BellRing,
color: "var(--ld-rosa)",
titulo: "Carmen no ha venido",
detalle: "4 meses sin aparecer",
pos: "left-0 bottom-0",
fase: 2.6,
},
];
/** Tarjeta de aviso. La posición la decide quien la monta, no la tarjeta. */
function Momento({ m, indice }: { m: (typeof MOMENTOS)[number]; indice: number }) {
const reduced = useReducedMotion();
const Icono = m.icono;
return (
<motion.div
className="flex w-full items-center gap-3 rounded-2xl bg-white/95 px-3.5 py-3 shadow-[0_16px_38px_-16px_rgba(27,21,51,0.3)] ring-1 ring-black/[0.04] backdrop-blur-sm"
initial={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: reduced ? 0 : 0.75,
delay: reduced ? 0 : 0.75 + indice * 0.22,
ease: EASE_EXPO,
}}
>
<motion.span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ background: m.color }}
animate={reduced ? undefined : { rotate: [0, -6, 0, 6, 0] }}
transition={{ duration: 6, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Icono className="h-4 w-4 text-white" strokeWidth={2.6} />
</motion.span>
<span className="min-w-0">
<span className="block truncate text-[0.9rem] font-extrabold leading-tight">
{m.titulo}
</span>
<span
className="block truncate text-[0.78rem] leading-tight"
style={{ color: "var(--ld-muted)" }}
>
{m.detalle}
</span>
</span>
</motion.div>
);
}
/**
* Capa flotante para pantallas grandes: se superpone al muestrario. La deriva vive
* en este envoltorio y no en la tarjeta, porque dos `animate` sobre la misma `y` se
* sobrescriben y la entrada se perdería.
*/
export function FloatingMomentsLayer({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
{MOMENTOS.map((m, i) => (
<motion.div
key={m.titulo}
className={`absolute w-[14.5rem] ${m.pos}`}
// Fases y duraciones distintas por tarjeta: si suben y bajan en bloque, se
// nota que es un bucle y deja de leerse como avisos que llegan solos.
animate={reduced ? undefined : { y: [0, -13, 0] }}
transition={{ duration: 5.5 + i, repeat: Infinity, delay: m.fase, ease: "easeInOut" }}
>
<Momento m={m} indice={i} />
</motion.div>
))}
</div>
);
}
/** Pila para móvil: los mismos avisos, en columna y sin flotar. */
export function MomentsStack({ className = "" }: { className?: string }) {
return (
<div className={`grid grid-cols-1 gap-2.5 ${className}`}>
{MOMENTOS.map((m, i) => (
<Momento key={m.titulo} m={m} indice={i} />
))}
</div>
);
}
@@ -0,0 +1,69 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
const APUNTES = [
{ texto: "10:00 Sra. Ortiz — tinte", tachado: true },
{ texto: "11:30 ¿Camila o Mateo?", tachado: false },
{ texto: "12:00 confirmar x WhatsApp", tachado: true },
{ texto: "1:00 —— cancelado", tachado: true },
{ texto: "4:00 pendiente $$$", tachado: false },
{ texto: "cobrar a la Sra. del jueves", tachado: false },
];
/**
* El "antes": la libreta. Se dibuja con tipografía manuscrita del sistema y tachones
* que se trazan, porque un icono de libreta no transmite el desorden.
*
* Es el ÚNICO componente de la landing que sale a propósito de la paleta de la
* plataforma: papel crema, renglones, margen rojo y grafito. Precisamente porque es
* lo que el producto viene a reemplazar — si se pareciera al producto, la sección
* dejaría de contar un cambio. No unifiques estos colores con los tokens `--ld-*`.
*/
export function NotebookVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div
className={`relative overflow-hidden rounded-2xl bg-[#faf7ef] p-6 text-[#3b3a35] shadow-[0_24px_60px_-24px_rgba(0,0,0,0.55)] ${className}`}
aria-hidden="true"
>
{/* Renglones del cuaderno */}
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "repeating-linear-gradient(#faf7ef 0 30px, #ddd6c4 30px 31px)",
}}
/>
{/* Margen rojo */}
<div className="pointer-events-none absolute inset-y-0 left-10 w-px bg-[#d98b8b]" />
<div className="relative grid grid-cols-1 gap-[0.72rem] pl-8">
{APUNTES.map((a, i) => (
<div key={a.texto} className="relative w-fit max-w-full">
<span
className="text-[0.92rem] leading-[1.6]"
style={{ fontFamily: "'Bradley Hand', 'Segoe Script', cursive" }}
>
{a.texto}
</span>
{a.tachado && (
<motion.span
className="absolute left-0 top-1/2 h-[1.5px] w-full bg-[#3b3a35]"
style={{ originX: 0 }}
initial={{ scaleX: reduced ? 1 : 0 }}
whileInView={{ scaleX: 1 }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.4,
delay: reduced ? 0 : 0.3 + i * 0.14,
ease: EASE_EXPO,
}}
/>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Serie fija: doce meses con tendencia al alza y ruido creíble. Determinista a
// propósito, para que el audit visual capture siempre la misma gráfica.
const SERIE = [38, 44, 41, 52, 58, 54, 66, 71, 68, 79, 86, 94];
const W = 520;
const H = 200;
const PAD = 12;
const MAX = Math.max(...SERIE);
const MIN = Math.min(...SERIE);
function yDe(v: number): number {
return H - PAD - ((v - MIN) / (MAX - MIN)) * (H - PAD * 2);
}
function pathFrom(serie: number[]): string {
const dx = (W - PAD * 2) / (serie.length - 1);
return serie
.map((v, i) => `${i === 0 ? "M" : "L"}${(PAD + i * dx).toFixed(1)} ${yDe(v).toFixed(1)}`)
.join(" ");
}
/** Línea de ingresos que se traza sola. El "antes/después" no se explica: se ve. */
export function RevenueLineVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const d = pathFrom(SERIE);
const area = `${d} L${W - PAD} ${H - PAD} L${PAD} ${H - PAD} Z`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className={`w-full ${className}`}
role="img"
aria-label="Ingresos mensuales en tendencia ascendente"
>
<defs>
<linearGradient id="ld-rev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity="0.35" />
<stop offset="100%" stopColor="#3b66ff" stopOpacity="0" />
</linearGradient>
</defs>
{/* Rejilla base: tres guías en #eef0f4, el mismo `CartesianGrid` que usa la
gráfica de ingresos del panel. Dan escala sin competir. */}
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD}
x2={W - PAD}
y1={PAD + f * (H - PAD * 2)}
y2={PAD + f * (H - PAD * 2)}
stroke="#eef0f4"
strokeWidth="1"
/>
))}
<motion.path
d={area}
fill="url(#ld-rev-fill)"
initial={{ opacity: reduced ? 1 : 0 }}
whileInView={{ opacity: 1 }}
viewport={inView}
transition={{ duration: 0.9, delay: reduced ? 0 : 0.5, ease: EASE_EXPO }}
/>
<motion.path
d={d}
fill="none"
stroke="#3b66ff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
whileInView={{ pathLength: 1 }}
viewport={inView}
transition={{ duration: reduced ? 0 : 1.4, ease: EASE_EXPO }}
/>
{/* Punto final: la marca del "hoy". Aterriza cuando la línea termina. */}
<motion.circle
cx={W - PAD}
cy={yDe(SERIE[SERIE.length - 1])}
r="5"
fill="#3b66ff"
initial={{ scale: reduced ? 1 : 0 }}
whileInView={{ scale: 1 }}
viewport={inView}
transition={{ duration: 0.5, delay: reduced ? 0 : 1.3, ease: EASE_EXPO }}
/>
</svg>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { EASE_EXPO } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
/**
* Muestrario: el abanico de coloración que hay sobre el mostrador de cualquier salón.
* Es el objeto más característico de este mundo y ya es vívido de por sí, así que hace
* de firma de la página sin necesidad de inventar un gradiente.
*
* Los ocho colores NO son decorativos ni inventados: son `PIE_COLORS` de
* [DashboardPage.tsx](../../../pages/DashboardPage.tsx), la paleta categórica que la
* app ya usa para los servicios en la gráfica de dona, y los mismos que el seed
* reparte entre los avatares de los empleados. Son ocho, igual que las muestras.
*
* De ahí que el hilo de la página sea literal y no una metáfora: el abanico del hero
* trae los mismos hex que la dueña va a ver en sus fichas cuando entre.
*
* Va sin etiquetas: las muestras se solapan como en un abanico real, así que solo la
* última quedaría a la vista, y una única etiqueta girada se lee como un error en vez
* de como un detalle. La forma y el color dicen lo que hay que decir.
*/
const TINTES = [
"#3b66ff",
"#f17616",
"#10b981",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f59e0b",
"#6366f1",
];
// El abanico abre 92° repartidos entre las nueve muestras. Más apertura y las puntas
// exteriores se salen de la columna y chocan con los avisos flotantes.
const SPREAD = 92;
const anguloDe = (i: number) => -SPREAD / 2 + (SPREAD / (TINTES.length - 1)) * i;
export function SwatchFanVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
// Puntero normalizado a 1..1. Con `useSpring` el abanico persigue al cursor con
// inercia en lugar de pegarse a él, que es lo que lo hace sentir como un objeto.
const px = useMotionValue(0);
const py = useMotionValue(0);
const sx = useSpring(px, { stiffness: 60, damping: 18, mass: 0.6 });
const sy = useSpring(py, { stiffness: 60, damping: 18, mass: 0.6 });
const rotateZ = useTransform(sx, [-1, 1], [7, -7]);
const rotateY = useTransform(sx, [-1, 1], [-11, 11]);
const rotateX = useTransform(sy, [-1, 1], [7, -7]);
useEffect(() => {
if (reduced) return;
// `pointerFine` decide si hay cursor que perseguir. En táctil no hay puntero,
// así que el abanico se queda con su vaivén propio (animate del contenedor).
const fino = window.matchMedia("(pointer: fine)").matches;
if (!fino) return;
const onMove = (e: PointerEvent) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
px.set(((e.clientX - (r.left + r.width / 2)) / (r.width / 2)) * 0.8);
py.set(((e.clientY - (r.top + r.height / 2)) / (r.height / 2)) * 0.8);
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, [px, py, reduced]);
return (
<div
ref={ref}
className={`relative select-none ${className}`}
style={{ perspective: 1100 }}
aria-hidden="true"
>
<motion.div
className="relative mx-auto h-[16.5rem] w-full max-w-[27rem] sm:h-[20rem]"
style={
reduced
? undefined
: { rotateZ, rotateY, rotateX, transformStyle: "preserve-3d" }
}
// Vaivén propio: sostiene la vida del abanico en táctil, donde no hay cursor.
animate={reduced ? undefined : { y: [0, -10, 0] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
>
{TINTES.map((color, i) => {
const ang = anguloDe(i);
return (
<motion.div
key={color}
// Se centra con `inset-x-0 mx-auto` y NO con `left-1/2` + margen
// negativo: el ancho cambia en `sm:` y un margen fijo solo puede
// centrar uno de los dos. `translateX(-50%)` tampoco sirve aquí,
// porque framer-motion es dueña del `transform` de este nodo.
className="absolute inset-x-0 bottom-0 mx-auto h-[12rem] w-[4.15rem] origin-[50%_112%] rounded-[1rem] sm:h-[14.5rem] sm:w-[4.85rem]"
style={{
background: color,
boxShadow: "0 18px 40px -18px rgba(15,23,42,0.42)",
}}
// Abre desde el abanico cerrado: todas apiladas en 0° y se despliegan.
// Es el momento orquestado de la carga, no un fade genérico.
initial={reduced ? { rotate: ang, opacity: 1 } : { rotate: 0, opacity: 0 }}
animate={{ rotate: ang, opacity: 1 }}
transition={{
duration: reduced ? 0 : 1.15,
delay: reduced ? 0 : 0.15 + i * 0.055,
ease: EASE_EXPO,
}}
whileHover={reduced ? undefined : { y: -16, scale: 1.05 }}
/>
);
})}
</motion.div>
</div>
);
}
@@ -0,0 +1,54 @@
import { motion } from "framer-motion";
import { EASE_EXPO, inView } from "../../../lib/motion";
import { useReducedMotion } from "../../../lib/useReducedMotion";
// Nombres del negocio demo sembrado (plantilla estetica-spa) y los colores que el
// seed reparte a los avatares: cada persona llega con el suyo, igual que dentro
// de la app.
const EQUIPO = [
{ nombre: "Valentina", carga: 0.92, color: "#3b66ff", citas: 34, nota: "hasta el tope" },
{ nombre: "Mateo", carga: 0.74, color: "#f17616", citas: 27, nota: "bien" },
{ nombre: "Camila", carga: 0.61, color: "#10b981", citas: 22, nota: "bien" },
{ nombre: "Diego", carga: 0.38, color: "#a855f7", citas: 14, nota: "le caben más" },
{ nombre: "Renata", carga: 0.22, color: "#ec4899", citas: 8, nota: "casi libre" },
];
/** Carga real por especialista. El punto: la diferencia se ve de un golpe. */
export function TeamLoadVisual({ className = "" }: { className?: string }) {
const reduced = useReducedMotion();
return (
<div className={`grid grid-cols-1 gap-3.5 ${className}`}>
{EQUIPO.map((p, i) => (
<div key={p.nombre} className="grid grid-cols-1 gap-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-extrabold">{p.nombre}</span>
<span className="shrink-0 text-xs" style={{ color: "var(--ld-muted)" }}>
<span className="ld-num font-bold">{p.citas}</span> citas · {p.nota}
</span>
</div>
{/* La pista es un div propio con opacidad en línea: el modificador de
opacidad de Tailwind no aplica sobre currentColor. */}
<div className="relative h-2.5 overflow-hidden rounded-full">
<div
className="absolute inset-0 rounded-full"
style={{ background: "currentColor", opacity: 0.12 }}
/>
<motion.div
className="absolute inset-y-0 left-0 w-full rounded-full"
style={{ background: p.color, originX: 0 }}
initial={{ scaleX: reduced ? p.carga : 0 }}
whileInView={{ scaleX: p.carga }}
viewport={inView}
transition={{
duration: reduced ? 0 : 0.9,
delay: reduced ? 0 : i * 0.09,
ease: EASE_EXPO,
}}
/>
</div>
</div>
))}
</div>
);
}