feat(secciones): puntos de inflexión y dolores, con gráfico en toda la página

Dos secciones nuevas tomadas del deck de la dueña, verbatim y sin
reescribir: los 6 puntos de inflexión y los 4 dolores en primera persona.

El resto de las secciones incorpora la capa gráfica: fondos de puntos,
duotono sobre foto en Mecanismo y Cómo empezar, pantalla partida sí/no en
Para quién, y pasos conectados con línea de progreso.

Las fotos son CC BY-SA 4.0 y llevan crédito en el footer, como exige la
licencia al haberlas modificado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5 committed 2026-07-31 10:44:54 -06:00
1 parent 5b400cb161
commit 69b4069525
25 files changed
+592 -120

No files matched your search

+5 -1
View File
@@ -2,6 +2,8 @@ import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer'; import { Footer } from '@/components/layout/Footer';
import { Hero } from '@/components/sections/Hero'; import { Hero } from '@/components/sections/Hero';
import { FraseAncla } from '@/components/sections/FraseAncla'; import { FraseAncla } from '@/components/sections/FraseAncla';
import { PuntosInflexion } from '@/components/sections/PuntosInflexion';
import { Dolores } from '@/components/sections/Dolores';
import { ParaQuien } from '@/components/sections/ParaQuien'; import { ParaQuien } from '@/components/sections/ParaQuien';
import { Mecanismo } from '@/components/sections/Mecanismo'; import { Mecanismo } from '@/components/sections/Mecanismo';
import { Disciplinas } from '@/components/sections/Disciplinas'; import { Disciplinas } from '@/components/sections/Disciplinas';
@@ -25,9 +27,11 @@ export default function Home() {
<main> <main>
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */} <Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
<FraseAncla /> <FraseAncla />
<ParaQuien /> <PuntosInflexion />
<Dolores />
<Mecanismo /> <Mecanismo />
<Disciplinas /> <Disciplinas />
<ParaQuien />
<Estratega /> <Estratega />
<ComoEmpezar /> <ComoEmpezar />
<Cierre /> <Cierre />
+12 -1
View File
@@ -1,6 +1,6 @@
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { footer } from '@/lib/content'; import { footer, creditosFotos } from '@/lib/content';
export function Footer() { export function Footer() {
return ( return (
@@ -10,6 +10,17 @@ export function Footer() {
<p className="text-sm text-gris-medio">{footer.tagline}</p> <p className="text-sm text-gris-medio">{footer.tagline}</p>
<p className="text-xs text-gris-medio">{footer.legal}</p> <p className="text-xs text-gris-medio">{footer.legal}</p>
</Container> </Container>
<Container className="pb-6 text-center md:text-left">
<p className="text-[10px] leading-relaxed text-gris-medio/60">
{creditosFotos.map((c, i) => (
<span key={c.href}>
{i > 0 && ' · '}
<a href={c.href} className="hover:text-gris-medio">{c.desc}</a>, modificada,{' '}
<a href={c.licenciaHref} className="hover:text-gris-medio">{c.licencia}</a>
</span>
))}
</p>
</Container>
</footer> </footer>
); );
} }
@@ -12,4 +12,11 @@ describe('<Footer />', () => {
render(<Footer />); render(<Footer />);
expect(screen.queryByRole('heading', { level: 1 })).toBeNull(); expect(screen.queryByRole('heading', { level: 1 })).toBeNull();
}); });
it('credits the stock photos per their CC BY-SA 4.0 licence', () => {
render(<Footer />);
expect(screen.getAllByText(/CC BY-SA 4\.0/)).toHaveLength(2);
expect(screen.getByText(/Golden Gate Bridge/)).toBeInTheDocument();
expect(screen.getByText(/Monte Nardi/)).toBeInTheDocument();
expect(screen.getAllByText(/modificada/).length).toBe(2);
});
}); });
+5 -3
View File
@@ -3,9 +3,11 @@ import { describe, it, expect } from 'vitest';
import { Header } from '../Header'; import { Header } from '../Header';
describe('<Header />', () => { describe('<Header />', () => {
it('renders the wordmark', () => { it('renders the wordmark, linked to the top of the page', () => {
const { container } = render(<Header />); render(<Header />);
expect(container.textContent).toContain('cre≡er'); const home = screen.getByRole('link', { name: /creeer/i });
expect(home).toHaveAttribute('href', '#top');
expect(home.querySelector('[data-accent="true"]')).not.toBeNull();
}); });
it('renders a persistent CTA linking to the contact section', () => { it('renders a persistent CTA linking to the contact section', () => {
render(<Header />); render(<Header />);
+14 -14
View File
@@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { cierre } from '@/lib/content'; import { cierre } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -10,25 +11,24 @@ export function Cierre() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
const { whatsapp, email, linkedin } = cierre.contact; const { whatsapp, email, linkedin } = cierre.contact;
return ( return (
<section id="contacto" className="bg-azul-profundo text-white scroll-mt-24"> <section id="contacto" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
<Container className="py-24 md:py-32 text-center"> <FondoPuntos className="top-12 -left-16 w-72 md:w-[30rem] text-dorado/25" distance={36} />
<Container className="relative py-24 md:py-32 text-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex flex-col items-center"> className="flex flex-col items-center">
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div> <motion.div variants={fadeInUp}><Wordmark size="lg" onDark showTagline /></motion.div>
<motion.blockquote variants={fadeInUp} <motion.p variants={fadeInUp}
className="mt-10 font-sans italic text-2xl md:text-4xl font-bold leading-snug max-w-3xl"> className="mt-12 font-sans font-black text-3xl md:text-5xl leading-[1.05] tracking-tight max-w-3xl">
<span className="text-dorado">&ldquo;</span>{cierre.quote}<span className="text-dorado">&rdquo;</span> {cierre.quote}
</motion.blockquote> </motion.p>
<motion.div variants={fadeInUp} className="mt-10"> <motion.div variants={fadeInUp} className="mt-10">
<CTA href={whatsapp.href} label={cierre.cta} variant="dark" /> <CTA href={whatsapp.href} label={cierre.cta} variant="dark" />
</motion.div> </motion.div>
<motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-12"> <motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14">
<ul className="flex flex-wrap justify-center gap-6 text-sm"> <ul className="flex flex-wrap justify-center gap-3 text-sm">
<li><a href={whatsapp.href} className="hover:text-dorado transition-colors">{whatsapp.label}</a></li> <li><a href={whatsapp.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{whatsapp.label}</a></li>
<li aria-hidden="true" className="text-gris-medio">·</li> <li><a href={email.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{email.label}</a></li>
<li><a href={email.href} className="hover:text-dorado transition-colors">{email.label}</a></li> <li><a href={linkedin.href} className="inline-block px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">{linkedin.label}</a></li>
<li aria-hidden="true" className="text-gris-medio">·</li>
<li><a href={linkedin.href} className="hover:text-dorado transition-colors">{linkedin.label}</a></li>
</ul> </ul>
</motion.nav> </motion.nav>
</motion.div> </motion.div>
+47 -20
View File
@@ -1,35 +1,62 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Image from 'next/image';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { comoEmpezar, cierre } from '@/lib/content'; import { comoEmpezar, cierre } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps, useAutoStep } from '@/lib/motion';
const ICONOS: IconoNombre[] = ['escribeme', 'diagnostico', 'plan'];
export function ComoEmpezar() { export function ComoEmpezar() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
const waHref = `${cierre.contact.whatsapp.href}?text=${encodeURIComponent(comoEmpezar.whatsappText)}`; const waHref = `${cierre.contact.whatsapp.href}?text=${encodeURIComponent(comoEmpezar.whatsappText)}`;
const { active, setActive, hoverProps } = useAutoStep(comoEmpezar.steps.length, 3200);
return ( return (
<section className="bg-white"> <section className="bg-gris-claro">
<Container className="py-24 md:py-32"> <Container className="py-24 md:py-32 grid gap-12 md:grid-cols-[1fr_auto] md:items-center">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
className="text-xs tracking-[0.3em] uppercase font-semibold text-dorado">{comoEmpezar.eyebrow}</motion.p> <motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{comoEmpezar.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-4xl md:text-6xl leading-[0.95] tracking-tight text-azul-profundo max-w-xl">{comoEmpezar.title}</motion.h2>
className="mt-4 font-sans font-bold text-3xl md:text-5xl text-azul-profundo">{comoEmpezar.title}</motion.h2> <motion.ol variants={staggerContainer} {...hoverProps} className="mt-12 grid gap-10 sm:grid-cols-3 md:grid-cols-1 md:gap-8">
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} {comoEmpezar.steps.map((step, i) => (
className="mt-12 grid gap-10 md:grid-cols-3"> <motion.li key={step.n} variants={fadeInUp}>
{comoEmpezar.steps.map((step) => ( <button
<motion.li key={step.n} variants={fadeInUp} type="button"
className="border-t border-dorado/30 pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 hover:border-dorado/70 motion-reduce:transform-none motion-reduce:transition-none"> onClick={() => setActive(i)}
<div className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none">{step.n}</div> aria-pressed={active === i}
<h3 className="mt-4 font-sans font-bold text-xl text-azul-profundo">{step.name}</h3> className={`w-full text-left border-t pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none
<p className="mt-3 text-gris-medio leading-relaxed">{step.desc}</p> md:flex md:items-baseline md:gap-4 md:border-t-0 md:border-l-2 md:pt-0 md:pl-5 ${
</motion.li> active === i ? 'border-dorado' : 'border-dorado/30 md:opacity-40'
))} }`}
</motion.ol> >
<div className="font-sans font-black text-5xl md:text-3xl text-dorado leading-none shrink-0">{step.n}</div>
<div>
<div className="mt-4 md:mt-0 flex items-center gap-3">
<Icono nombre={ICONOS[i]} className="w-6 h-6 text-dorado shrink-0" />
<h3 className="font-sans font-bold text-xl text-azul-profundo">{step.name}</h3>
</div>
<p className="mt-3 md:mt-1 text-gris-medio leading-relaxed">{step.desc}</p>
</div>
</button>
</motion.li>
))}
</motion.ol>
<motion.div variants={fadeInUp} className="mt-12">
<CTA href={waHref} label={comoEmpezar.cta} variant="light" />
</motion.div>
</motion.div>
{/* Foto: amanecer sobre la cumbre del Monte Nardi, Tony 1212, CC BY-SA 4.0,
modificada (duotono) — créditos en el footer. "Tu siguiente movimiento
empieza…": el amanecer como metáfora del comienzo. */}
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12"> className="hidden md:block relative w-72 lg:w-96 aspect-[4/5] rounded-lg overflow-hidden shadow-2xl shadow-azul-profundo/20 rotate-2 motion-reduce:rotate-0">
<CTA href={waHref} label={comoEmpezar.cta} variant="light" /> <div className="absolute inset-0 bg-gradient-to-br from-azul-profundo to-dorado" />
<Image src="/images/comoempezar-bg.jpg" alt="" fill sizes="(min-width: 1024px) 384px, 288px"
className="object-cover mix-blend-luminosity" />
<div className="absolute inset-0 bg-azul-profundo/20" />
</motion.div> </motion.div>
</Container> </Container>
</section> </section>
+16 -7
View File
@@ -1,27 +1,36 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { disciplinas } from '@/lib/content'; import { disciplinas } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
const ICONOS: IconoNombre[] = ['comunicacion', 'digital', 'experiencias'];
export function Disciplinas() { export function Disciplinas() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( return (
<section className="bg-white"> <section className="bg-white">
<Container className="py-24 md:py-32"> <Container className="py-24 md:py-32">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-semibold text-dorado">{disciplinas.eyebrow}</motion.p> className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{disciplinas.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-bold text-3xl md:text-5xl text-azul-profundo">{disciplinas.title}</motion.h2> className="mt-4 font-sans font-black text-4xl md:text-6xl text-azul-profundo tracking-tight">{disciplinas.title}</motion.h2>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 text-gris-medio text-lg">{disciplinas.lead}</motion.p> className="mt-4 text-gris-medio text-lg">{disciplinas.lead}</motion.p>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-8 md:grid-cols-3 list-none p-0"> className="mt-12 grid gap-6 md:grid-cols-3 list-none p-0">
{disciplinas.items.map((item) => ( {disciplinas.items.map((item, i) => (
<motion.li key={item.name} variants={fadeInUp} <motion.li key={item.name} variants={fadeInUp}
className="border-t-2 border-dorado pt-5 transition-all duration-300 ease-out hover:-translate-y-1.5 hover:border-dorado motion-reduce:transform-none motion-reduce:transition-none"> className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
<h3 className="font-sans font-bold text-2xl text-azul-profundo">{item.name}</h3> <TarjetaSpotlight className="h-full rounded-lg bg-azul-profundo text-white p-7">
<p className="mt-3 text-gris-medio leading-relaxed">{item.desc}</p> <div className="grid place-items-center w-14 h-14 rounded-full bg-white/10">
<Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
</div>
<h3 className="mt-5 font-sans font-black text-2xl">{item.name}</h3>
<p className="mt-3 text-gris-medio leading-relaxed">{item.desc}</p>
</TarjetaSpotlight>
</motion.li> </motion.li>
))} ))}
</motion.ul> </motion.ul>
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { dolores } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Dolores() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-white">
<Container className="py-24 md:py-32">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{dolores.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-4xl md:text-6xl text-azul-profundo leading-[0.95] max-w-3xl tracking-tight">{dolores.title}</motion.h2>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-14 grid gap-8 md:grid-cols-2 list-none p-0">
{dolores.quotes.map((quote) => (
<motion.li key={quote} variants={fadeInUp}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
<TarjetaSpotlight className="h-full rounded-lg bg-gris-claro shadow-lg shadow-azul-profundo/5 border-l-4 border-dorado p-7">
<blockquote className="font-sans italic text-xl md:text-2xl leading-snug text-azul-profundo">
<span className="text-dorado not-italic">“</span>
{quote}
<span className="text-dorado not-italic">”</span>
</blockquote>
</TarjetaSpotlight>
</motion.li>
))}
</motion.ul>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-16 font-sans font-black text-3xl md:text-5xl leading-[1.05] text-azul-profundo max-w-4xl tracking-tight">
{dolores.remate.before}{' '}
<span className="text-dorado">{dolores.remate.accent}</span>
</motion.p>
</Container>
</section>
);
}
+19 -6
View File
@@ -1,6 +1,9 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { PatronPuntos } from '@/components/primitives/PatronPuntos';
import { Wordmark } from '@/components/primitives/Wordmark';
import { ContadorNumero } from '@/components/primitives/ContadorNumero';
import { estratega } from '@/lib/content'; import { estratega } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -11,15 +14,25 @@ export function Estratega() {
<Container className="py-24 md:py-32"> <Container className="py-24 md:py-32">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center"> className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
{/* Portrait — placeholder gradient until dueña provides a photo */} {/* TODO dueña: real portrait. Until then, a branded placeholder built
from the manual's dot pattern rather than a stand-in stock photo. */}
<motion.div variants={fadeInUp} <motion.div variants={fadeInUp}
className="w-40 h-40 md:w-56 md:h-56 rounded-full flex-shrink-0 bg-gradient-to-br from-azul-profundo to-dorado" className="w-44 h-44 md:w-64 md:h-64 rounded-full flex-shrink-0 overflow-hidden relative bg-azul-profundo grid place-items-center ring-4 ring-dorado ring-offset-4"
aria-label="Retrato de la estratega" aria-label="Retrato de la estratega — pendiente"
role="img" role="img"
/> >
<PatronPuntos rows={4} cols={7} className="absolute inset-0 m-auto w-[150%] h-auto text-white/15" />
<span className="relative"><Wordmark size="sm" onDark /></span>
</motion.div>
<div> <div>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-semibold text-dorado">{estratega.eyebrow}</motion.p> <motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-bold text-3xl md:text-5xl text-azul-profundo">{estratega.title}</motion.h2> {estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
</motion.p>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-4xl md:text-6xl text-azul-profundo tracking-tight">{estratega.title}</motion.h2>
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
<ContadorNumero hasta={30} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
<p className="text-xs uppercase tracking-[0.2em] text-gris-medio font-semibold max-w-[8rem] leading-tight">años construyendo marcas</p>
</motion.div>
<motion.p variants={fadeInUp} className="mt-6 text-lg text-gris-medio leading-relaxed max-w-2xl">{estratega.bio}</motion.p> <motion.p variants={fadeInUp} className="mt-6 text-lg text-gris-medio leading-relaxed max-w-2xl">{estratega.bio}</motion.p>
<motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4"> <motion.blockquote variants={fadeInUp} className="mt-6 font-sans italic text-xl md:text-2xl text-azul-profundo border-l-4 border-dorado pl-4">
{estratega.quote} {estratega.quote}
+17 -9
View File
@@ -2,27 +2,35 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { SimboloTresLineas } from '@/components/primitives/SimboloTresLineas';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { fraseAncla } from '@/lib/content'; import { fraseAncla } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function FraseAncla() { export function FraseAncla() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( return (
<section className="bg-white"> <section className="bg-azul-profundo relative overflow-hidden">
<Container className="py-24 md:py-32 text-center max-w-4xl"> <Container className="relative py-28 md:py-40 text-center max-w-5xl">
<motion.blockquote <motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex justify-center">
<SimboloTresLineas className="pointer-events-none select-none w-14 text-dorado" />
</motion.div>
<motion.p
variants={fadeInUp} variants={fadeInUp}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
viewport={viewportOnce} viewport={viewportOnce}
{...motionProps(reduced)} {...motionProps(reduced)}
className="font-sans italic text-2xl md:text-4xl font-bold leading-snug text-azul-profundo" className="mt-10 font-sans font-black text-4xl md:text-6xl leading-[0.95] tracking-tight text-white"
> >
<span className="text-dorado">“</span> <TextoDesenfoque
{fraseAncla.quote.before}{' '} segments={[
<span className="text-dorado">{fraseAncla.quote.accent}</span> { text: fraseAncla.quote.before },
<span className="text-dorado">”</span> { text: fraseAncla.quote.accent, className: 'text-dorado' },
</motion.blockquote> ]}
/>
</motion.p>
</Container> </Container>
</section> </section>
); );
+51 -20
View File
@@ -4,7 +4,11 @@ import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark'; import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA'; import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { hero } from '@/lib/content'; import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { Carrusel } from '@/components/primitives/Carrusel';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { hero, dolores } from '@/lib/content';
import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
type Variant = 'hormozi' | 'editorial'; type Variant = 'hormozi' | 'editorial';
@@ -15,28 +19,55 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const headlineBase = isHormozi const headlineBase = isHormozi
? 'font-sans font-black uppercase tracking-tight' ? 'font-sans font-black uppercase tracking-tight'
: 'font-sans font-bold'; : 'font-sans font-bold';
const headlineSize = 'text-4xl md:text-6xl leading-[1.1]'; const headlineSize = 'text-5xl md:text-7xl leading-[0.98] tracking-tight';
const clarityBorder = isHormozi ? 'border-b-[5px] border-dorado pb-0.5' : 'border-b-[3px] border-dorado pb-0.5';
return ( return (
<section className="bg-white"> <section className="bg-white relative overflow-hidden">
<Container className="min-h-[88vh] flex flex-col items-center justify-center text-center py-24"> <FondoPuntos className="top-24 -right-16 w-72 md:w-[28rem] text-azul-profundo/10" interactivo />
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)} className="flex flex-col items-center"> <FondoPuntos className="bottom-16 -left-20 w-64 md:w-96 text-dorado/20" distance={28} rows={2} interactivo />
<motion.div variants={fadeInUp}><Wordmark size="xl" /></motion.div> <Container className="relative min-h-[88vh] flex items-center py-24">
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-semibold text-dorado"> <div className="grid gap-14 lg:grid-cols-[1.1fr_0.9fr] lg:items-center w-full">
{hero.eyebrow} <motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)}
</motion.p> className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}> <motion.div variants={fadeInUp}><Wordmark size="xl" showTagline /></motion.div>
{hero.headline.line1}{' '} <motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-black text-dorado">
<span className="text-dorado">{hero.headline.effort}</span>{' '} {hero.eyebrow}
{hero.headline.line3}{' '} </motion.p>
<span style={{ borderBottom: isHormozi ? '5px solid #C4942E' : '3px solid #C4942E', paddingBottom: 2 }}> <motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
{hero.headline.clarity} <TextoDesenfoque
</span> as="span"
</motion.h1> segments={[
<motion.div variants={fadeInUp} className="mt-10"> { text: hero.headline.line1 },
<CTA href="#contacto" label={hero.cta} variant="light" /> { text: hero.headline.effort, className: 'text-dorado' },
{ text: hero.headline.line3 },
{ text: hero.headline.clarity, className: clarityBorder },
]}
/>
</motion.h1>
<motion.div variants={fadeInUp} className="mt-10 flex flex-col md:flex-row items-center gap-4">
<CTA href="#contacto" label={hero.cta} variant="light" className="md:whitespace-nowrap" />
<CTA href="#mecanismo" label={hero.ctaSecundario} variant="ghost" className="md:whitespace-nowrap" />
</motion.div>
</motion.div> </motion.div>
</motion.div> {/* Rota las frases textuales de `dolores` — quien llega al hero se ve
reflejado antes de haber bajado a leer la sección completa. */}
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)}
className="hidden lg:block min-w-0">
<Carrusel
ariaLabel="Lo que sienten nuestros clientes"
autoplayMs={4500}
slides={dolores.quotes.map((quote) => (
<TarjetaSpotlight key={quote} className="rounded-2xl border border-azul-profundo/10 bg-white shadow-xl shadow-azul-profundo/5 p-8 lg:p-10 min-h-[15rem] flex flex-col justify-center">
<p className="text-xs uppercase tracking-[0.3em] font-semibold text-dorado">lo que escuchamos</p>
<blockquote className="mt-4 font-sans italic text-xl lg:text-2xl leading-snug text-azul-profundo">
<span className="text-dorado not-italic">&ldquo;</span>{quote}<span className="text-dorado not-italic">&rdquo;</span>
</blockquote>
</TarjetaSpotlight>
))}
/>
</motion.div>
</div>
</Container> </Container>
</section> </section>
); );
+64 -12
View File
@@ -2,26 +2,78 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { mecanismo } from '@/lib/content'; import { mecanismo } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps, useAutoStep } from '@/lib/motion';
const ICONOS: IconoNombre[] = ['inmersion', 'desafiar', 'estrategia'];
export function Mecanismo() { export function Mecanismo() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
const { active, setActive, hoverProps } = useAutoStep(mecanismo.steps.length, 3200);
return ( return (
<section className="bg-azul-profundo text-white"> <section id="mecanismo" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
<Container className="py-24 md:py-32"> {/* Foto: Golden Gate Bridge, Dietmar Rabich, CC BY-SA 4.0, modificada (duotono) — créditos en el footer */}
<FondoFoto src="/images/mecanismo-bg.jpg" className="inset-0 opacity-25" wash="bg-azul-profundo/80" />
<Container className="relative py-24 md:py-32">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-semibold text-dorado">{mecanismo.eyebrow}</motion.p> className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{mecanismo.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-bold text-3xl md:text-5xl">{mecanismo.title}</motion.h2> className="mt-4 font-sans font-black text-4xl md:text-6xl tracking-tight">{mecanismo.title}</motion.h2>
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
{/* Conector de pasos: avanza solo cada ~3s (se pausa con hover o si el
usuario elige un paso), y adelanta la línea de progreso — el mismo
lenguaje de "proceso conectado" de en-ti.com. */}
<div {...hoverProps} className="mt-14 hidden md:flex items-center" role="tablist" aria-label="Pasos del mecanismo">
{mecanismo.steps.map((step, i) => (
<div key={step.n} className="flex items-center flex-1 last:flex-none">
<button
type="button"
role="tab"
aria-selected={active === i}
aria-label={`Paso ${step.n}: ${step.name}`}
onClick={() => setActive(i)}
onMouseEnter={() => setActive(i)}
className={`shrink-0 grid place-items-center w-14 h-14 rounded-full border-2 font-sans font-black text-lg transition-colors duration-300 ${
active === i ? 'bg-dorado border-dorado text-azul-profundo' : 'border-white/25 text-white/60 hover:border-dorado/60 hover:text-white'
}`}
>
{step.n}
</button>
{i < mecanismo.steps.length - 1 && (
<div className="flex-1 h-px mx-2 bg-white/15 relative overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 w-full origin-left bg-dorado motion-reduce:transition-none"
initial={false}
animate={{ scaleX: active > i ? 1 : 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
)}
</div>
))}
</div>
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} {...hoverProps}
className="mt-12 grid gap-10 md:grid-cols-3"> className="mt-12 grid gap-10 md:grid-cols-3">
{mecanismo.steps.map((step) => ( {mecanismo.steps.map((step, i) => (
<motion.li key={step.n} variants={fadeInUp} <motion.li key={step.n} variants={fadeInUp}>
className="border-t border-dorado/30 pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 hover:border-dorado/70 motion-reduce:transform-none motion-reduce:transition-none"> <button
<div className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none">{step.n}</div> type="button"
<h3 className="mt-4 font-sans font-bold text-xl">{step.name}</h3> onClick={() => setActive(i)}
<p className="mt-3 text-gris-medio leading-relaxed">{step.desc}</p> aria-pressed={active === i}
className={`w-full text-left border-t pt-6 transition-all duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none ${
active === i ? 'border-dorado md:-translate-y-1.5' : 'border-dorado/30 md:opacity-40'
}`}
>
<div className="flex items-center justify-between">
<div className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none">{step.n}</div>
<Icono nombre={ICONOS[i]} className="w-10 h-10 md:w-12 md:h-12 text-white/70" />
</div>
<h3 className="mt-4 font-sans font-bold text-xl">{step.name}</h3>
<p className="mt-3 text-gris-medio leading-relaxed">{step.desc}</p>
</button>
</motion.li> </motion.li>
))} ))}
</motion.ol> </motion.ol>
+30 -12
View File
@@ -8,23 +8,41 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function ParaQuien() { export function ParaQuien() {
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
return ( return (
<section className="bg-white"> <section className="bg-white relative overflow-hidden">
<Container className="py-24 md:py-32"> <Container className="relative pt-24 md:pt-32 pb-12 md:pb-16">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} className="max-w-3xl"> <motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
<motion.h2 variants={fadeInUp} className="font-sans font-bold text-3xl md:text-5xl text-azul-profundo leading-tight"> className="font-sans font-black text-4xl md:text-6xl text-azul-profundo leading-[0.95] max-w-4xl tracking-tight">{paraQuien.title}</motion.h2>
{paraQuien.title} </Container>
</motion.h2> {/* Pantalla partida: luz contra oscuridad, para dramatizar la bifurcación
<motion.p variants={fadeInUp} className="mt-6 text-lg text-gris-medio">{paraQuien.lead}</motion.p> "sí es para ti / no es para ti" — sale del ancho del Container a
<motion.ul variants={fadeInUp} className="mt-8 space-y-3"> propósito, a todo lo ancho de la ventana. */}
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid md:grid-cols-2">
<motion.div variants={fadeInUp} className="bg-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-dorado">
<p className="text-xs tracking-[0.3em] uppercase font-black text-dorado">Sí</p>
<p className="mt-3 text-2xl font-sans font-bold text-azul-profundo max-w-sm">{paraQuien.lead}</p>
<ul className="mt-8 space-y-4">
{paraQuien.criteria.map((c) => ( {paraQuien.criteria.map((c) => (
<li key={c} className="flex items-start gap-3"> <li key={c} className="flex items-start gap-3">
<span className="text-dorado font-bold mt-1" aria-hidden="true">✓</span> <span aria-hidden="true" className="grid place-items-center w-7 h-7 rounded-full bg-dorado text-azul-profundo font-black text-sm shrink-0">✓</span>
<span className="text-azul-profundo text-lg">{c}</span> <span className="text-azul-profundo text-lg leading-snug">{c}</span>
</li> </li>
))} ))}
</motion.ul> </ul>
</motion.div> </motion.div>
</Container> <motion.div variants={fadeInUp} className="bg-azul-profundo text-white px-6 md:px-16 py-16 md:py-20 border-t-4 border-white/10">
<p className="text-xs tracking-[0.3em] uppercase font-black text-white/50">No</p>
<p className="mt-3 text-2xl font-sans font-bold text-white/70 max-w-sm">{paraQuien.leadNo}</p>
<ul className="mt-8 space-y-4">
{paraQuien.criteriaNo.map((c) => (
<li key={c} className="flex items-start gap-3">
<span aria-hidden="true" className="grid place-items-center w-7 h-7 rounded-full border border-white/30 text-white/50 font-black text-sm shrink-0">✕</span>
<span className="text-white/60 text-lg leading-snug">{c}</span>
</li>
))}
</ul>
</motion.div>
</motion.div>
</section> </section>
); );
} }
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { Carrusel } from '@/components/primitives/Carrusel';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
import { puntosInflexion } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
function Tarjeta({ n, name, desc }: { n: number; name: string; desc: string }) {
return (
<TarjetaSpotlight className="relative h-full rounded-md border-t-2 border-dorado pt-5 pl-1 pr-1 pb-1 overflow-visible">
<span aria-hidden="true" className="pointer-events-none absolute -top-2 right-0 font-sans font-black text-5xl text-azul-profundo/[0.06] leading-none select-none">
{String(n).padStart(2, '0')}
</span>
<h3 className="relative font-sans font-bold text-xl text-azul-profundo">{name}</h3>
<p className="relative mt-2 text-gris-medio leading-relaxed">{desc}</p>
</TarjetaSpotlight>
);
}
export function PuntosInflexion() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro relative overflow-hidden">
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
<Container className="relative py-24 md:py-32">
<div className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
<div>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-4xl md:text-6xl text-azul-profundo leading-[0.95] tracking-tight max-w-3xl">{puntosInflexion.title}</motion.h2>
</div>
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 md:mt-0 shrink-0">
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
</motion.div>
</div>
{/* Carrusel táctil en móvil (contador "01 / 06", como en-ti.com); grid fijo desde md. */}
<div className="mt-12 md:hidden">
<Carrusel
ariaLabel="Los seis puntos de inflexión"
mostrarContador
slides={puntosInflexion.items.map((item, i) => <Tarjeta key={item.name} n={i + 1} {...item} />)}
slideClassName="pr-4"
/>
</div>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 hidden md:grid md:grid-cols-3 md:gap-8 list-none p-0">
{puntosInflexion.items.map((item, i) => (
<motion.li key={item.name} variants={fadeInUp}
className="transition-transform duration-300 ease-out hover:-translate-y-1.5 motion-reduce:transform-none motion-reduce:transition-none">
<Tarjeta n={i + 1} {...item} />
</motion.li>
))}
</motion.ul>
</Container>
</section>
);
}
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ComoEmpezar } from '../ComoEmpezar'; import { ComoEmpezar } from '../ComoEmpezar';
describe('<ComoEmpezar />', () => { describe('<ComoEmpezar />', () => {
@@ -15,4 +15,26 @@ describe('<ComoEmpezar />', () => {
expect(href).toMatch(/^https:\/\/wa\.me\//); expect(href).toMatch(/^https:\/\/wa\.me\//);
expect(href).toContain('text='); expect(href).toContain('text=');
}); });
it('renders the sunrise photo panel as decorative, not real content', () => {
const { container } = render(<ComoEmpezar />);
const img = container.querySelector('img');
// next/image rewrites `src` through its loader in this test environment;
// the static export (images.unoptimized: true) serves the plain path.
expect(img?.getAttribute('src')).toContain('comoempezar-bg.jpg');
expect(img).toHaveAttribute('alt', '');
});
describe('auto-advancing step highlight', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('highlights the first step by default, then advances on its own', () => {
render(<ComoEmpezar />);
const first = screen.getByText('Escríbeme').closest('button');
const second = screen.getByText('Sesión de diagnóstico').closest('button');
expect(first).toHaveAttribute('aria-pressed', 'true');
act(() => { vi.advanceTimersByTime(3300); });
expect(second).toHaveAttribute('aria-pressed', 'true');
});
});
}); });
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Dolores } from '../Dolores';
describe('<Dolores />', () => {
it('renders the four pains verbatim, in the client\'s first person', () => {
render(<Dolores />);
expect(screen.getByText(/Trabajo más y gano casi lo mismo que hace dos años\./)).toBeInTheDocument();
expect(screen.getByText(/no sé en cuál enfocar mi tiempo y mi dinero\./)).toBeInTheDocument();
expect(screen.getByText(/no puede crecer si yo no estoy en cada decisión\./)).toBeInTheDocument();
expect(screen.getByText(/ya no me sirve para el siguiente nivel\./)).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(4);
});
it('closes with the resolution, not with more effort', () => {
const { container } = render(<Dolores />);
expect(container.textContent).toContain('Esto no se resuelve con más esfuerzo.');
expect(container.textContent).toContain('Se resuelve con una estrategia clara.');
});
it('does NOT introduce scarcity', () => {
const { container } = render(<Dolores />);
expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
});
});
@@ -11,4 +11,12 @@ describe('<Estratega />', () => {
render(<Estratega />); render(<Estratega />);
expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument(); expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument();
}); });
it('never lets CSS uppercase turn the brand name into CREEER', () => {
const { container } = render(<Estratega />);
const brand = screen.getByText('creeer');
expect(brand.className).toContain('normal-case');
// the eyebrow around it may be uppercase, the brand name may not
expect(brand.closest('p')?.className).toContain('uppercase');
expect(container.textContent).not.toMatch(/CREEER|Creeer/);
});
}); });
@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react'; import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { FraseAncla } from '../FraseAncla'; import { FraseAncla } from '../FraseAncla';
describe('<FraseAncla />', () => { describe('<FraseAncla />', () => {
it('renders the anchor quote', () => { it('renders the anchor quote', () => {
render(<FraseAncla />); const { container } = render(<FraseAncla />);
expect(screen.getByText(/Las empresas no dejan de crecer/i)).toBeInTheDocument(); expect(container.textContent).toMatch(/Las empresas no dejan de crecer/i);
expect(screen.getByText(/falta de claridad/i)).toBeInTheDocument(); expect(container.textContent).toMatch(/falta de claridad/i);
}); });
}); });
+14 -5
View File
@@ -8,17 +8,26 @@ describe('<Hero />', () => {
expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument(); expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument();
}); });
it('renders the headline copy', () => { it('renders the headline copy', () => {
render(<Hero variant="hormozi" />); const { container } = render(<Hero variant="hormozi" />);
expect(screen.getByText(/Tu negocio/i)).toBeInTheDocument(); expect(container.textContent).toMatch(/Tu negocio/i);
expect(screen.getByText(/más esfuerzo/i)).toBeInTheDocument(); expect(container.textContent).toMatch(/más esfuerzo/i);
expect(screen.getByText(/más claridad/i)).toBeInTheDocument(); expect(container.textContent).toMatch(/más claridad/i);
}); });
it('renders the CTA', () => { it('renders the CTA', () => {
render(<Hero variant="editorial" />); render(<Hero variant="editorial" />);
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toHaveAttribute('href', '#contacto');
});
it('offers a secondary route into the process for readers not ready to convert', () => {
render(<Hero variant="editorial" />);
expect(screen.getByRole('link', { name: /ver cómo trabajo/i })).toHaveAttribute('href', '#mecanismo');
}); });
it('does NOT render scarcity (cupos/cohorte)', () => { it('does NOT render scarcity (cupos/cohorte)', () => {
const { container } = render(<Hero variant="editorial" />); const { container } = render(<Hero variant="editorial" />);
expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i); expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
}); });
it('mirrors the client\'s own words back in a rotating carousel', () => {
render(<Hero variant="editorial" />);
expect(screen.getByRole('group', { name: /lo que sienten nuestros clientes/i })).toBeInTheDocument();
expect(screen.getByText(/Trabajo más y gano casi lo mismo/i)).toBeInTheDocument();
});
}); });
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Mecanismo } from '../Mecanismo'; import { Mecanismo } from '../Mecanismo';
describe('<Mecanismo />', () => { describe('<Mecanismo />', () => {
@@ -13,4 +13,31 @@ describe('<Mecanismo />', () => {
const { container } = render(<Mecanismo />); const { container } = render(<Mecanismo />);
expect(container.textContent).toMatch(/01.+02.+03/); expect(container.textContent).toMatch(/01.+02.+03/);
}); });
it('keeps the background photo subtle enough not to fight the text', () => {
const { container } = render(<Mecanismo />);
const bg = container.querySelector('img');
expect(bg).toHaveAttribute('src', '/images/mecanismo-bg.jpg');
expect(bg?.closest('[class*="opacity-"]')).not.toBeNull();
});
describe('auto-advancing step indicator', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('highlights the first step by default, then advances on its own', () => {
render(<Mecanismo />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
act(() => { vi.advanceTimersByTime(3300); });
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
});
it('stops advancing while the pointer is over the steps', () => {
render(<Mecanismo />);
const tabs = screen.getAllByRole('tab');
fireEvent.mouseEnter(screen.getByRole('tablist'));
act(() => { vi.advanceTimersByTime(6000); });
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
});
});
}); });
@@ -7,10 +7,23 @@ describe('<ParaQuien />', () => {
render(<ParaQuien />); render(<ParaQuien />);
expect(screen.getByText(/punto de inflexión/i)).toBeInTheDocument(); expect(screen.getByText(/punto de inflexión/i)).toBeInTheDocument();
}); });
it('renders all 3 criteria', () => { it('renders the qualifying criteria', () => {
render(<ParaQuien />); render(<ParaQuien />);
expect(screen.getByText(/tienes tracción/i)).toBeInTheDocument(); expect(screen.getByText(/tienes tracción/i)).toBeInTheDocument();
expect(screen.getByText(/trabajar más ya no rinde/i)).toBeInTheDocument(); expect(screen.getByText(/trabajar más ya no rinde/i)).toBeInTheDocument();
expect(screen.getByText(/necesitas claridad/i)).toBeInTheDocument(); expect(screen.getByText(/necesitas claridad/i)).toBeInTheDocument();
expect(screen.getByText(/sin perder tu esencia/i)).toBeInTheDocument();
});
it('renders the disqualifying criteria from the dueña deck', () => {
render(<ParaQuien />);
expect(screen.getByText(/Esto no es para ti si/i)).toBeInTheDocument();
expect(screen.getByText(/fase inicial o sin tracción/i)).toBeInTheDocument();
expect(screen.getByText(/solución rápida de venta/i)).toBeInTheDocument();
expect(screen.getByText(/un curso, no una transformación/i)).toBeInTheDocument();
expect(screen.getByText(/crisis de supervivencia/i)).toBeInTheDocument();
});
it('keeps the disqualifiers impersonal, so they read as a profile and not an accusation', () => {
render(<ParaQuien />);
expect(screen.getByText(/fase inicial o sin tracción/i).textContent).not.toMatch(/\btú\b|\btu\b/i);
}); });
}); });
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { PuntosInflexion } from '../PuntosInflexion';
describe('<PuntosInflexion />', () => {
it('renders the section title', () => {
render(<PuntosInflexion />);
expect(screen.getByText(/llegan a un punto de inflexión/i)).toBeInTheDocument();
});
it('renders the six inflection points from the dueña deck', () => {
render(<PuntosInflexion />);
// Each point renders twice in the markup — once in the mobile carousel,
// once in the desktop grid — with CSS `hidden`/`md:` toggling which one
// is actually visible at a given breakpoint.
for (const name of ['Crecer', 'Consolidarse', 'Recuperar rentabilidad', 'Simplificar', 'Expandirse', 'Replantear el portafolio']) {
expect(screen.getAllByText(name).length).toBeGreaterThanOrEqual(1);
}
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});
it('does NOT introduce scarcity', () => {
const { container } = render(<PuntosInflexion />);
expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
});
});
+60 -1
View File
@@ -5,6 +5,12 @@ export const wordmark = {
parts: { before: 'cre', accent: '≡', after: 'er' }, parts: { before: 'cre', accent: '≡', after: 'er' },
}; };
export const brandPillars = {
pillar1: "Claridad",
pillar2: "Dirección",
pillar3: "Crecimiento",
};
export const hero = { export const hero = {
eyebrow: '— tu estratega de crecimiento —', eyebrow: '— tu estratega de crecimiento —',
// Headline broken into segments for emphasis styling // Headline broken into segments for emphasis styling
@@ -15,6 +21,7 @@ export const hero = {
clarity: 'más claridad.', // underlined gold clarity: 'más claridad.', // underlined gold
}, },
cta: 'Define tu siguiente movimiento', cta: 'Define tu siguiente movimiento',
ctaSecundario: 'Ver cómo trabajo',
}; };
export const fraseAncla = { export const fraseAncla = {
@@ -25,6 +32,39 @@ export const fraseAncla = {
}, },
}; };
// The six inflection points, verbatim from the dueña's deck
// "Rumbo Estratégico · Propuesta Socio" (slide 2).
export const puntosInflexion = {
eyebrow: 'el contexto',
title: 'Todos los negocios consolidados llegan a un punto de inflexión',
items: [
{ name: 'Crecer', desc: 'Dar el siguiente salto sin saber por dónde.' },
{ name: 'Consolidarse', desc: 'Ordenar lo construido antes de escalar.' },
{ name: 'Recuperar rentabilidad', desc: 'El esfuerzo ya no se traduce en resultado.' },
{ name: 'Simplificar', desc: 'Demasiada complejidad operativa.' },
{ name: 'Expandirse', desc: 'Nuevos mercados, mismo mapa de decisión.' },
{ name: 'Replantear el portafolio', desc: 'Qué seguir, qué dejar ir.' },
],
};
// Slide 3 of the same deck. The four quotes are the client's own words —
// keep them verbatim, in first person.
export const dolores = {
eyebrow: 'lo que realmente sientes',
title: 'Los dolores detrás del punto de inflexión',
quotes: [
'Trabajo más y gano casi lo mismo que hace dos años.',
'Tengo varios proyectos y no sé en cuál enfocar mi tiempo y mi dinero.',
'Mi empresa no puede crecer si yo no estoy en cada decisión.',
'Lo que me trajo hasta aquí ya no me sirve para el siguiente nivel.',
],
// Split so the resolution renders in gold.
remate: {
before: 'Esto no se resuelve con más esfuerzo.',
accent: 'Se resuelve con una estrategia clara.',
},
};
export const paraQuien = { export const paraQuien = {
title: '¿Llegaste a tu punto de inflexión?', title: '¿Llegaste a tu punto de inflexión?',
lead: 'Esto es para ti si…', lead: 'Esto es para ti si…',
@@ -32,6 +72,16 @@ export const paraQuien = {
'tienes tracción y ya demostraste que sabes crecer', 'tienes tracción y ya demostraste que sabes crecer',
'trabajar más ya no rinde como antes', 'trabajar más ya no rinde como antes',
'necesitas claridad para decidir el siguiente movimiento', 'necesitas claridad para decidir el siguiente movimiento',
'quieres profesionalizar sin perder tu esencia',
],
// Deck slide 9, "NO ES PARA". Kept impersonal on purpose: this disqualifies
// a profile, it does not accuse the reader.
leadNo: 'Esto no es para ti si…',
criteriaNo: [
'el negocio está en fase inicial o sin tracción',
'buscas una solución rápida de venta',
'buscas un curso, no una transformación',
'la empresa está en crisis de supervivencia, no en transición',
], ],
}; };
@@ -57,7 +107,9 @@ export const disciplinas = {
}; };
export const estratega = { export const estratega = {
eyebrow: 'la mente detrás de creeer', // Split so the brand name keeps its lowercase inside an uppercase eyebrow —
// CSS text-transform would otherwise render it as CREEER.
eyebrow: { before: 'la mente detrás de', brand: 'creeer' },
title: 'Sobre la estratega', title: 'Sobre la estratega',
bio: 'Más de 30 años construyendo marcas. Hoy acompaño a empresarios que ya lograron tracción y necesitan claridad para su siguiente movimiento.', bio: 'Más de 30 años construyendo marcas. Hoy acompaño a empresarios que ya lograron tracción y necesitan claridad para su siguiente movimiento.',
quote: 'El tiempo no se compra, se invierte.', quote: 'El tiempo no se compra, se invierte.',
@@ -94,3 +146,10 @@ export const footer = {
eslogan: 'Estrategia que transforma.', eslogan: 'Estrategia que transforma.',
legal: '© 2026 creeer', legal: '© 2026 creeer',
}; };
// CC BY-SA 4.0 requires attribution + a link to the licence + noting the
// image was modified (both got a navy/gold duotone treatment).
export const creditosFotos = [
{ desc: 'Foto: Golden Gate Bridge — Dietmar Rabich', href: 'https://commons.wikimedia.org/wiki/File:San_Francisco_(CA,_USA),_Golden_Gate_Bridge_--_2022_--_3023_(bw).jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
{ desc: 'Foto: amanecer, Monte Nardi — Tony 1212', href: 'https://commons.wikimedia.org/wiki/File:Sunrise_over_the_summit_of_Mount_Nardi_from_the_south.jpg', licencia: 'CC BY-SA 4.0', licenciaHref: 'https://creativecommons.org/licenses/by-sa/4.0/' },
];
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB