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 ( +