From 5b400cb1619d0c1461af6b51d4132974354d32f0 Mon Sep 17 00:00:00 2001 From: erikgh Date: Fri, 31 Jul 2026 10:44:28 -0600 Subject: [PATCH] =?UTF-8?q?feat(primitivas):=20capa=20gr=C3=A1fica=20y=20d?= =?UTF-8?q?e=20movimiento=20reutilizable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diez primitivas que dan a la landing el "gráfico y movimiento" que pidió la dueña, todas con guarda de prefers-reduced-motion: - FondoPuntos / PatronPuntos — el patrón de puntos del manual - TarjetaSpotlight, TextoDesenfoque, ContadorNumero — realce y reveal - Carrusel — deslizable, con contador, autoplay pausable - Icono, SimboloTresLineas, IlustracionInflexion — línea, sin relleno - FondoFoto — duotono azul/dorado sobre foto useAutoStep en lib/motion.ts comparte la forma del autoplay del carrusel para indicadores de paso que no son carrusel. IlustracionInflexion documenta un detalle que muerde: motion.path aplica su propio andamiaje de dasharray aunque no reciba initial/animate, así que la rama de reduced motion renderiza planos en vez de intentar neutralizar los animados. Co-Authored-By: Claude Opus 5 --- components/primitives/Carrusel.tsx | 90 +++++++++++++ components/primitives/ContadorNumero.tsx | 44 +++++++ components/primitives/FondoFoto.tsx | 29 +++++ components/primitives/FondoPuntos.tsx | 55 ++++++++ components/primitives/Icono.tsx | 122 ++++++++++++++++++ .../primitives/IlustracionInflexion.tsx | 89 +++++++++++++ components/primitives/PatronPuntos.tsx | 46 +++++++ components/primitives/TarjetaSpotlight.tsx | 42 ++++++ components/primitives/TextoDesenfoque.tsx | 91 +++++++++++++ .../primitives/__tests__/Carrusel.test.tsx | 48 +++++++ .../__tests__/ContadorNumero.test.tsx | 20 +++ .../primitives/__tests__/FondoFoto.test.tsx | 19 +++ .../primitives/__tests__/FondoPuntos.test.tsx | 21 +++ .../primitives/__tests__/Icono.test.tsx | 34 +++++ .../__tests__/IlustracionInflexion.test.tsx | 20 +++ .../__tests__/PatronPuntos.test.tsx | 26 ++++ .../__tests__/TarjetaSpotlight.test.tsx | 35 +++++ .../__tests__/TextoDesenfoque.test.tsx | 28 ++++ lib/motion.ts | 22 ++++ 19 files changed, 881 insertions(+) create mode 100644 components/primitives/Carrusel.tsx create mode 100644 components/primitives/ContadorNumero.tsx create mode 100644 components/primitives/FondoFoto.tsx create mode 100644 components/primitives/FondoPuntos.tsx create mode 100644 components/primitives/Icono.tsx create mode 100644 components/primitives/IlustracionInflexion.tsx create mode 100644 components/primitives/PatronPuntos.tsx create mode 100644 components/primitives/TarjetaSpotlight.tsx create mode 100644 components/primitives/TextoDesenfoque.tsx create mode 100644 components/primitives/__tests__/Carrusel.test.tsx create mode 100644 components/primitives/__tests__/ContadorNumero.test.tsx create mode 100644 components/primitives/__tests__/FondoFoto.test.tsx create mode 100644 components/primitives/__tests__/FondoPuntos.test.tsx create mode 100644 components/primitives/__tests__/Icono.test.tsx create mode 100644 components/primitives/__tests__/IlustracionInflexion.test.tsx create mode 100644 components/primitives/__tests__/PatronPuntos.test.tsx create mode 100644 components/primitives/__tests__/TarjetaSpotlight.test.tsx create mode 100644 components/primitives/__tests__/TextoDesenfoque.test.tsx diff --git a/components/primitives/Carrusel.tsx b/components/primitives/Carrusel.tsx new file mode 100644 index 0000000..c735e0e --- /dev/null +++ b/components/primitives/Carrusel.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import useEmblaCarousel from 'embla-carousel-react'; +import Autoplay from 'embla-carousel-autoplay'; +import { usePrefersReducedMotion } from '@/lib/motion'; + +// Thin Embla wrapper shared by the hero's pain-point rotator and the mobile +// row of inflection points. Autoplay pauses on hover/focus/interaction, and +// never runs at all for users who asked for less motion — it's still a fully +// usable carousel (drag, dots, keyboard) with it off, just static. +export function Carrusel({ + slides, + autoplayMs, + className = '', + slideClassName = '', + ariaLabel, + mostrarContador = false, +}: { + slides: ReactNode[]; + autoplayMs?: number; + className?: string; + slideClassName?: string; + ariaLabel: string; + // "01 / 06" + flechas prev/next, en vez de puntos — el patrón de + // en-ti.com ("desliza para explorar") para carruseles con más peso. + mostrarContador?: boolean; +}) { + const reduced = usePrefersReducedMotion(); + const plugins = autoplayMs && !reduced ? [Autoplay({ delay: autoplayMs, stopOnInteraction: false, stopOnMouseEnter: true })] : []; + const [emblaRef, embla] = useEmblaCarousel({ loop: true }, plugins); + const [selected, setSelected] = useState(0); + + useEffect(() => { + if (!embla) return; + const onSelect = () => setSelected(embla.selectedScrollSnap()); + embla.on('select', onSelect); + onSelect(); + return () => { + embla.off('select', onSelect); + }; + }, [embla]); + + const scrollTo = useCallback((i: number) => embla?.scrollTo(i), [embla]); + const scrollPrev = useCallback(() => embla?.scrollPrev(), [embla]); + const scrollNext = useCallback(() => embla?.scrollNext(), [embla]); + + return ( +
+
+
+ {slides.map((slide, i) => ( +
+ {slide} +
+ ))} +
+
+ {slides.length > 1 && mostrarContador && ( +
+ +

+ {String(selected + 1).padStart(2, '0')} / {String(slides.length).padStart(2, '0')} +

+ +
+ )} + {slides.length > 1 && !mostrarContador && ( +
+ {slides.map((_, i) => ( +
+ )} +
+ ); +} diff --git a/components/primitives/ContadorNumero.tsx b/components/primitives/ContadorNumero.tsx new file mode 100644 index 0000000..193fd6e --- /dev/null +++ b/components/primitives/ContadorNumero.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { animate } from 'framer-motion'; +import { usePrefersReducedMotion } from '@/lib/motion'; + +// Counts up to `hasta` once scrolled into view. Only ever used with a number +// that's already written in the approved copy (e.g. "30+ años") — a visual +// echo of real content, never a stand-in for real metrics we don't have yet. +export function ContadorNumero({ hasta, sufijo = '', className = '' }: { hasta: number; sufijo?: string; className?: string }) { + const reduced = usePrefersReducedMotion(); + const ref = useRef(null); + const [display, setDisplay] = useState(reduced ? hasta : 0); + + useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + let controls: { stop: () => void } | undefined; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + observer.unobserve(el); + controls = animate(0, hasta, { + duration: 1.4, + ease: [0.22, 1, 0.36, 1], + onUpdate: (v) => setDisplay(Math.round(v)), + }); + }, + { threshold: 0.5 }, + ); + observer.observe(el); + return () => { + observer.disconnect(); + controls?.stop(); + }; + }, [reduced, hasta]); + + return ( + + {display} + {sufijo} + + ); +} diff --git a/components/primitives/FondoFoto.tsx b/components/primitives/FondoFoto.tsx new file mode 100644 index 0000000..f901521 --- /dev/null +++ b/components/primitives/FondoFoto.tsx @@ -0,0 +1,29 @@ +// Full-bleed photo background, tinted into the brand's navy/gold duotone via +// `mix-blend-mode: luminosity` — the gradient behind supplies the colour, the +// photo only contributes its tonal range. Keeps "blanco con azul" even when a +// photograph is on screen. Decorative only: aria-hidden, lazy-loaded. +export function FondoFoto({ + src, + className = '', + imgClassName = '', + wash = 'bg-azul-profundo/70', +}: { + src: string; + className?: string; + imgClassName?: string; + wash?: string; +}) { + return ( +