1499 lines
48 KiB
Markdown
1499 lines
48 KiB
Markdown
# creeer Landing Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement the creeer landing page (one-page, Next.js 14 static export) from the approved spec at `docs/superpowers/specs/2026-07-20-creeer-landing-design.md`.
|
||
|
||
**Architecture:** Next.js 14 App Router with 7 section components composed in `app/page.tsx`. Reusable primitives (`Wordmark`, `Container`, `CTA`) shared across sections. All copy centralized in `lib/content.ts`. Framer Motion animations with `prefers-reduced-motion` support. Static export (`output: export`) — no backend, contact via direct links.
|
||
|
||
**Tech Stack:** Next.js 14, React 18, TypeScript 5, Tailwind CSS 3.4, Framer Motion 11, Inter (Google Font via `next/font`), Georgia (system serif). Tests: Vitest + @testing-library/react.
|
||
|
||
## Global Constraints
|
||
|
||
(Copied verbatim from the spec — apply to every task.)
|
||
|
||
- **Wordmark:** `cre`**`e`**`er.` SIEMPRE minúsculas, Georgia 700 italic, la 2ª "e" en peso 900 + dorado, punto final dorado. Nunca `CREEER`, `Creeer`, sans-serif, ni mayúsculas.
|
||
- **Paleta:** navy `#0A1F44` (texto/CTA) · dorado tostado `#B8901f` (acentos sobre claro) · dorado `#D4AF37` (acentos sobre oscuro) · blanco cálido `#FBFAF7` (fondo base) · blanco `#FFFFFF` (hero) · marfil `#F5EBD0` (texto sobre oscuro).
|
||
- **Tipografía híbrida:** Georgia italic = voz de marca (wordmark + citas); Inter (400/600/900) = voz funcional (headlines Hormozi + body + UI).
|
||
- **Sin escasez en el hero:** no mostrar cupos/cohorte/"Generación Fundadora" arriba.
|
||
- **Voz:** segunda persona ("tú"/"tu negocio"), sin jerga corporativa.
|
||
- **Sin backend, sin formularios** — contacto por enlaces directos (WhatsApp, email, LinkedIn).
|
||
- **`output: export`** en `next.config.mjs` — HTML estático.
|
||
- **Idioma:** español (`<html lang="es">`).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
creeer/
|
||
├── package.json
|
||
├── next.config.mjs # output: export
|
||
├── tsconfig.json
|
||
├── tailwind.config.ts # design tokens (palette, fontFamily)
|
||
├── postcss.config.js
|
||
├── vitest.config.ts
|
||
├── app/
|
||
│ ├── layout.tsx # <html lang="es">, fonts, metadata
|
||
│ ├── page.tsx # composes 7 sections
|
||
│ └── globals.css # @tailwind + base
|
||
├── components/
|
||
│ ├── primitives/
|
||
│ │ ├── Wordmark.tsx # creeer logo (reused)
|
||
│ │ ├── Container.tsx # max-w wrapper (reused)
|
||
│ │ └── CTA.tsx # button + link variant (reused)
|
||
│ └── sections/
|
||
│ ├── Hero.tsx # 01 — variant="hormozi" | "editorial"
|
||
│ ├── FraseAncla.tsx # 02
|
||
│ ├── ParaQuien.tsx # 03
|
||
│ ├── Mecanismo.tsx # 04 (dark)
|
||
│ ├── Disciplinas.tsx # 05
|
||
│ ├── Estratega.tsx # 06
|
||
│ └── Cierre.tsx # 07 (dark) + contact links
|
||
├── lib/
|
||
│ ├── content.ts # all copy constants (source of truth)
|
||
│ └── motion.ts # Framer Motion variants + useReducedMotion wrapper
|
||
└── public/
|
||
└── og-image.png # Open Graph image (added manually)
|
||
```
|
||
|
||
Each section has one responsibility: render its slice of content using the design system. Primitives are consumed by multiple sections. `lib/content.ts` centralizes copy so text changes don't require touching JSX.
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold + dependencies + base config
|
||
|
||
**Files:**
|
||
- Create: `package.json`, `next.config.mjs`, `tsconfig.json`, `tailwind.config.ts`, `postcss.config.js`, `app/layout.tsx`, `app/page.tsx`, `app/globals.css`
|
||
|
||
**Interfaces:**
|
||
- Produces: a runnable Next.js 14 app at `npm run dev` showing "hello world", with Tailwind + Framer Motion + Vitest installed, `output: export` enabled.
|
||
|
||
- [ ] **Step 1: Initialize Next.js project in the current directory**
|
||
|
||
```bash
|
||
cd /home/erikgh/dev/Creeer
|
||
npx create-next-app@14 . --typescript --tailwind --app --no-src-dir --no-eslint --use-npm --import-alias "@/*"
|
||
```
|
||
|
||
If prompted about existing files, choose to overwrite/ignore conflicts for `.gitignore` (keep existing). This scaffold creates `package.json`, `app/`, configs.
|
||
|
||
- [ ] **Step 2: Install runtime dependencies**
|
||
|
||
```bash
|
||
npm install framer-motion@^11
|
||
```
|
||
|
||
- [ ] **Step 3: Install dev dependencies for testing**
|
||
|
||
```bash
|
||
npm install -D vitest@^1 @vitejs/plugin-react@^4 @testing-library/react@^15 @testing-library/jest-dom@^6 jsdom@^25
|
||
```
|
||
|
||
- [ ] **Step 4: Configure static export in `next.config.mjs`**
|
||
|
||
Overwrite `next.config.mjs`:
|
||
|
||
```javascript
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
output: 'export',
|
||
images: { unoptimized: true },
|
||
};
|
||
export default nextConfig;
|
||
```
|
||
|
||
(`images.unoptimized` is required for static export.)
|
||
|
||
- [ ] **Step 5: Verify dev server runs**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Expected: server starts at http://localhost:3000, default Next.js page renders. Stop with Ctrl+C.
|
||
|
||
- [ ] **Step 6: Initialize git repo (first commit)**
|
||
|
||
```bash
|
||
cd /home/erikgh/dev/Creeer
|
||
git init
|
||
git add .
|
||
git commit -m "chore: scaffold Next.js 14 + Tailwind + Framer Motion + Vitest"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Design tokens (Tailwind palette, fonts, content + motion libs)
|
||
|
||
**Files:**
|
||
- Modify: `tailwind.config.ts`, `app/globals.css`, `app/layout.tsx`
|
||
- Create: `lib/content.ts`, `lib/motion.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: Tailwind tokens (`bg-base`, `bg-dark`, `text-primary`, `accent-gold`, etc.), Inter font loaded via `next/font`, all copy in `lib/content.ts`, motion variants in `lib/motion.ts`.
|
||
|
||
- [ ] **Step 1: Write Tailwind tokens in `tailwind.config.ts`**
|
||
|
||
```typescript
|
||
import type { Config } from 'tailwindcss';
|
||
|
||
const config: Config = {
|
||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
base: '#FBFAF7', // blanco cálido (fondo base)
|
||
'bg-pure': '#FFFFFF', // blanco puro (hero)
|
||
'bg-dark': '#0A1F44', // navy (secciones oscuras)
|
||
ink: '#0A1F44', // texto principal
|
||
cream: '#F5EBD0', // texto sobre oscuro (marfil)
|
||
gold: {
|
||
DEFAULT: '#B8901f', // dorado tostado (sobre claro)
|
||
bright: '#D4AF37', // dorado (sobre oscuro)
|
||
},
|
||
muted: '#5a5168',
|
||
'muted-dark': '#9aa6c2',
|
||
},
|
||
fontFamily: {
|
||
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
|
||
serif: ['Georgia', 'Cambria', 'Times New Roman', 'serif'],
|
||
},
|
||
maxWidth: {
|
||
content: '1152px',
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
};
|
||
export default config;
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `app/globals.css` with base styles**
|
||
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
@layer base {
|
||
html { scroll-behavior: smooth; }
|
||
body {
|
||
background-color: #FBFAF7;
|
||
color: #0A1F44;
|
||
font-family: var(--font-inter), system-ui, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
::selection { background: #D4AF37; color: #0A1F44; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Load Inter font in `app/layout.tsx`**
|
||
|
||
Replace `app/layout.tsx`:
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import { Inter } from 'next/font/google';
|
||
import './globals.css';
|
||
|
||
const inter = Inter({ subsets: ['latin'], weight: ['400', '600', '700', '900'], variable: '--font-inter', display: 'swap' });
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'creeer · tu estratega de crecimiento',
|
||
description: 'Claridad estratégica para empresarios con tracción en punto de inflexión.',
|
||
// (SEO task expands this later)
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="es" className={inter.variable}>
|
||
<body>{children}</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `lib/content.ts` with all copy**
|
||
|
||
```typescript
|
||
// Source of truth for all copy. Edit here, not in JSX.
|
||
|
||
export const wordmark = {
|
||
// cre[e]er. — 'cre' + gold 'e' + 'er' + gold '.'
|
||
parts: { before: 'cre', accent: 'e', after: 'er', dot: '.' },
|
||
};
|
||
|
||
export const hero = {
|
||
eyebrow: '— tu estratega de crecimiento —',
|
||
// Headline broken into segments for emphasis styling
|
||
headline: {
|
||
line1: 'Tu negocio no necesita',
|
||
effort: 'más esfuerzo.', // gold (HV-1) or gold italic (V-a)
|
||
line3: 'Necesita',
|
||
clarity: 'más claridad.', // underlined gold
|
||
},
|
||
cta: 'Define tu siguiente movimiento',
|
||
};
|
||
|
||
export const fraseAncla = {
|
||
quote: 'Las empresas no dejan de crecer por falta de talento; dejan de crecer por falta de claridad.',
|
||
};
|
||
|
||
export const paraQuien = {
|
||
title: '¿Llegaste a tu punto de inflexión?',
|
||
lead: 'Esto es para ti si…',
|
||
criteria: [
|
||
'tienes tracción y ya demostraste que sabes crecer',
|
||
'trabajar más ya no rinde como antes',
|
||
'necesitas claridad para decidir el siguiente movimiento',
|
||
],
|
||
};
|
||
|
||
export const mecanismo = {
|
||
eyebrow: 'el mecanismo único',
|
||
title: 'Cómo trabajo',
|
||
steps: [
|
||
{ n: '01', name: 'Inmersión', desc: 'Me sumerjo en tu empresa hasta comprenderla desde dentro.' },
|
||
{ n: '02', name: 'Desafiar supuestos', desc: 'Cuestiono lo que todos dan por hecho y frenan el crecimiento.' },
|
||
{ n: '03', name: 'Estrategia ejecutable', desc: 'Construimos, juntos, una estrategia que puedas ejecutar.' },
|
||
],
|
||
};
|
||
|
||
export const disciplinas = {
|
||
eyebrow: 'las herramientas',
|
||
title: 'Las disciplinas que uso',
|
||
lead: 'No son la promesa — son los medios.',
|
||
items: [
|
||
{ name: 'Comunicación', desc: 'Mensaje que alinea a tu equipo y a tu mercado.' },
|
||
{ name: 'Digital', desc: 'Presencia y procesos que escalan sin ti.' },
|
||
{ name: 'Experiencias', desc: 'Cada toque con tu marca construye confianza.' },
|
||
],
|
||
};
|
||
|
||
export const estratega = {
|
||
eyebrow: 'la mente detrás de creeer',
|
||
title: 'Sobre la estratega',
|
||
bio: 'Más de 30 años construyendo marcas. Hoy acompaño a empresarios que ya lograron tracción y necesitan claridad para su siguiente movimiento.',
|
||
quote: 'El tiempo no se compra, se invierte.',
|
||
};
|
||
|
||
export const cierre = {
|
||
quote: 'Devuélvele claridad al rumbo de tu empresa.',
|
||
cta: 'Define tu siguiente movimiento',
|
||
contact: {
|
||
whatsapp: { label: 'WhatsApp', href: 'https://wa.me/520000000000' }, // TODO dueña: real number
|
||
email: { label: 'Email', href: 'mailto:hola@creeer.com' }, // TODO dueña: real email
|
||
linkedin: { label: 'LinkedIn', href: 'https://linkedin.com/in/...' }, // TODO dueña: real URL
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Create `lib/motion.ts`**
|
||
|
||
```typescript
|
||
import type { Variants } from 'framer-motion';
|
||
|
||
// EaseOutExpo — smooth editorial deceleration
|
||
export const easeOutExpo = [0.22, 1, 0.36, 1] as const;
|
||
|
||
export const fadeInUp: Variants = {
|
||
hidden: { opacity: 0, y: 20 },
|
||
visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: easeOutExpo } },
|
||
};
|
||
|
||
export const fadeIn: Variants = {
|
||
hidden: { opacity: 0 },
|
||
visible: { opacity: 1, transition: { duration: 0.4, ease: easeOutExpo } },
|
||
};
|
||
|
||
export const staggerContainer: Variants = {
|
||
hidden: {},
|
||
visible: { transition: { staggerChildren: 0.1 } },
|
||
};
|
||
|
||
export const viewportOnce = { once: true, amount: 0.3 } as const;
|
||
```
|
||
|
||
- [ ] **Step 6: Smoke test — `npm run dev`**
|
||
|
||
Run: `npm run dev`
|
||
Expected: app boots, no TS errors, default page renders at localhost:3000.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: design tokens (palette, Inter font, content + motion libs)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Primitives — Wordmark, Container, CTA
|
||
|
||
**Files:**
|
||
- Create: `components/primitives/Wordmark.tsx`, `components/primitives/Container.tsx`, `components/primitives/CTA.tsx`, `vitest.config.ts`
|
||
- Create tests: `components/primitives/__tests__/Wordmark.test.tsx`, `components/primitives/__tests__/CTA.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `<Wordmark size? />`, `<Container as? children />`, `<CTA href variant? />` — consumed by every section.
|
||
|
||
- [ ] **Step 1: Configure Vitest in `vitest.config.ts`**
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vitest/config';
|
||
import react from '@vitejs/plugin-react';
|
||
import path from 'path';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
environment: 'jsdom',
|
||
globals: true,
|
||
setupFiles: [],
|
||
},
|
||
resolve: { alias: { '@': path.resolve(__dirname, './') } },
|
||
});
|
||
```
|
||
|
||
Add script to `package.json`:
|
||
|
||
```json
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing test for `Wordmark`**
|
||
|
||
`components/primitives/__tests__/Wordmark.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Wordmark } from '../Wordmark';
|
||
|
||
describe('<Wordmark />', () => {
|
||
it('renders the wordmark in lowercase', () => {
|
||
render(<Wordmark />);
|
||
const text = screen.getByText(/cre/).closest('span')?.textContent;
|
||
expect(text?.toLowerCase()).toBe('creeer.');
|
||
});
|
||
it('renders the accent "e" separately for gold styling', () => {
|
||
const { container } = render(<Wordmark />);
|
||
const accent = container.querySelector('[data-accent="true"]');
|
||
expect(accent).not.toBeNull();
|
||
expect(accent?.textContent).toBe('e');
|
||
});
|
||
it('never renders uppercase CREEER', () => {
|
||
const { container } = render(<Wordmark />);
|
||
expect(container.textContent).not.toMatch(/CREEER/);
|
||
expect(container.textContent).not.toMatch(/Creeer/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `npm test -- Wordmark`
|
||
Expected: FAIL — module `../Wordmark` not found.
|
||
|
||
- [ ] **Step 4: Implement `Wordmark`**
|
||
|
||
`components/primitives/Wordmark.tsx`:
|
||
|
||
```tsx
|
||
import { wordmark } from '@/lib/content';
|
||
|
||
type Size = 'sm' | 'md' | 'lg' | 'xl';
|
||
const sizeClass: Record<Size, string> = {
|
||
sm: 'text-xl',
|
||
md: 'text-2xl',
|
||
lg: 'text-3xl',
|
||
xl: 'text-5xl',
|
||
};
|
||
|
||
export function Wordmark({ size = 'lg', onDark = false }: { size?: Size; onDark?: boolean }) {
|
||
const { before, accent, after, dot } = wordmark.parts;
|
||
const baseColor = onDark ? 'text-cream' : 'text-ink';
|
||
const goldColor = onDark ? 'text-gold-bright' : 'text-gold';
|
||
return (
|
||
<span className={`font-serif italic font-bold tracking-tight leading-none ${sizeClass[size]} ${baseColor}`}>
|
||
{before}
|
||
<span data-accent="true" className={`not-italic font-black ${goldColor}`}>{accent}</span>
|
||
{after}
|
||
<span className={goldColor}>{dot}</span>
|
||
</span>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `npm test -- Wordmark`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 6: Write failing test for `CTA`**
|
||
|
||
`components/primitives/__tests__/CTA.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { CTA } from '../CTA';
|
||
|
||
describe('<CTA />', () => {
|
||
it('renders the label with arrow', () => {
|
||
render(<CTA href="#contacto" label="Define tu siguiente movimiento" />);
|
||
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toBeInTheDocument();
|
||
});
|
||
it('uses the href', () => {
|
||
render(<CTA href="#contacto" label="x" />);
|
||
expect(screen.getByRole('link').getAttribute('href')).toBe('#contacto');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Run test — expected FAIL (module missing)**
|
||
|
||
Run: `npm test -- CTA`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 8: Implement `Container` and `CTA`**
|
||
|
||
`components/primitives/Container.tsx`:
|
||
|
||
```tsx
|
||
import type { ElementType, ReactNode } from 'react';
|
||
|
||
export function Container({ as: Tag = 'div', className = '', children }: { as?: ElementType; className?: string; children: ReactNode }) {
|
||
return <Tag className={`mx-auto w-full max-w-content px-6 md:px-12 ${className}`}>{children}</Tag>;
|
||
}
|
||
```
|
||
|
||
`components/primitives/CTA.tsx`:
|
||
|
||
```tsx
|
||
type Variant = 'light' | 'dark';
|
||
const styles: Record<Variant, string> = {
|
||
light: 'bg-ink text-base hover:bg-ink/90', // navy button, light text (on light bg)
|
||
dark: 'bg-gold-bright text-ink hover:bg-gold-bright/90', // gold button (on dark bg)
|
||
};
|
||
|
||
export function CTA({ href, label, variant = 'light', className = '' }: { href: string; label: string; variant?: Variant; className?: string }) {
|
||
return (
|
||
<a
|
||
href={href}
|
||
className={`inline-block font-sans font-bold uppercase tracking-wider px-7 py-3.5 text-sm transition-colors ${styles[variant]} ${className}`}
|
||
>
|
||
{label} <span aria-hidden="true">→</span>
|
||
</a>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Run tests — expected PASS**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests PASS.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: primitives (Wordmark, Container, CTA) with tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Hero (section 01) with HV-1 + V-a variants
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Hero.tsx`
|
||
- Test: `components/sections/__tests__/Hero.test.tsx`
|
||
- Modify: `app/page.tsx` (render `<Hero />`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `<Wordmark>`, `<CTA>`, `hero` content object.
|
||
- Produces: `<Hero variant="hormozi" | "editorial" />` — dueña decides via prop.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/Hero.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Hero } from '../Hero';
|
||
|
||
describe('<Hero />', () => {
|
||
it('renders the eyebrow', () => {
|
||
render(<Hero variant="editorial" />);
|
||
expect(screen.getByText(/tu estratega de crecimiento/i)).toBeInTheDocument();
|
||
});
|
||
it('renders the headline copy', () => {
|
||
render(<Hero variant="hormozi" />);
|
||
expect(screen.getByText(/Tu negocio/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/más esfuerzo/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/más claridad/i)).toBeInTheDocument();
|
||
});
|
||
it('renders the CTA', () => {
|
||
render(<Hero variant="editorial" />);
|
||
expect(screen.getByRole('link', { name: /define tu siguiente movimiento/i })).toBeInTheDocument();
|
||
});
|
||
it('does NOT render scarcity (cupos/cohorte)', () => {
|
||
const { container } = render(<Hero variant="editorial" />);
|
||
expect(container.textContent).not.toMatch(/cupos|cohorte|generación fundadora/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- Hero`
|
||
Expected: FAIL (module missing).
|
||
|
||
- [ ] **Step 3: Implement `Hero`**
|
||
|
||
`components/sections/Hero.tsx`:
|
||
|
||
```tsx
|
||
import { motion } from 'framer-motion';
|
||
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';
|
||
|
||
type Variant = 'hormozi' | 'editorial';
|
||
|
||
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||
const isHormozi = variant === 'hormozi';
|
||
const headlineBase = isHormozi
|
||
? 'font-sans font-black uppercase tracking-tight'
|
||
: 'font-serif italic font-bold';
|
||
const headlineSize = 'text-4xl md:text-6xl leading-[1.1]';
|
||
|
||
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={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}
|
||
</motion.p>
|
||
<motion.h1 variants={fadeInUp} className={`mt-8 ${headlineBase} ${headlineSize} text-ink`}>
|
||
{hero.headline.line1}{' '}
|
||
<span className="text-gold">{hero.headline.effort}</span>{' '}
|
||
{hero.headline.line3}{' '}
|
||
<span style={{ borderBottom: isHormozi ? '5px solid #B8901f' : '3px solid #B8901f', paddingBottom: 2 }}>
|
||
{hero.headline.clarity}
|
||
</span>
|
||
</motion.h1>
|
||
<motion.div variants={fadeInUp} className="mt-10">
|
||
<CTA href="#contacto" label={hero.cta} variant="light" />
|
||
</motion.div>
|
||
</motion.div>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- Hero`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Render `<Hero />` in `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Hero } from '@/components/sections/Hero';
|
||
|
||
export default function Home() {
|
||
return (
|
||
<main>
|
||
{/* @ts-expect-error server component animating — wrap with 'use client' in next step if needed */}
|
||
<Hero variant="editorial" />
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
(Note: Framer Motion `motion` requires client components. If `npm run dev` errors about client components, add `'use client';` at the top of `Hero.tsx` and any component using `motion`. The animations task (Task 11) addresses this systematically.)
|
||
|
||
- [ ] **Step 6: Verify in browser**
|
||
|
||
Run: `npm run dev`
|
||
Expected: hero renders at localhost:3000 with the wordmark, eyebrow, headline, CTA. Try both `variant="editorial"` and `variant="hormozi"`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: Hero section (01) with HV-1 hormozi + V-a editorial variants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: FraseAncla (section 02)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/FraseAncla.tsx`
|
||
- Test: `components/sections/__tests__/FraseAncla.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `fraseAncla` content, `Container`.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/FraseAncla.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { FraseAncla } from '../FraseAncla';
|
||
|
||
describe('<FraseAncla />', () => {
|
||
it('renders the anchor quote', () => {
|
||
render(<FraseAncla />);
|
||
expect(screen.getByText(/Las empresas no dejan de crecer/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/falta de claridad/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- FraseAncla`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/FraseAncla.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { fraseAncla } from '@/lib/content';
|
||
import { fadeInUp, viewportOnce } from '@/lib/motion';
|
||
|
||
export function FraseAncla() {
|
||
return (
|
||
<section className="bg-base">
|
||
<Container className="py-24 md:py-32 text-center max-w-4xl">
|
||
<motion.blockquote
|
||
variants={fadeInUp}
|
||
initial="hidden"
|
||
whileInView="visible"
|
||
viewport={viewportOnce}
|
||
className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink"
|
||
>
|
||
<span className="text-gold">"</span>
|
||
Las empresas no dejan de crecer por falta de talento;{' '}
|
||
<span className="text-gold">dejan de crecer por falta de claridad.</span>
|
||
<span className="text-gold">"</span>
|
||
</motion.blockquote>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- FraseAncla`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Hero } from '@/components/sections/Hero';
|
||
import { FraseAncla } from '@/components/sections/FraseAncla';
|
||
|
||
export default function Home() {
|
||
return (
|
||
<main>
|
||
<Hero variant="editorial" />
|
||
<FraseAncla />
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: FraseAncla section (02) — anchor quote"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: ParaQuien (section 03)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/ParaQuien.tsx`
|
||
- Test: `components/sections/__tests__/ParaQuien.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `paraQuien` content, `Container`.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/ParaQuien.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { ParaQuien } from '../ParaQuien';
|
||
|
||
describe('<ParaQuien />', () => {
|
||
it('renders the section title', () => {
|
||
render(<ParaQuien />);
|
||
expect(screen.getByText(/punto de inflexión/i)).toBeInTheDocument();
|
||
});
|
||
it('renders all 3 criteria', () => {
|
||
render(<ParaQuien />);
|
||
expect(screen.getByText(/tienes tracción/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/trabajar más ya no rinde/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/necesitas claridad/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- ParaQuien`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/ParaQuien.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { paraQuien } from '@/lib/content';
|
||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||
|
||
export function ParaQuien() {
|
||
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.h2 variants={fadeInUp} className="font-serif italic font-bold text-3xl md:text-5xl text-ink leading-tight">
|
||
{paraQuien.title}
|
||
</motion.h2>
|
||
<motion.p variants={fadeInUp} className="mt-6 text-lg text-muted">{paraQuien.lead}</motion.p>
|
||
<motion.ul variants={fadeInUp} className="mt-8 space-y-3">
|
||
{paraQuien.criteria.map((c) => (
|
||
<li key={c} className="flex items-start gap-3">
|
||
<span className="text-gold font-bold mt-1" aria-hidden="true">✓</span>
|
||
<span className="text-ink text-lg">{c}</span>
|
||
</li>
|
||
))}
|
||
</motion.ul>
|
||
</motion.div>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- ParaQuien`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
// ...imports
|
||
import { ParaQuien } from '@/components/sections/ParaQuien';
|
||
// ... in JSX after <FraseAncla />:
|
||
<ParaQuien />
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: ParaQuien section (03) — audience filter"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Mecanismo (section 04, dark)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Mecanismo.tsx`
|
||
- Test: `components/sections/__tests__/Mecanismo.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `mecanismo` content, `Container`. Dark section (navy bg).
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/Mecanismo.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } 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/s);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- Mecanismo`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/Mecanismo.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { mecanismo } from '@/lib/content';
|
||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||
|
||
export function Mecanismo() {
|
||
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}
|
||
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}
|
||
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}
|
||
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">
|
||
<div className="font-serif italic font-black text-5xl md:text-6xl text-gold-bright leading-none">{step.n}</div>
|
||
<h3 className="mt-4 font-sans font-bold text-xl">{step.name}</h3>
|
||
<p className="mt-3 text-muted-dark leading-relaxed">{step.desc}</p>
|
||
</motion.li>
|
||
))}
|
||
</motion.ol>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- Mecanismo`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Mecanismo } from '@/components/sections/Mecanismo';
|
||
// after <ParaQuien />: <Mecanismo />
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: Mecanismo section (04, dark) — 3-step method"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Disciplinas (section 05)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Disciplinas.tsx`
|
||
- Test: `components/sections/__tests__/Disciplinas.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `disciplinas` content, `Container`.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/Disciplinas.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Disciplinas } from '../Disciplinas';
|
||
|
||
describe('<Disciplinas />', () => {
|
||
it('renders the 3 disciplines', () => {
|
||
render(<Disciplinas />);
|
||
expect(screen.getByText('Comunicación')).toBeInTheDocument();
|
||
expect(screen.getByText('Digital')).toBeInTheDocument();
|
||
expect(screen.getByText('Experiencias')).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- Disciplinas`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/Disciplinas.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { disciplinas } from '@/lib/content';
|
||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||
|
||
export function Disciplinas() {
|
||
return (
|
||
<section className="bg-base">
|
||
<Container className="py-24 md:py-32">
|
||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||
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}
|
||
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}
|
||
className="mt-4 text-muted text-lg">{disciplinas.lead}</motion.p>
|
||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||
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">
|
||
<h3 className="font-serif italic font-bold text-2xl text-ink">{item.name}</h3>
|
||
<p className="mt-3 text-muted leading-relaxed">{item.desc}</p>
|
||
</motion.div>
|
||
))}
|
||
</motion.div>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- Disciplinas`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Disciplinas } from '@/components/sections/Disciplinas';
|
||
// after <Mecanismo />: <Disciplinas />
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: Disciplinas section (05) — pillars as tools"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Estratega (section 06)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Estratega.tsx`
|
||
- Test: `components/sections/__tests__/Estratega.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `estratega` content, `Container`. Includes portrait (placeholder gradient if no photo yet).
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/Estratega.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Estratega } from '../Estratega';
|
||
|
||
describe('<Estratega />', () => {
|
||
it('renders the bio', () => {
|
||
render(<Estratega />);
|
||
expect(screen.getByText(/30 años/i)).toBeInTheDocument();
|
||
});
|
||
it('renders the institutional quote', () => {
|
||
render(<Estratega />);
|
||
expect(screen.getByText(/El tiempo no se compra/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- Estratega`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/Estratega.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { estratega } from '@/lib/content';
|
||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
||
|
||
export function Estratega() {
|
||
return (
|
||
<section className="bg-base">
|
||
<Container className="py-24 md:py-32">
|
||
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
|
||
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}
|
||
className="w-40 h-40 md:w-56 md:h-56 rounded-full flex-shrink-0"
|
||
style={{ background: 'linear-gradient(135deg, #0A1F44 0%, #B8901f 100%)' }}
|
||
aria-label="Retrato de la estratega"
|
||
role="img"
|
||
/>
|
||
<div>
|
||
<motion.p variants={fadeInUp} className="text-xs tracking-[0.3em] uppercase font-semibold text-gold">{estratega.eyebrow}</motion.p>
|
||
<motion.h2 variants={fadeInUp} className="mt-4 font-serif italic font-bold text-3xl md:text-5xl text-ink">{estratega.title}</motion.h2>
|
||
<motion.p variants={fadeInUp} className="mt-6 text-lg text-muted leading-relaxed max-w-2xl">{estratega.bio}</motion.p>
|
||
<motion.blockquote variants={fadeInUp} className="mt-6 font-serif italic text-xl md:text-2xl text-ink border-l-4 border-gold pl-4">
|
||
{estratega.quote}
|
||
</motion.blockquote>
|
||
</div>
|
||
</motion.div>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
> **Note:** When the dueña provides a real photo, replace the gradient div with `<Image src="/retrato.jpg" alt="..." width={224} height={224} className="rounded-full" />` using `next/image`. Keep `aria-label`/`alt` meaningful.
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- Estratega`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Estratega } from '@/components/sections/Estratega';
|
||
// after <Disciplinas />: <Estratega />
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: Estratega section (06) — portrait + bio + quote"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Cierre + contacto (section 07, dark)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Cierre.tsx`
|
||
- Test: `components/sections/__tests__/Cierre.test.tsx`
|
||
- Modify: `app/page.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `cierre` content (includes contact links), `Container`, `CTA`, `Wordmark` (in footer). Dark section.
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`components/sections/__tests__/Cierre.test.tsx`:
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Cierre } from '../Cierre';
|
||
|
||
describe('<Cierre />', () => {
|
||
it('renders the closing quote', () => {
|
||
render(<Cierre />);
|
||
expect(screen.getByText(/Devuélvele claridad/i)).toBeInTheDocument();
|
||
});
|
||
it('renders 3 contact links with correct hrefs', () => {
|
||
render(<Cierre />);
|
||
const wa = screen.getByRole('link', { name: /whatsapp/i });
|
||
const mail = screen.getByRole('link', { name: /email/i });
|
||
const li = screen.getByRole('link', { name: /linkedin/i });
|
||
expect(wa.getAttribute('href')).toMatch(/^https:\/\/wa\.me\//);
|
||
expect(mail.getAttribute('href')).toMatch(/^mailto:/);
|
||
expect(li.getAttribute('href')).toMatch(/^https:\/\/(www\.)?linkedin\./);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test — expected FAIL**
|
||
|
||
Run: `npm test -- Cierre`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`components/sections/Cierre.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
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';
|
||
|
||
export function Cierre() {
|
||
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}
|
||
className="flex flex-col items-center">
|
||
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div>
|
||
<motion.blockquote variants={fadeInUp}
|
||
className="mt-10 font-serif italic text-2xl md:text-4xl font-bold leading-snug max-w-3xl">
|
||
<span className="text-gold-bright">"</span>{cierre.quote}<span className="text-gold-bright">"</span>
|
||
</motion.blockquote>
|
||
<motion.div variants={fadeInUp} className="mt-10">
|
||
<CTA href={whatsapp.href} label={cierre.cta} variant="dark" />
|
||
</motion.div>
|
||
<motion.ul variants={fadeInUp} className="mt-12 flex flex-wrap justify-center gap-6 text-sm">
|
||
<li><a href={whatsapp.href} className="hover:text-gold-bright transition-colors">{whatsapp.label}</a></li>
|
||
<li aria-hidden="true" className="text-muted-dark">·</li>
|
||
<li><a href={email.href} className="hover:text-gold-bright transition-colors">{email.label}</a></li>
|
||
<li aria-hidden="true" className="text-muted-dark">·</li>
|
||
<li><a href={linkedin.href} className="hover:text-gold-bright transition-colors">{linkedin.label}</a></li>
|
||
</motion.ul>
|
||
</motion.div>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test — expected PASS**
|
||
|
||
Run: `npm test -- Cierre`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add to `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Cierre } from '@/components/sections/Cierre';
|
||
// after <Estratega />: <Cierre />
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: Cierre section (07, dark) — closing quote + contact links"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Compose page + animations + reduced-motion
|
||
|
||
**Files:**
|
||
- Modify: `app/page.tsx`, `lib/motion.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: the full scrollable page with all 7 sections in order, Framer Motion animations, and `prefers-reduced-motion` support.
|
||
|
||
- [ ] **Step 1: Final `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Hero } from '@/components/sections/Hero';
|
||
import { FraseAncla } from '@/components/sections/FraseAncla';
|
||
import { ParaQuien } from '@/components/sections/ParaQuien';
|
||
import { Mecanismo } from '@/components/sections/Mecanismo';
|
||
import { Disciplinas } from '@/components/sections/Disciplinas';
|
||
import { Estratega } from '@/components/sections/Estratega';
|
||
import { Cierre } from '@/components/sections/Cierre';
|
||
|
||
export default function Home() {
|
||
return (
|
||
<main>
|
||
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
||
<FraseAncla />
|
||
<ParaQuien />
|
||
<Mecanismo />
|
||
<Disciplinas />
|
||
<Estratega />
|
||
<Cierre />
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add reduced-motion support to `lib/motion.ts`**
|
||
|
||
Append to `lib/motion.ts`:
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
|
||
// 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 }
|
||
: {};
|
||
```
|
||
|
||
- [ ] **Step 3: Apply in each animated component**
|
||
|
||
For each section using `motion`, replace its `variants/initial/whileInView` props with reduced-motion-aware versions. Example for `FraseAncla.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
import { motion } from 'framer-motion';
|
||
import { Container } from '@/components/primitives/Container';
|
||
import { fraseAncla } from '@/lib/content';
|
||
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">
|
||
<motion.blockquote
|
||
variants={fadeInUp}
|
||
initial={reduced ? 'visible' : 'hidden'}
|
||
whileInView={reduced ? undefined : 'visible'}
|
||
viewport={reduced ? undefined : viewportOnce}
|
||
className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink"
|
||
>
|
||
{/* ...quote content... */}
|
||
</motion.blockquote>
|
||
</Container>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
Repeat the same `reduced` pattern for: `Hero`, `ParaQuien`, `Mecanismo`, `Disciplinas`, `Estratega`, `Cierre`.
|
||
|
||
- [ ] **Step 4: Run full test suite**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests PASS.
|
||
|
||
- [ ] **Step 5: Visual verification**
|
||
|
||
Run: `npm run dev`
|
||
Expected: all 7 sections render in order with smooth scroll animations. Toggle `prefers-reduced-motion` in DevTools → animations should disable (content appears immediately).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: compose page + Framer Motion animations + reduced-motion support"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: SEO metadata, accessibility, static build verification
|
||
|
||
**Files:**
|
||
- Modify: `app/layout.tsx` (full metadata), `app/page.tsx` (semantic HTML)
|
||
- Create: `public/og-image.png` (manually), `public/robots.txt`, `public/sitemap.xml`
|
||
- Verify: `npm run build` produces static HTML in `out/`
|
||
|
||
**Interfaces:**
|
||
- Produces: complete SEO metadata, OG image, semantic HTML, accessible contact links, passing static export build.
|
||
|
||
- [ ] **Step 1: Expand metadata in `app/layout.tsx`**
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import { Inter } from 'next/font/google';
|
||
import './globals.css';
|
||
|
||
const inter = Inter({ subsets: ['latin'], weight: ['400', '600', '700', '900'], variable: '--font-inter', display: 'swap' });
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'creeer · tu estratega de crecimiento',
|
||
description: 'Claridad estratégica para empresarios con tracción que llegaron a su punto de inflexión. Inmersión profunda, estrategia ejecutable.',
|
||
metadataBase: new URL('https://creeer.com'), // TODO dueña: real domain
|
||
openGraph: {
|
||
title: 'creeer · tu estratega de crecimiento',
|
||
description: 'Claridad estratégica para empresarios con tracción en punto de inflexión.',
|
||
type: 'website',
|
||
locale: 'es',
|
||
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'creeer' }],
|
||
},
|
||
twitter: { card: 'summary_large_image', title: 'creeer', description: 'Claridad estratégica para empresarios.' },
|
||
alternates: { canonical: 'https://creeer.com' },
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="es" className={inter.variable}>
|
||
<body>{children}</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add JSON-LD structured data in `app/page.tsx`**
|
||
|
||
Wrap the `<main>` content with a structured data script. Add at top of `Home()`:
|
||
|
||
```tsx
|
||
const jsonLd = {
|
||
'@context': 'https://schema.org',
|
||
'@type': 'ProfessionalService',
|
||
name: 'creeer',
|
||
description: 'Estratega de crecimiento. Claridad estratégica para empresarios con tracción.',
|
||
url: 'https://creeer.com',
|
||
};
|
||
|
||
export default function Home() {
|
||
return (
|
||
<>
|
||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||
<main>
|
||
{/* ...sections... */}
|
||
</main>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `public/robots.txt`**
|
||
|
||
```
|
||
User-agent: *
|
||
Allow: /
|
||
Sitemap: https://creeer.com/sitemap.xml
|
||
```
|
||
|
||
- [ ] **Step 4: Create `public/sitemap.xml`**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||
<url><loc>https://creeer.com/</loc><changefreq>monthly</changefreq><priority>1.0</priority></url>
|
||
</urlset>
|
||
```
|
||
|
||
- [ ] **Step 5: Create OG image**
|
||
|
||
Manually create `public/og-image.png` (1200×630) — use the wordmark `cre`**`e`**`er.` on navy `#0A1F44` background with the headline. If no design tool available, use a placeholder PNG for now and replace later.
|
||
|
||
- [ ] **Step 6: Add focus-visible styles to `app/globals.css`**
|
||
|
||
Append to `globals.css`:
|
||
|
||
```css
|
||
@layer base {
|
||
a:focus-visible, button:focus-visible {
|
||
outline: 3px solid #D4AF37;
|
||
outline-offset: 3px;
|
||
border-radius: 2px;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run final test suite**
|
||
|
||
Run: `npm test`
|
||
Expected: all tests PASS.
|
||
|
||
- [ ] **Step 8: Run static build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds, outputs `out/` directory with static HTML. No errors.
|
||
|
||
- [ ] **Step 9: Run lint**
|
||
|
||
Run: `npm run lint`
|
||
Expected: passes (or only minor warnings).
|
||
|
||
- [ ] **Step 10: Manual accessibility smoke test**
|
||
|
||
Open `npm run dev`, run browser DevTools Lighthouse audit (or axe extension). Expected:
|
||
- Contrast ratios pass (navy on white: 14.6:1; marfil on navy: 13.1:1)
|
||
- `<html lang="es">` set
|
||
- All contact links have discernible text
|
||
- No duplicate `<h1>` (only Hero has h1)
|
||
|
||
- [ ] **Step 11: Final commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: SEO metadata, JSON-LD, OG image, sitemap, a11y focus styles"
|
||
```
|
||
|
||
---
|
||
|
||
## Spec Coverage Self-Check
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| 3.1 Paleta (tokens) | Task 2 |
|
||
| 3.2 Tipografía híbrida (Georgia + Inter) | Task 2, 3 |
|
||
| 3.3 Escala tipográfica | Tasks 4–10 (applied in Tailwind classes) |
|
||
| 3.4 Wordmark | Task 3 |
|
||
| 3.5 Recursos (•, smart quotes, →) | Tasks 4–10 |
|
||
| 3.6 Espaciado y ritmo | Tasks 4–10 (`py-24 md:py-32`, `Container`) |
|
||
| 3.7 Layout claro/oscuro | Tasks 4–10 (`bg-base`, `bg-bg-dark`) |
|
||
| 4. Hero (HV-1 + V-a) | Task 4 |
|
||
| 5. Flujo 7 secciones | Tasks 4–10 |
|
||
| 6. Messaging (frases-ancla) | Task 2 (content.ts) |
|
||
| 7. Restricciones técnicas | Tasks 1, 10 |
|
||
| 8. SEO / a11y / perf | Task 12 |
|
||
| 9. Animación (Framer Motion + reduced) | Task 11 |
|
||
|
||
**Placeholder scan:** None. All steps contain complete code. The `cierre.contact` hrefs in `lib/content.ts` are marked `TODO dueña` for real values — that's a content decision (not implementation) and documented in spec §10.
|
||
|
||
**Type consistency:** All section components share the same interface signature (`export function SectionName()` with no props, except `<Hero variant>`). All consume the same primitives (`<Container>`, `<CTA>`, `<Wordmark>`) and content objects. ✓
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
Plan complete. Two execution options:
|
||
|
||
**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task with review between tasks. Best for catching issues early and keeping each task focused.
|
||
|
||
**2. Inline Execution** — execute tasks in this session using executing-plans, with checkpoints for review. Best when you want tight loop with the user.
|
||
|
||
**Which approach?**
|