'use client';
import { useEffect, useRef, useState } from 'react';
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { FondoFoto } from '@/components/primitives/FondoFoto';
import { ParDialogo } from '@/components/primitives/ParDialogo';
import { EscenaDialogo } from '@/components/primitives/EscenaDialogo';
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
import { puntosInflexion } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
const items = puntosInflexion.items;
const TOTAL = items.length;
// One photograph per inflection point, in the order the points are declared.
// Asset wiring, not copy — `lib/content.ts` stays the source of truth for words
// only. Every file is from Wikimedia Commons under a licence that allows reuse,
// and every one is credited in `creditosFotos`; the footer renders that list.
//
// They are read through FondoFoto, which pushes them through
// mix-blend-luminosity over the brand's navy→gold gradient, so no photograph
// 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
];
function Cabecera({ reduced }: { reduced: boolean }) {
return (
{puntosInflexion.eyebrow}
{puntosInflexion.title}
);
}
// Reduced motion, and the server's first paint, get the whole conversation laid
// out at once. A pinned section that advances on scroll is exactly the thing
// someone asking for less movement is asking to be spared, and hiding five
// sixths of the copy behind a scroll position is not an option either way.
function Estatica() {
return (
{puntosInflexion.lead}
{items.map((item, i) => (
{}} />
))}
);
}
function Escenificado() {
const contenedor = useRef(null);
// How far through the pinned stage the reader is, 0 → 1, measured by hand.
// `useScroll({ target })` sat at exactly 0 for the whole 5.400px of scroll —
// measured, not guessed: every scene stayed at its initial opacity from the
// top of the section to the bottom. Reading the container's own rect on each
// frame is a dozen lines, needs no measurement pass from the library, and can
// be verified in the browser, which the black box could not.
const bruto = useMotionValue(0);
// Whether the stage is close enough to be worth paying for its photographs.
// Measured on the static export: the six of them are 1,24 MB and every one
// was downloading before the reader had scrolled a pixel — `loading="lazy"`
// did not save us, because Chrome's distance threshold is generous enough to
// swallow a section that starts under 2.000px down. Two megabytes before the
// first gesture, on a page that gets opened from a WhatsApp link on mobile
// data.
const [cerca, setCerca] = useState(false);
const yaCerca = useRef(false);
useEffect(() => {
let pendiente = 0;
const medir = () => {
pendiente = 0;
const el = contenedor.current;
if (!el) return;
const { top, height } = el.getBoundingClientRect();
const recorrido = height - window.innerHeight;
bruto.set(recorrido > 0 ? Math.min(1, Math.max(0, -top / recorrido)) : 0);
// Latch, never unset: the images are cached by then, and flipping it back
// would tear down the stage every time the reader scrolled past.
if (!yaCerca.current && top < window.innerHeight * 1.25) {
yaCerca.current = true;
setCerca(true);
}
};
const alScroll = () => {
if (!pendiente) pendiente = requestAnimationFrame(medir);
};
medir();
window.addEventListener('scroll', alScroll, { passive: true });
window.addEventListener('resize', alScroll);
return () => {
if (pendiente) cancelAnimationFrame(pendiente);
window.removeEventListener('scroll', alScroll);
window.removeEventListener('resize', alScroll);
};
}, [bruto]);
// Takes the jitter out of a trackpad without lagging behind the reader; the
// hilo conductor taught this project what an overdamped spring feels like.
const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 });
// Twelve beats, not a continuum: question, answer, question, answer… Scroll
// picks the beat and nothing else. When the cards were driven straight off
// the scroll value, every position in between was a frame you could stop on,
// and creeer's card sat there as a half-transparent grey slab.
const TIEMPOS = TOTAL * 2;
const [tiempo, setTiempo] = useState(0);
useMotionValueEvent(progreso, 'change', (v) => {
setTiempo(Math.min(TIEMPOS - 1, Math.max(0, Math.floor(v * TIEMPOS))));
});
const activo = Math.floor(tiempo / 2);
const respondida = tiempo % 2 === 1;
const barra = useTransform(progreso, [0, 1], [0, 1]);
const fondoY = useTransform(progreso, [0, 1], [0, -140]);
// A slower drift than the dots, so the photograph sits behind them in depth.
const fotoY = useTransform(progreso, [0, 1], [0, -70]);
return (
{/* The pinned stage starts the instant this container ends, so without a
bottom the dark ground began three pixels under the last line of the
title and the intro read as a squeezed strip between two dark blocks
rather than as the section's opening. */}
{/* One viewport of stage, six viewports of scroll to move through it. */}
{/* The photograph of whichever point is on screen, crossfading with
the beat. Only the neighbours of the active scene are mounted:
the next one is in the document a whole scene before it is needed,
which is time enough to arrive, and the far ones cost nothing.
Mounting all six was 1,24 MB spent up front for five pictures the
reader might never reach. */}
{items.map((item, i) => (
{cerca && Math.abs(i - activo) <= 1 && (
)}
))}
{/* "Respetando la capa azul y blanco": the photograph never carries
the frame, it only gives it grain. The light veil holds the
question readable in white; the navy veil takes the whole stage
over for the answer. The question's film is neutral black and the
answer's is the brand's navy, so the two frames are told apart by
temperature as well as by who is speaking. Both are heavy on
purpose — anything thinner and the copy fights the photograph. */}
{/* left-6 / md:left-12, not left-0: an absolutely positioned child
resolves against Container's padding box, so `left-0` lands on
the inside of the border and ignores the px-6 the rest of the
page is aligned to. On a phone that clipped the first glyph. */}
{puntosInflexion.lead}
{/* The rail. Where you are in the conversation, and how much is
left — a pinned section without it feels like a page that
stopped responding. */}
{items.map((item, i) => (
-
))}
{items.map((item, i) => (
))}
);
}
export function PuntosInflexion() {
const reduced = usePrefersReducedMotion();
return reduced ? : ;
}