feat: rediseño «El documento» en curso, contacto real y enlaces externos

Primitivas y contenido del rediseño (escala tipográfica, Revelar,
MarcaSeccion, Seccion, Antetitulo, SimboloMarca, Resplandor, ClaridadHero,
sección Proceso de ocho etapas, lib/content.ts reestructurado en seis
secciones con alias para las viejas). Mecanismo y FormaTrabajar se retiran.

Encima, lo de esta sesión:
- recorrido: creeer vuelve a responder cada situación en vez de repetirla
  (el alias apuntaba respuesta a situacion); respuestas en plural. Créditos
  de las seis fotos de México.
- contacto: WhatsApp, email (con mailto:) y LinkedIn reales; iconos de las
  redes en el cierre y el footer (IconoRed, trazos de Simple Icons, CC0).
- enlaces: lo externo abre en otra pestaña con noopener y aviso para
  lectores de pantalla (lib/enlaces.ts); mailto y anclas se quedan.
- Proceso: las ocho tarjetas no aparecían nunca en móvil (amount 0.3 sobre
  una lista de ~3000px); ahora cada una se revela sola. El ≡ de la 08 ya no
  tapa un entregable, título equilibrado, etiqueta a 12px, sin elevar al
  hover lo que no es clicable.
- Disciplinas: la frase «No son la promesa — son los medios» gana peso y
  se agrupa con título e ilustración; sin cortes huérfanos en móvil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikghandClaude Opus 5.5 committed 2026-09-30 11:27:20 -06:00
1 parent 3242a8bf71
commit 89e54a6e84
73 files changed
+5261 -592

No files matched your search

+16 -5
View File
@@ -3,16 +3,20 @@ import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { CTA } from '@/components/primitives/CTA';
import { Wordmark } from '@/components/primitives/Wordmark';
import { IconoRed, NOMBRES_RED } from '@/components/primitives/IconoRed';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { Resplandor } from '@/components/primitives/Resplandor';
import { cierre } from '@/lib/content';
import { esExterno, propsEnlace, AVISO_PESTANA } from '@/lib/enlaces';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Cierre() {
const reduced = usePrefersReducedMotion();
const { whatsapp, email, linkedin } = cierre.contact;
const { whatsapp } = cierre.contact;
return (
<section id="contacto" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
<section id="contacto" className="bg-azul-profundo bg-hondo-navy text-white scroll-mt-24 relative overflow-hidden">
<FondoPuntos className="top-12 -left-16 w-72 md:w-[30rem] text-dorado/25" distance={36} />
<Resplandor className="-top-32 left-1/2 -translate-x-1/2 w-[46rem] h-[46rem]" />
<Container className="relative py-16 md:py-24 text-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex flex-col items-center">
@@ -26,9 +30,16 @@ export function Cierre() {
</motion.div>
<motion.nav variants={fadeInUp} aria-label="Contacto" className="mt-14">
<ul className="flex flex-wrap justify-center gap-3 text-sm">
<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><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={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>
{NOMBRES_RED.map((red) => (
<li key={red}>
<a href={cierre.contact[red].href} {...propsEnlace(cierre.contact[red].href)}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-white/20 hover:border-dorado hover:text-dorado transition-colors">
<IconoRed nombre={red} className="w-4 h-4 shrink-0" />
{cierre.contact[red].label}
{esExterno(cierre.contact[red].href) && <span className="sr-only"> ({AVISO_PESTANA})</span>}
</a>
</li>
))}
</ul>
</motion.nav>
</motion.div>
+4 -3
View File
@@ -3,6 +3,7 @@
import { motion, useScroll, useTransform } from 'framer-motion';
import Image from 'next/image';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { CTA } from '@/components/primitives/CTA';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { comoEmpezar, cierre } from '@/lib/content';
@@ -18,10 +19,10 @@ export function ComoEmpezar() {
const { scrollYProgress } = useScroll();
const desplazamiento = useTransform(scrollYProgress, [0, 1], [20, -20]);
return (
<section className="bg-white">
<section className="bg-white bg-hondo-blanco">
<Container className="py-16 md:py-24 grid gap-12 md:grid-cols-[1fr_auto] md:items-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{comoEmpezar.eyebrow}</motion.p>
<Antetitulo as={motion.p} variants={fadeInUp}>{comoEmpezar.eyebrow}</Antetitulo>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-xl">{comoEmpezar.title}</motion.h2>
<motion.ol variants={staggerContainer} className="mt-12 grid gap-10 sm:grid-cols-3 md:grid-cols-1 md:gap-8">
{comoEmpezar.steps.map((step, i) => (
@@ -38,7 +39,7 @@ export function ComoEmpezar() {
<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>
<p className="mt-3 md:mt-1 text-azul-profundo/70 leading-relaxed">{step.desc}</p>
</div>
</motion.li>
))}
+50 -13
View File
@@ -2,7 +2,9 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Resplandor } from '@/components/primitives/Resplandor';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { IlustracionConvergencia } from '@/components/primitives/IlustracionConvergencia';
import { disciplinas } from '@/lib/content';
@@ -18,16 +20,45 @@ export function Disciplinas() {
// nobody loses anything by not having a mouse.
const [senalada, setSenalada] = useState<number | null>(null);
return (
<section className="bg-white">
<Container className="py-16 md:py-24">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
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)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo">{disciplinas.title}</motion.h2>
// Azul profundo, no blanco: al mover esta sección delante de Proceso
// quedaba pegada a Dolores, que también es blanca, y las dos se fundían.
// Además rompe el tramo de seis secciones claras seguidas que había entre
// FraseAncla y Cierre, y le da a la página un latido oscuro a media altura.
<section className="bg-azul-profundo bg-hondo-navy text-white relative overflow-hidden">
<Resplandor className="-top-40 -right-32 w-[38rem] h-[38rem]" />
<Resplandor className="bottom-[-14rem] -left-40 w-[32rem] h-[32rem]" tono="frio" />
<Container className="relative py-16 md:py-24">
{/* Title and lead on the left, the drawing on the right at the same
height. The lead is the sentence this section exists for — Proceso
sits after it so it lands first — and it used to be small grey
text lost in the gap under the title, with the drawing floating
loose below. */}
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 md:flex md:items-center md:justify-between md:gap-10">
<p className="text-gris-medio text-lg">{disciplinas.lead}</p>
<IlustracionConvergencia className="mt-6 md:mt-0 w-56 md:w-64 h-auto shrink-0" activa={senalada} />
className="md:flex md:items-end md:justify-between md:gap-12">
<div>
{/* sobre="oscuro": la sección es azul profundo, así que aquí el dorado
del manual se queda tal cual — 6,07:1. */}
<Antetitulo sobre="oscuro" as="p">
{disciplinas.eyebrow}
</Antetitulo>
<h2 className="mt-4 font-sans font-black text-seccion text-balance">{disciplinas.title}</h2>
<p className="mt-4 text-xl md:text-2xl font-semibold text-white/85">
{/* Two unbreakable halves, the dash closing the first. On a
phone the line used to break wherever it fell — before the
dash, or before «medios» — and left a stray fragment alone. */}
{(() => {
const [antes, despues] = disciplinas.lead.split(' — ');
if (!despues) return disciplinas.lead; // copy without a dash
return (
<>
<span className="whitespace-nowrap">{antes} —</span>{' '}
<span className="whitespace-nowrap">{despues}</span>
</>
);
})()}
</p>
</div>
<IlustracionConvergencia className="mt-8 md:mt-0 w-56 md:w-72 h-auto shrink-0" activa={senalada} />
</motion.div>
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-6 md:grid-cols-3 list-none p-0">
@@ -35,13 +66,19 @@ export function Disciplinas() {
<motion.li key={item.name} variants={fadeInUp}
onPointerEnter={() => setSenalada(i)}
onPointerLeave={() => setSenalada(null)}
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-azul-profundo text-white p-7">
<div className="grid place-items-center w-14 h-14 rounded-full bg-white/10">
// No lift: the cards are not links. What answers the pointer is
// the card's own strand lighting up in the drawing above, plus a
// gold edge so the reader sees which card that strand belongs to.
className="group">
{/* Tarjetas invertidas al pasar la sección a azul: `text-gris-medio`
se leía bien sobre navy, pero sobre blanco cae a ~2,3:1 y no
pasa contraste. La descripción va en azul rebajado. */}
<TarjetaSpotlight className="h-full rounded-xl bg-white text-azul-profundo p-7 shadow-tarjeta border-t-4 border-transparent transition-colors duration-200 group-hover:border-dorado motion-reduce:transition-none">
<div className="grid place-items-center w-14 h-14 rounded-full bg-dorado/15">
<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>
<p className="mt-3 text-azul-profundo/75 leading-relaxed text-pretty">{item.desc}</p>
</TarjetaSpotlight>
</motion.li>
))}
+5 -3
View File
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionNudo } from '@/components/primitives/IlustracionNudo';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { dolores } from '@/lib/content';
@@ -28,15 +29,16 @@ export function Dolores() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
{/* Smaller and a touch stronger than it was: sprawling at 8% across the
whole title it read as a smudge over the headline rather than as a
knot. Stays centred. */}
<IlustracionNudo className="pointer-events-none select-none absolute top-0 left-1/2 -translate-x-1/2 w-56 md:w-80 text-azul-profundo/[0.11]" />
<Container className="relative py-16 md:py-24">
<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>
<Antetitulo as={motion.p} variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
{dolores.eyebrow}
</Antetitulo>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{dolores.title}</motion.h2>
+14 -33
View File
@@ -1,49 +1,30 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Wordmark } from '@/components/primitives/Wordmark';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { ContadorNumero } from '@/components/primitives/ContadorNumero';
import { MarcoRetrato } from '@/components/primitives/MarcoRetrato';
import { estratega } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Estratega() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro">
<section className="bg-gris-claro bg-hondo-claro">
<Container className="py-16 md:py-24">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
{/* TODO dueña: real portrait. The frame is built to take it as-is —
drop an <Image fill /> in place of the placeholder below. */}
<motion.div variants={fadeInUp}>
<MarcoRetrato>
{/* Reads as a brand plate, not as a broken image. The dot grid
that used to sit behind the wordmark looked like the
checkerboard a browser shows for a picture that failed to
load; the ≡ alone, meanwhile, reads as a hamburger menu once
it stands by itself in a circle. The wordmark is neither. */}
<div className="w-full h-full bg-azul-profundo grid place-items-center" role="img" aria-label="Retrato de la estratega — pendiente">
<Wordmark size="md" onDark />
</div>
</MarcoRetrato>
className="max-w-2xl">
<Antetitulo as={motion.p} variants={fadeInUp}>
{estratega.eyebrow.before} <span className="normal-case">{estratega.eyebrow.brand}</span>
</Antetitulo>
<motion.h2 variants={fadeInUp} className="mt-4 font-sans font-black text-seccion text-azul-profundo">{estratega.title}</motion.h2>
<motion.div variants={fadeInUp} className="mt-6 flex items-baseline gap-3">
<ContadorNumero hasta={25} sufijo="+" className="font-sans font-black text-5xl md:text-6xl text-dorado leading-none" />
<p className="text-xs uppercase tracking-[0.14em] text-azul-profundo/70 font-semibold max-w-[12rem] leading-snug">años construyendo marcas</p>
</motion.div>
<div>
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-black text-dorado">
{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-seccion text-azul-profundo">{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" />
{/* 8rem was narrower than the word "construyendo" itself, which
broke the label into three ragged lines beside the number. */}
<p className="text-xs uppercase tracking-[0.14em] text-gris-medio font-semibold max-w-[12rem] leading-snug">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.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}
</motion.blockquote>
</div>
<motion.p variants={fadeInUp} className="mt-6 text-lg text-azul-profundo/70 leading-relaxed">{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">
{estratega.quote}
</motion.blockquote>
</motion.div>
</Container>
</section>
-77
View File
@@ -1,77 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { formaTrabajar } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// Reuses existing icons where the metaphor fits; only 'reloj' is new.
const ICONOS: IconoNombre[] = [
'estrategia', // Metodología — ascending path to a flag
'comunicacion', // Quién participa — dialogue bubbles
'inmersion', // De dónde partimos — diving through layers
'plan', // Qué te llevas — checklist
'reloj', // Duración — clock
];
/**
* "Así es trabajar conmigo" — practical details of the engagement.
* Inspired by en-ti.com's card-based solutions grid, adapted to creeer's
* identity: white cards on gris-claro, dorado accents, TarjetaSpotlight glow.
* Sits between Mecanismo (high-level method) and Disciplinas (the tools).
*/
export function FormaTrabajar() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-gris-claro relative overflow-hidden">
<FondoPuntos className="-top-10 -left-12 w-72 md:w-[28rem] text-azul-profundo/[0.06]" distance={32} />
<Container className="relative py-16 md:py-24">
{/* ── Header ── */}
<motion.p
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-black text-dorado"
>
{formaTrabajar.eyebrow}
</motion.p>
<motion.h2
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo"
>
{formaTrabajar.title}
</motion.h2>
{/* ── Card grid ── */}
<motion.ul
variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 list-none p-0"
>
{formaTrabajar.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"
>
<TarjetaSpotlight className="h-full rounded-2xl border border-white bg-white p-7 transition-[border-color,box-shadow] duration-300 hover:border-dorado/30 hover:shadow-[0_20px_50px_-20px_rgba(196,148,46,0.2)]">
{/* Icon */}
<div className="grid place-items-center w-14 h-14 rounded-full bg-azul-profundo">
<Icono nombre={ICONOS[i]} className="w-7 h-7 text-dorado" />
</div>
{/* Copy */}
<h3 className="mt-5 font-sans font-black text-xl text-azul-profundo">
{item.name}
</h3>
<p className="mt-3 text-gris-medio leading-relaxed text-sm">
{item.desc}
</p>
</TarjetaSpotlight>
</motion.li>
))}
</motion.ul>
</Container>
</section>
);
}
+18 -1
View File
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { Resplandor } from '@/components/primitives/Resplandor';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { fraseAncla } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -10,7 +11,10 @@ import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/
export function FraseAncla() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-azul-profundo relative overflow-hidden">
<section className="bg-azul-profundo bg-hondo-navy relative overflow-hidden">
{/* Centrado: la frase es el momento más quieto de la página, así que la
luz viene de detrás de ella y no de un lado. */}
<Resplandor className="top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[52rem] h-[52rem]" />
<Container className="relative py-20 md:py-28 text-center max-w-5xl">
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex justify-center">
@@ -33,6 +37,19 @@ export function FraseAncla() {
]}
/>
</motion.p>
{/* El "después": la ancla nombra el diagnóstico; esta línea pinta el
resultado en la vida del lector. Sin ella, el único futuro que
existe en la página es el negativo (los dolores). */}
<motion.p
variants={fadeInUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
{...motionProps(reduced)}
className="mt-8 max-w-[42ch] mx-auto text-white/70 leading-relaxed"
>
{fraseAncla.despues}
</motion.p>
</Container>
</section>
);
+26 -38
View File
@@ -1,19 +1,14 @@
'use client';
import { motion } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
import { CTA } from '@/components/primitives/CTA';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { SecuenciaClaridad } from '@/components/primitives/SecuenciaClaridad';
import { TextoDesenfoque } from '@/components/primitives/TextoDesenfoque';
import { hero } from '@/lib/content';
import { fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
import { ClaridadHero } from '@/components/primitives/ClaridadHero';
import { hero, apertura } from '@/lib/content';
type Variant = 'hormozi' | 'editorial';
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const reduced = usePrefersReducedMotion();
const isHormozi = variant === 'hormozi';
const headlineBase = isHormozi
? 'font-sans font-black uppercase tracking-tight'
@@ -22,7 +17,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
const clarityBorder = isHormozi ? 'border-b-[5px] border-dorado pb-0.5' : 'border-b-[3px] border-dorado pb-0.5';
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
{/* Kept clear of the wordmark on narrow screens: at 390px the md field
reached back across the logotype and the tagline, so the pattern read
as collision rather than texture. */}
@@ -30,39 +25,32 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
<FondoPuntos className="bottom-16 -left-20 w-64 md:w-96 text-dorado/20" distance={28} rows={2} />
<Container className="relative min-h-[80vh] lg:min-h-[88vh] flex items-center py-16 md:py-24">
<div className="grid gap-8 lg:gap-14 lg:grid-cols-[1.1fr_0.9fr] lg:items-center w-full">
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)}
className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
<motion.div variants={fadeInUp}><Wordmark size="xl" showTagline /></motion.div>
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-black text-dorado">
{hero.eyebrow}
</motion.p>
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
<TextoDesenfoque
as="span"
segments={[
{ text: hero.headline.line1 },
{ text: hero.headline.effort, className: 'text-dorado' },
{ text: hero.headline.line3 },
{ text: hero.headline.clarity, className: clarityBorder },
]}
/>
</motion.h1>
<div className="min-w-0 flex flex-col items-center text-center lg:items-start lg:text-left">
<Wordmark size="xl" showTagline />
<Antetitulo className="mt-6 md:text-sm">{hero.eyebrow}</Antetitulo>
<h1 className={`mt-8 ${headlineBase} ${headlineSize} text-azul-profundo`}>
{hero.headline.line1}{' '}
<span className="text-dorado">{hero.headline.effort}</span>{' '}
{hero.headline.line3}{' '}
<span className={clarityBorder}>{hero.headline.clarity}</span>
</h1>
<p className="mt-6 text-lg md:text-xl text-azul-profundo/70 leading-relaxed max-w-xl">
{apertura.dreamOutcome}
</p>
{/* Full width while stacked: at their natural widths the two CTAs
came out ragged and unequal, which read as an accident rather
than as hierarchy. */}
<motion.div variants={fadeInUp} className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
<div className="mt-10 w-full flex flex-col md:flex-row items-stretch md:items-center md:w-auto gap-4">
<CTA href="#contacto" label={hero.cta} variant="light" className="text-center md:whitespace-nowrap" />
<CTA href="#mecanismo" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
</motion.div>
</motion.div>
{/* Complexity → focus, left unresolved: the hero poses the tension
and FraseAncla pays it off by resolving into the ≡. Shown on every
breakpoint — hidden below lg it left the phone hero with no piece
of the brand's own graphic language at all. */}
<motion.div variants={fadeInUp} initial="hidden" animate="visible" {...motionProps(reduced)}
className="min-w-0">
<SecuenciaClaridad variant="hero" className="w-32 lg:w-full max-w-md mx-auto text-azul-profundo" />
</motion.div>
<CTA href="#proceso" label={hero.ctaSecundario} variant="ghost" className="text-center md:whitespace-nowrap" />
</div>
</div>
{/* A visual representation of clarity, direction, and growth.
The box is sized here rather than by the scene, so the space is
reserved before three.js arrives and nothing shifts when it does. */}
<div className="min-w-0 h-64 lg:h-[28rem]">
<ClaridadHero className="w-full h-full" />
</div>
</div>
</Container>
</section>
-113
View File
@@ -1,113 +0,0 @@
'use client';
import { motion, useScroll, useTransform } from 'framer-motion';
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 { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps, useAutoStep } from '@/lib/motion';
const ICONOS: IconoNombre[] = ['inmersion', 'desafiar', 'estrategia'];
export function Mecanismo() {
const reduced = usePrefersReducedMotion();
const { active, setActive, hoverProps } = useAutoStep(mecanismo.steps.length, 3200);
const { scrollYProgress } = useScroll();
const desplazamientoFoto = useTransform(scrollYProgress, [0, 1], [-24, 24]);
return (
<section id="mecanismo" className="bg-azul-profundo text-white scroll-mt-24 relative overflow-hidden">
{/* Foto: Golden Gate Bridge, Dietmar Rabich, CC BY-SA 4.0, modificada (duotono) — créditos en el footer */}
<motion.div
aria-hidden="true"
className="absolute inset-0 pointer-events-none"
style={reduced ? undefined : { y: desplazamientoFoto }}
>
<FondoFoto src="/images/mecanismo-bg.jpg" className="inset-0 opacity-25" wash="bg-azul-profundo/80" />
</motion.div>
<Container className="relative py-16 md:py-24">
{/* The step you are on, set enormous and almost invisible behind the
column — the same device the recorrido uses, so the two sections
read as one page rather than as one showpiece and its neighbour.
It follows the autoplay, which gives the section a slow pulse
without adding anything the reader has to attend to. */}
{reduced ? (
<span aria-hidden="true" className="pointer-events-none absolute top-10 right-6 md:right-12 select-none font-sans font-black leading-none text-white/[0.05] text-[9rem] md:text-[18rem]">
{mecanismo.steps[active].n}
</span>
) : (
<motion.span
key={active}
aria-hidden="true"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="pointer-events-none absolute top-10 right-6 md:right-12 select-none font-sans font-black leading-none text-white/[0.05] text-[9rem] md:text-[18rem]"
>
{mecanismo.steps[active].n}
</motion.span>
)}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
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)}
className="mt-4 font-sans font-black text-seccion">{mecanismo.title}</motion.h2>
{/* Step connector: advances on its own every ~3s (paused on hover or
once the reader picks a step) and pulls the progress line along with
it — the same "connected process" language as 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">
{mecanismo.steps.map((step, i) => (
<motion.li key={step.n} variants={fadeInUp}>
<button
type="button"
onClick={() => setActive(i)}
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-white/20'
}`}
>
<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.ol>
</Container>
</section>
);
}
+4 -3
View File
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { IlustracionBifurcacion } from '@/components/primitives/IlustracionBifurcacion';
import { paraQuien } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
@@ -9,7 +10,7 @@ import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, moti
export function ParaQuien() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-white relative overflow-hidden">
<section className="bg-white bg-hondo-blanco relative overflow-hidden">
<Container className="relative pt-16 md:pt-24 pb-8 md:pb-12 md:flex md:items-center md:justify-between md:gap-12">
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="font-sans font-black text-seccion text-azul-profundo max-w-3xl">{paraQuien.title}</motion.h2>
@@ -24,7 +25,7 @@ export function ParaQuien() {
<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>
<Antetitulo>Sí</Antetitulo>
<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) => (
@@ -35,7 +36,7 @@ export function ParaQuien() {
))}
</ul>
</motion.div>
<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">
<motion.div variants={fadeInUp} className="bg-azul-profundo bg-hondo-navy 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">
+218
View File
@@ -0,0 +1,218 @@
'use client';
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { PatronPuntos } from '@/components/primitives/PatronPuntos';
import { SimboloMarca } from '@/components/primitives/SimboloMarca';
import { TarjetaSpotlight } from '@/components/primitives/TarjetaSpotlight';
import { Icono, type IconoNombre } from '@/components/primitives/Icono';
import { proceso } from '@/lib/content';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
// The eight stages of the dueña's working lámina for a real client, rebuilt to
// its own anatomy: eight blocks laid flat, each with its number in a circle,
// its own icon, and a block labelled ENTREGABLES where every line carries its
// own mark. Everything identifying that client is stripped out in
// lib/content.ts.
//
// The three phases of deck slide 7 survive as a strip under the lead: the
// framework is the dueña's and worth keeping, it just does not dictate the
// layout — the lámina runs its eight blocks flat, and grouping them into three
// banded sections split one grid into three.
//
// Stage 08 sits full width at the foot, where the lámina puts One Year Plan,
// and goes navy — it is the payoff the other seven point at.
const ICONOS: Record<string, IconoNombre> = {
'01': 'compania', // company analysis + FODA — the company from outside
'02': 'inmersion', // internal analysis — dives through the layers
'03': 'consumidor', // Consumer Knowledge — the insight inside the person
'04': 'estrategia', // brand strategy — the path to the flag
'05': 'comunicacion', // communication and branding — the bubbles
'06': 'ventas', // marketing and sales — the rising bars
'07': 'desafiar', // commercial innovation — the cracked assumption
'08': 'plan', // One Year Plan — the checklist
};
// One mark per deliverable, the way the lámina gives every line its own rather
// than repeating a tick. Keyed by the copy itself, so a Proceso.test guard
// checks every deliverable in lib/content.ts has an icon here — otherwise a
// reworded line would silently render blank.
const ICONOS_ENTREGABLE: Record<string, IconoNombre> = {
// 01 · Análisis de compañía y FODA
'FODA': 'matriz',
'Diagnóstico ejecutivo': 'diagnostico',
'Prioridades': 'prioridades',
'Quick wins': 'rayo',
// 02 · Análisis interno
'Mapa de capacidades': 'mapa',
'Restricciones del negocio': 'restriccion',
'Oportunidades de crecimiento': 'brote',
// 03 · Consumer Knowledge
'Segmentación': 'segmentos',
'Buyer persona': 'ficha',
'Customer journey': 'recorrido',
'Necesidades no cubiertas': 'hueco',
// 04 · Estrategia de marca
'Posicionamiento': 'diana',
'Arquitectura de marcas': 'arquitectura',
'Manual estratégico': 'libro',
// 05 · Comunicación y branding
'Plataforma de comunicación': 'megafono',
'Campaña anual': 'calendario',
'Estrategia de medios': 'ondas',
'Plan de contenido': 'documento',
// 06 · Marketing y ventas
'Dashboard CEO': 'medidor',
'Dashboard comercial': 'tablero',
'Comité de crecimiento': 'comite',
// 07 · Innovación comercial
'Roadmap de innovación': 'hitos',
'Go to market': 'cohete',
'Prioridades de innovación': 'bombilla',
// 08 · One Year Plan
'Roadmap a 12 meses': 'anio',
'Iniciativas prioritarias': 'prioridades',
'Gobernanza del crecimiento': 'escudo',
};
export function Proceso() {
const reduced = usePrefersReducedMotion();
return (
<section id="proceso" className="bg-gris-claro bg-hondo-claro scroll-mt-24 relative overflow-hidden">
<Container className="relative py-16 md:py-24">
{/* ── Header ── */}
<Antetitulo as={motion.p} variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}>
{proceso.eyebrow}
</Antetitulo>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-4xl text-balance">{proceso.title}</motion.h2>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-5 text-azul-profundo/70 leading-relaxed max-w-2xl">{proceso.lead}</motion.p>
{/* ── The three approved phases, as a strip rather than as structure ── */}
<motion.ol
variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-8 flex flex-wrap items-center gap-x-3 gap-y-2 list-none p-0"
>
{proceso.fases.map((fase, i) => (
<li key={fase} className="flex items-center gap-3">
{i > 0 && <span aria-hidden="true" className="text-dorado/60">→</span>}
<span className="text-xs tracking-[0.18em] uppercase font-black text-azul-profundo/70">{fase}</span>
</li>
))}
</motion.ol>
{/* ── The eight blocks, flat. An <ol>, because the sequence is the
point: it carries the order for assistive tech, which lets the
drawn number stay decorative. ── */}
{/* Each card reveals on its own. The list used to be one stagger
container with viewportOnce's amount of 0.3 — on a phone the eight
stacked cards run ~3000px against an ~850px screen, so 30% of the
list is never on screen at once and nothing below the header ever
appeared. */}
<ol className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 list-none p-0">
{proceso.etapas.map((etapa, i) => {
const destacada = 'destacada' in etapa && etapa.destacada;
return (
<motion.li
key={etapa.n}
variants={fadeInUp}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
// The cards in one row still arrive one after another, the way
// the stagger did, without depending on the whole list.
transition={{ delay: (i % 3) * 0.08 }}
{...motionProps(reduced)}
// No hover lift: these cards are not links, and a card that
// rises under the pointer promises a click that goes nowhere.
className={destacada ? 'sm:col-span-2' : ''}
>
<TarjetaSpotlight
className={`relative h-full rounded-2xl p-6 md:p-7 shadow-tarjeta ${
destacada ? 'bg-azul-profundo bg-hondo-navy' : 'border border-azul-profundo/[0.07] bg-white'
}`}
>
{destacada && (
// In the foot of the card, which the grid's equal-height rows
// leave empty — decoration that fills dead space instead of
// crowding the deliverables.
<PatronPuntos rows={3} cols={8} className="absolute -left-2 bottom-5 w-1/3 text-white/[0.08]" />
)}
{/* ── Photo band, with the number and the icon riding on it.
The duotone is what makes eight photographs by eight
different people read as one set: FondoFoto throws away
their colour with mix-blend-luminosity and relights them
navy-to-gold. Every file here is CC0 and credited by path
in `creditosFotos`, which a test enforces. ── */}
<div className={`relative -mx-6 -mt-6 md:-mx-7 md:-mt-7 mb-6 overflow-hidden ${destacada ? 'h-40 md:h-52' : 'h-32'}`}>
<FondoFoto
src={`/images/proceso/${etapa.n}.jpg`}
className="inset-0"
wash={destacada ? 'bg-azul-profundo/55' : 'bg-azul-profundo/60'}
/>
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-4 md:p-5">
<span
aria-hidden="true"
className="grid place-items-center w-10 h-10 rounded-full bg-dorado bg-dorado-metal font-sans font-black text-sm text-azul-profundo shadow-[0_4px_12px_-2px_rgba(10,29,61,0.5)]"
>
{etapa.n}
</span>
<div className="grid place-items-center w-11 h-11 rounded-full bg-azul-profundo/80 backdrop-blur-sm">
<Icono nombre={ICONOS[etapa.n]} className="w-6 h-6 text-dorado" />
</div>
</div>
</div>
<div className="relative flex items-center justify-between gap-4">
<h3 className={`font-sans font-black leading-snug ${
destacada ? 'text-2xl md:text-3xl text-white' : 'text-lg text-azul-profundo'
}`}>
{etapa.name}
</h3>
{/* Beside the title, not in the card's foot: pinned to the
bottom-right corner it sat on top of the third
deliverable once the three ran in a row. Gold needs
real alpha on navy — at 25% it reads as a skeleton. */}
{destacada && <SimboloMarca className="w-12 md:w-14 shrink-0 text-dorado/60" />}
</div>
{/* ── ENTREGABLES ── */}
<div className={`relative mt-5 border-t pt-4 ${destacada ? 'border-white/15' : 'border-azul-profundo/10'}`}>
{/* La 08 va sobre navy y conserva el dorado del manual; las
otras siete son tarjetas blancas y necesitan el tono
legible. */}
<p className={`text-xs tracking-[0.2em] uppercase font-black ${
destacada ? 'text-dorado' : 'text-dorado-texto'
}`}>
{proceso.etiquetaEntregables}
</p>
<ul className={`mt-3 grid gap-2 list-none p-0 ${destacada ? 'sm:grid-cols-3' : ''}`}>
{etapa.entregables.map((entregable) => (
<li
key={entregable}
className={`flex items-start gap-2.5 text-sm ${destacada ? 'text-white' : 'text-azul-profundo/85'}`}
>
<Icono
nombre={ICONOS_ENTREGABLE[entregable]}
className="w-[18px] h-[18px] mt-px shrink-0 text-dorado"
/>
<span>{entregable}</span>
</li>
))}
</ul>
</div>
</TarjetaSpotlight>
</motion.li>
);
})}
</ol>
</Container>
</section>
);
}
+11 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { Antetitulo } from '@/components/primitives/Antetitulo';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { ParDialogo } from '@/components/primitives/ParDialogo';
@@ -23,12 +24,12 @@ const TOTAL = items.length;
// ever brings its own colour onto the page. Under the veils below they read as
// texture, not as stock photography.
const FOTOS = [
'/images/recorrido/crecer.jpg', // a financial district still going up, cranes and all
'/images/recorrido/consolidarse.jpg', // an empty meeting room: order before more weight
'/images/recorrido/rentabilidad.jpg', // the harvest — effort actually turned into result
'/images/recorrido/simplificar.jpg', // a trading screen too dense to read
'/images/recorrido/expandirse.jpg', // a departures board: markets you have not flown to
'/images/recorrido/portafolio.jpg', // a skyline of towers, every one of them a front
'/images/recorrido/crecer.jpg', // Torre Mitikah, CDMX, still topped by its crane
'/images/recorrido/consolidarse.jpg', // an office built in a hurry: boxes before order
'/images/recorrido/rentabilidad.jpg', // the agave harvest — effort actually turned into result
'/images/recorrido/simplificar.jpg', // overpasses stacked over overpasses, CDMX
'/images/recorrido/expandirse.jpg', // Cancún's boarding gates: markets you have not flown to
'/images/recorrido/portafolio.jpg', // Monterrey's skyline, every tower a front
];
// Reduced motion, and the server's first paint, get the whole conversation laid
@@ -37,10 +38,10 @@ const FOTOS = [
// sixths of the copy behind a scroll position is not an option either way.
function Estatica() {
return (
<section className="bg-gris-claro relative overflow-hidden">
<section className="bg-gris-claro bg-hondo-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-16 md:py-24">
<h2 className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</h2>
<Antetitulo as="h2">{puntosInflexion.lead}</Antetitulo>
<ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
{items.map((item, i) => (
<ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} />
@@ -185,9 +186,9 @@ function Escenificado() {
the recorrido its <h2>, and a section this size with no heading
at all is a hole in the document outline — for a screen reader
the six exchanges would hang off nothing. */}
<h2 className="absolute top-24 left-6 md:left-12 text-xs tracking-[0.3em] uppercase font-black text-dorado md:top-28">
<Antetitulo as="h2" sobre="oscuro" className="absolute top-24 left-6 md:left-12 md:top-28">
{puntosInflexion.lead}
</h2>
</Antetitulo>
{/* The rail. Where you are in the conversation, and how much is
left — a pinned section without it feels like a page that
@@ -21,3 +21,16 @@ describe('<Cierre />', () => {
expect(screen.getByRole('navigation', { name: /contacto/i })).toBeInTheDocument();
});
});
describe('<Cierre /> — pestañas', () => {
it('opens WhatsApp and LinkedIn in a new tab and keeps email in place', () => {
render(<Cierre />);
for (const nombre of [/whatsapp/i, /linkedin/i]) {
for (const a of screen.getAllByRole('link', { name: nombre })) {
expect(a).toHaveAttribute('target', '_blank');
expect(a).toHaveAttribute('rel', 'noopener noreferrer');
}
}
expect(screen.getByRole('link', { name: /email/i })).not.toHaveAttribute('target');
});
});
@@ -5,12 +5,12 @@ import { ComoEmpezar } from '../ComoEmpezar';
describe('<ComoEmpezar />', () => {
it('renders the on-ramp steps', () => {
render(<ComoEmpezar />);
expect(screen.getByText('Escríbeme')).toBeInTheDocument();
expect(screen.getByText('Sesión de diagnóstico')).toBeInTheDocument();
expect(screen.getByText('Escríbenos')).toBeInTheDocument();
expect(screen.getByText('Sesión de claridad')).toBeInTheDocument();
});
it('links the CTA to WhatsApp with a prefilled message', () => {
render(<ComoEmpezar />);
const cta = screen.getByRole('link', { name: /Agenda tu sesión de diagnóstico/i });
const cta = screen.getByRole('link', { name: /Agenda tu sesión de claridad/i });
const href = cta.getAttribute('href') ?? '';
expect(href).toMatch(/^https:\/\/wa\.me\//);
expect(href).toContain('text=');
@@ -5,11 +5,18 @@ import { Estratega } from '../Estratega';
describe('<Estratega />', () => {
it('renders the bio', () => {
render(<Estratega />);
expect(screen.getByText(/30 años/i)).toBeInTheDocument();
expect(screen.getByText(/años construyendo marcas/i)).toBeInTheDocument();
expect(screen.getByText(/Hemos acompañado a más de 40 empresas/i)).toBeInTheDocument();
});
it('shows the confirmed figure — 25+, never the stale 30', () => {
// La dueña confirmó 25+ (19/ago/2026); el 30 fue un borrador que
// sobrevivió como hardcode después de que el copy pasó a 25.
const { container } = render(<Estratega />);
expect(container.textContent).not.toMatch(/30/);
});
it('renders the institutional quote', () => {
render(<Estratega />);
expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument();
expect(screen.getByText(/Tu negocio ya demostró que funciona/i)).toBeInTheDocument();
});
it('never lets CSS uppercase turn the brand name into CREEER', () => {
const { container } = render(<Estratega />);
@@ -9,6 +9,11 @@ describe('<FraseAncla />', () => {
expect(container.textContent).toMatch(/falta de claridad/i);
});
it('paints the "after": the anchor names the problem, this line names the outcome', () => {
const { container } = render(<FraseAncla />);
expect(container.textContent).toMatch(/Sabes exactamente dónde invertir/i);
});
it('resolves the brand metaphor into the mark', () => {
const { container } = render(<FraseAncla />);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(3);
+37 -10
View File
@@ -5,7 +5,7 @@ import { Hero } from '../Hero';
describe('<Hero />', () => {
it('renders the eyebrow', () => {
render(<Hero variant="editorial" />);
expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument();
expect(screen.getByText(/claridad estratégica para tu negocio/i)).toBeInTheDocument();
});
it('renders the headline copy', () => {
const { container } = render(<Hero variant="hormozi" />);
@@ -15,11 +15,11 @@ describe('<Hero />', () => {
});
it('renders the CTA', () => {
render(<Hero variant="editorial" />);
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toHaveAttribute('href', '#contacto');
expect(screen.getByRole('link', { name: /agenda una sesión de claridad/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');
expect(screen.getByRole('link', { name: /ver cómo trabajamos/i })).toHaveAttribute('href', '#proceso');
});
it('does NOT render scarcity (cupos/cohorte)', () => {
const { container } = render(<Hero variant="editorial" />);
@@ -33,13 +33,40 @@ describe('<Hero />', () => {
expect(container.textContent).not.toMatch(/Trabajo más y gano casi lo mismo/i);
});
it('poses the brand metaphor without resolving it', () => {
// El hero es el LCP de la página, y la página se abre desde un enlace de
// WhatsApp con datos móviles. Mientras el titular naciera en opacity 0
// esperando a que hidratara React, montara el IntersectionObserver y corriera
// el stagger, lo primero que veía el lector era una pantalla en blanco — y si
// el JS fallaba, se quedaba así. El export estático llegó a salir con 126
// elementos en opacity 0, el <h1> entre ellos.
it('entra pintado: nada del hero nace oculto', () => {
const { container } = render(<Hero variant="editorial" />);
// SecuenciaClaridad is the only thing here drawn from <line>; the dot
// fields are circles. variant="hero" plays dispersion and convergence
// only — the ≡ resolution is FraseAncla's payoff, and a hero that
// resolved would spend the beat before the copy earns it.
expect(container.querySelectorAll('line').length).toBeGreaterThan(10);
expect(container.querySelectorAll('[data-resolucion]')).toHaveLength(0);
const ocultos = Array.from(container.querySelectorAll<HTMLElement>('*')).filter(
(el) => el.style.opacity === '0',
);
expect(ocultos).toHaveLength(0);
});
it('el titular no se difumina palabra a palabra', () => {
const { container } = render(<Hero variant="editorial" />);
const difuminados = Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) =>
el.style.filter.includes('blur'),
);
expect(difuminados).toHaveLength(0);
});
// El edificio es three.js: ~185 kB que el titular no puede esperar. Vive tras
// next/dynamic con ssr:false, así que el hero se pinta entero sin él y el
// export estático no lleva ni un <canvas>. Cuando el dynamic() vivía en el
// propio Hero — un server component — Next 14 se negaba a construir.
it('el hero se pinta sin esperar al edificio', () => {
const { container } = render(<Hero variant="editorial" />);
expect(container.querySelector('h1')).toBeInTheDocument();
expect(container.querySelector('canvas')).toBeNull();
});
it('renders the Dream Outcome below the headline', () => {
const { container } = render(<Hero variant="editorial" />);
expect(container.textContent).toMatch(/plan a 12 meses/i);
});
});
@@ -1,43 +0,0 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Mecanismo } from '../Mecanismo';
describe('<Mecanismo />', () => {
it('renders the 3 steps in order', () => {
render(<Mecanismo />);
expect(screen.getByText('Inmersión')).toBeInTheDocument();
expect(screen.getByText('Desafiar supuestos')).toBeInTheDocument();
expect(screen.getByText('Estrategia ejecutable')).toBeInTheDocument();
});
it('renders step numbers 01/02/03', () => {
const { container } = render(<Mecanismo />);
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');
});
});
});
@@ -14,11 +14,11 @@ describe('<ParaQuien />', () => {
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', () => {
it('renders the disqualifying criteria', () => {
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(/solución rápida, no un cambio de fondo/i)).toBeInTheDocument();
expect(screen.getByText(/un curso, no una transformación/i)).toBeInTheDocument();
expect(screen.getByText(/crisis de supervivencia/i)).toBeInTheDocument();
});
@@ -0,0 +1,119 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Proceso } from '../Proceso';
import { proceso, creditosFotos } from '@/lib/content';
describe('<Proceso />', () => {
it('renders the three approved phases as a strip', () => {
render(<Proceso />);
expect(proceso.fases).toHaveLength(3);
for (const fase of proceso.fases) {
expect(screen.getByText(fase)).toBeInTheDocument();
}
});
it('renders all eight stages, numbered 01 through 08', () => {
render(<Proceso />);
expect(proceso.etapas).toHaveLength(8);
for (const etapa of proceso.etapas) {
expect(screen.getByText(etapa.name)).toBeInTheDocument();
expect(screen.getByText(etapa.n)).toBeInTheDocument();
}
});
// The argument of the section: "claridad estratégica" is abstract until it
// is a document you can hold. The lámina labels this block on all eight of
// its cards and marks every line — losing either turns the deliverables back
// into a list of service names.
it('labels the deliverables block on every card and marks every line', () => {
const { container } = render(<Proceso />);
expect(screen.getAllByText(proceso.etiquetaEntregables)).toHaveLength(8);
for (const entregable of proceso.etapas.flatMap((e) => e.entregables)) {
expect(screen.getByText(entregable)).toBeInTheDocument();
}
// One <ul> of deliverables per stage, every line of it carrying an icon.
const listas = container.querySelectorAll('ul');
expect(listas).toHaveLength(proceso.etapas.length);
listas.forEach((lista, i) => {
const esperados = proceso.etapas[i].entregables.length;
expect(lista.querySelectorAll(':scope > li')).toHaveLength(esperados);
expect(lista.querySelectorAll(':scope > li > svg')).toHaveLength(esperados);
});
});
// The icon map is keyed by the copy itself, so rewording a deliverable in
// lib/content.ts without updating the map would render a blank space where
// its mark should be. Nothing else would fail.
it('gives every deliverable its own icon, none of them empty', () => {
const { container } = render(<Proceso />);
const items = Array.from(container.querySelectorAll('ul > li'));
expect(items).toHaveLength(proceso.etapas.reduce((n, e) => n + e.entregables.length, 0));
for (const item of items) {
const svg = item.querySelector(':scope > svg');
expect(svg, `«${item.textContent}» se quedó sin ícono`).not.toBeNull();
expect(svg!.children.length, `el ícono de «${item.textContent}» está vacío`).toBeGreaterThan(0);
}
});
// Stage 01's medallion was the magnifier, and so was its own "Diagnóstico
// ejecutivo" line — the same glyph twice in one card, which reads as a
// mistake. Nothing but the eye catches this, so the eye gets help.
it('never repeats a glyph inside the same card', () => {
const { container } = render(<Proceso />);
const tarjetas = Array.from(container.querySelectorAll('ol'))[1].querySelectorAll(':scope > li');
tarjetas.forEach((tarjeta, i) => {
const glifos = Array.from(tarjeta.querySelectorAll('svg')).map((s) => s.innerHTML);
expect(new Set(glifos).size, `la etapa ${proceso.etapas[i].n} repite un ícono`).toBe(glifos.length);
});
});
it('closes on the One Year Plan, the payoff the other seven point at', () => {
render(<Proceso />);
expect(screen.getByText('One Year Plan')).toBeInTheDocument();
expect(screen.getByText('Roadmap a 12 meses')).toBeInTheDocument();
expect(screen.getByText('Gobernanza del crecimiento')).toBeInTheDocument();
});
// The stages are a sequence, so the order has to reach assistive tech
// through the markup — otherwise it lives only in the drawn number, which is
// decorative and never announced.
it('carries the order in the markup, not just in the drawn number', () => {
const { container } = render(<Proceso />);
const listas = container.querySelectorAll('ol');
expect(listas).toHaveLength(2); // the phase strip and the eight stages
expect(listas[1].querySelectorAll(':scope > li')).toHaveLength(8);
});
// The footer is the only place in the project where the photo licences are
// honoured, and it renders `creditosFotos` verbatim. An image added here
// without an entry there would go uncredited and nothing else would notice.
it('credits every photograph it shows', () => {
const { container } = render(<Proceso />);
const fotos = Array.from(container.querySelectorAll('img')).map((i) => i.getAttribute('src'));
expect(fotos).toHaveLength(proceso.etapas.length);
for (const src of fotos) {
expect(
creditosFotos.some((c) => 'archivo' in c && c.archivo === src),
`«${src}» no tiene crédito en creditosFotos`,
).toBe(true);
}
});
it('loads the photographs lazily, since none of them is above the fold', () => {
const { container } = render(<Proceso />);
for (const img of Array.from(container.querySelectorAll('img'))) {
expect(img).toHaveAttribute('loading', 'lazy');
}
});
// Everything here is adapted from the dueña's working deck for a real
// client. None of that client's detail may reach a public page.
it('leaks nothing that identifies the client the deck was built for', () => {
const { container } = render(<Proceso />);
expect(container.textContent).not.toMatch(
/cápsulas|gomitas|tabletas|planta|sell in|sell out|mercado mexicano|líderes de la categoría/i,
);
});
});
@@ -38,6 +38,15 @@ describe('<PuntosInflexion />', () => {
}
});
it('never lets creeer answer by repeating the situation back', () => {
// The restructure of lib/content.ts once aliased `respuesta` to
// `situacion`: problem and answer read the same, and the test above still
// passed because the situation renders inside «».
for (const { situacion, respuesta } of puntosInflexion.items) {
expect(respuesta).not.toBe(situacion);
}
});
it('states each situation in the second person, as the dueña reads them', () => {
// Her own phrasing in the 31/jul call: "estás atrapado en tu compañía",
// "creciste muy rápido pero quieres expandirte". The deck's infinitives