feat: compose page + Framer Motion animations + reduced-motion support
This commit is contained in:
1 parent
8947a11e70
commit
e98adf5b08
10 files changed
+65
-19
No files matched your search
+1
-1
@@ -9,7 +9,7 @@ import { Cierre } from '@/components/sections/Cierre';
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<Hero variant="editorial" />
|
||||
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
||||
<FraseAncla />
|
||||
<ParaQuien />
|
||||
<Mecanismo />
|
||||
|
||||
@@ -4,14 +4,15 @@ import { Container } from '@/components/primitives/Container';
|
||||
import { CTA } from '@/components/primitives/CTA';
|
||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { cierre } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Cierre() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const { whatsapp, email, linkedin } = cierre.contact;
|
||||
return (
|
||||
<section id="contacto" className="bg-bg-dark text-cream">
|
||||
<Container className="py-24 md:py-32 text-center">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="flex flex-col items-center">
|
||||
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div>
|
||||
<motion.blockquote variants={fadeInUp}
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { disciplinas } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Disciplinas() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-base">
|
||||
<Container className="py-24 md:py-32">
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-semibold text-gold">{disciplinas.eyebrow}</motion.p>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-serif italic font-bold text-3xl md:text-5xl text-ink">{disciplinas.title}</motion.h2>
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 text-muted text-lg">{disciplinas.lead}</motion.p>
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-12 grid gap-8 md:grid-cols-3">
|
||||
{disciplinas.items.map((item) => (
|
||||
<motion.div key={item.name} variants={fadeInUp} className="border-t-2 border-gold pt-5">
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { estratega } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Estratega() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-base">
|
||||
<Container className="py-24 md:py-32">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
|
||||
{/* Portrait — placeholder gradient until dueña provides a photo */}
|
||||
<motion.div variants={fadeInUp}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { fraseAncla } from '@/lib/content';
|
||||
import { fadeInUp, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function FraseAncla() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-base">
|
||||
<Container className="py-24 md:py-32 text-center max-w-4xl">
|
||||
@@ -14,6 +15,7 @@ export function FraseAncla() {
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
{...motionProps(reduced)}
|
||||
className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink"
|
||||
>
|
||||
<span className="text-gold">“</span>
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Wordmark } from '@/components/primitives/Wordmark';
|
||||
import { CTA } from '@/components/primitives/CTA';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { hero } from '@/lib/content';
|
||||
import { easeOutExpo, fadeInUp, staggerContainer } from '@/lib/motion';
|
||||
import { easeOutExpo, fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
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'
|
||||
@@ -19,7 +20,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||||
return (
|
||||
<section className="bg-bg-pure">
|
||||
<Container className="min-h-[88vh] flex flex-col items-center justify-center text-center py-24">
|
||||
<motion.div variants={staggerContainer} initial="hidden" animate="visible" className="flex flex-col items-center">
|
||||
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)} className="flex flex-col items-center">
|
||||
<motion.div variants={fadeInUp}><Wordmark size="xl" /></motion.div>
|
||||
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-semibold text-gold">
|
||||
{hero.eyebrow}
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { mecanismo } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function Mecanismo() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-bg-dark text-cream">
|
||||
<Container className="py-24 md:py-32">
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-semibold text-gold-bright">{mecanismo.eyebrow}</motion.p>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-serif italic font-bold text-3xl md:text-5xl">{mecanismo.title}</motion.h2>
|
||||
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||||
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-12 grid gap-10 md:grid-cols-3">
|
||||
{mecanismo.steps.map((step) => (
|
||||
<motion.li key={step.n} variants={fadeInUp} className="border-t border-gold-bright/30 pt-6">
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { paraQuien } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function ParaQuien() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return (
|
||||
<section className="bg-base">
|
||||
<Container className="py-24 md:py-32">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} className="max-w-3xl">
|
||||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} className="max-w-3xl">
|
||||
<motion.h2 variants={fadeInUp} className="font-serif italic font-bold text-3xl md:text-5xl text-ink leading-tight">
|
||||
{paraQuien.title}
|
||||
</motion.h2>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Variants } from 'framer-motion';
|
||||
|
||||
// EaseOutExpo — smooth editorial deceleration
|
||||
@@ -19,3 +20,23 @@ export const staggerContainer: Variants = {
|
||||
};
|
||||
|
||||
export const viewportOnce = { once: true, amount: 0.3 } as const;
|
||||
|
||||
// Hook: returns true if user prefers reduced motion. Components use this to
|
||||
// disable Framer Motion animations (per spec, a11y requirement).
|
||||
export function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setReduced(mq.matches);
|
||||
const handler = () => setReduced(mq.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
// Pass to <motion.*> as motion-reduced guard. When reduced, variants resolve to visible immediately.
|
||||
export const motionProps = (reduced: boolean) =>
|
||||
reduced
|
||||
? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined }
|
||||
: {};
|
||||
@@ -17,3 +17,20 @@ if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) {
|
||||
window.IntersectionObserver = IntersectionObserverStub;
|
||||
global.IntersectionObserver = IntersectionObserverStub;
|
||||
}
|
||||
|
||||
// jsdom lacks matchMedia; usePrefersReducedMotion() calls it in useEffect.
|
||||
// Note: vitest's jsdom environment pre-defines `matchMedia` as an own property
|
||||
// set to `undefined`, so `'matchMedia' in window` is true even though it isn't
|
||||
// a function — guard on typeof instead of `in`.
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
Reference in new issue
Block a user