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:
1 parent
3242a8bf71
commit
89e54a6e84
73 files changed
+5261
-592
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user