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:
co-authored by
Claude Opus 5
parent
0b466f33f3
commit
4c19244df9
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 sí 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user