1436 lines
43 KiB
Markdown
1436 lines
43 KiB
Markdown
# creeer Landing Page 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:** Build the one-page `creeer` landing site (7 sections) as a statically-exported Next.js 14 App Router site, matching the approved design spec exactly.
|
||
|
||
**Architecture:** A single `app/page.tsx` assembles seven independent section components (`components/sections/*.tsx`), each wrapped in a shared `MotionSection` scroll-reveal wrapper that respects `prefers-reduced-motion`. A shared `Wordmark` component renders the brand mark everywhere it appears. No backend, no client-side state beyond motion — content is static JSX.
|
||
|
||
**Tech Stack:** Next.js 14 (App Router) + TypeScript + Tailwind CSS 3 + Framer Motion 11, `output: export`. Tests: Vitest + React Testing Library + jsdom.
|
||
|
||
## Global Constraints
|
||
|
||
- Idioma: español, `lang="es"` en `<html>`.
|
||
- Wordmark SIEMPRE minúsculas: `creeer` — nunca `CREEER` ni `Creeer`.
|
||
- Paleta exacta (no aproximar): `bg-base #FBFAF7`, `bg-pure #FFFFFF`, `bg-dark #0A1F44`, `text-primary #0A1F44`, `text-on-dark #F5EBD0`, `accent-gold #B8901f`, `accent-gold-bright #D4AF37`, `text-muted #5a5168`, `text-muted-dark #9aa6c2`. NO amarillo eléctrico tipo Hormozi.
|
||
- Sin backend, sin formulario — contacto solo por enlaces directos (WhatsApp/email/LinkedIn).
|
||
- `next.config.js` debe tener `output: 'export'` **y** `images.unoptimized: true` (la optimización de imágenes de Next no funciona en export estático).
|
||
- Container max-width: `1152px`.
|
||
- Voz: segunda persona ("tú"/"tu negocio"), sin jerga corporativa.
|
||
- Toda animación Framer Motion debe desactivarse con `useReducedMotion()`.
|
||
- Contraste WCAG 2.2 AA: `accent-gold` (#B8901f) solo para texto grande/decorativo, nunca body text sobre fondo claro.
|
||
- Un único `<h1>` en toda la página (vive en el Hero).
|
||
- Datos de contacto reales (WhatsApp/email/LinkedIn), foto de la dueña y elección final HV-1 vs V-a están **pendientes de la dueña** (ver spec §10). Este plan usa placeholders explícitos marcados como tales — no bloquean el build.
|
||
|
||
---
|
||
|
||
### Task 1: Next.js scaffold, Tailwind tokens, fonts, root layout
|
||
|
||
**Files:**
|
||
- Create: `package.json`
|
||
- Create: `tsconfig.json`
|
||
- Create: `next.config.js`
|
||
- Create: `postcss.config.js`
|
||
- Create: `tailwind.config.ts`
|
||
- Create: `.eslintrc.json`
|
||
- Create: `vitest.config.ts`
|
||
- Create: `vitest.setup.ts`
|
||
- Create: `test/mocks/next-font-google.ts`
|
||
- Create: `app/layout.tsx`
|
||
- Create: `app/layout.test.tsx`
|
||
- Create: `app/globals.css`
|
||
- Create: `app/page.tsx` (placeholder, replaced in Task 11)
|
||
- Create: `components/MotionProvider.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: Tailwind color tokens (`bg-base`, `bg-pure`, `bg-dark`, `text-primary`, `text-on-dark`, `accent-gold`, `accent-gold-bright`, `text-muted`, `text-muted-dark`), `font-serif` (Georgia), `font-sans` (Inter via `--font-inter`), `max-w-container` (1152px). All later tasks consume these.
|
||
- Produces: working `npm test` (Vitest) and `npm run build` (static export) pipelines.
|
||
- Produces: `MotionProvider({ children: ReactNode })` at `components/MotionProvider.tsx`, wrapping the app in `<LazyMotion features={domAnimation} strict>` per spec §8.3 (bundle-size). Consumed by `app/layout.tsx` here, and required context for Tasks 3 and 4, which must use framer-motion's `m.*` components (not `motion.*`) so the `strict` flag catches accidental full-bundle imports.
|
||
|
||
- [ ] **Step 1: Write `package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "creeer",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "next dev",
|
||
"build": "next build",
|
||
"start": "next start",
|
||
"lint": "next lint",
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
},
|
||
"dependencies": {
|
||
"framer-motion": "^11.3.19",
|
||
"next": "^14.2.5",
|
||
"react": "^18.3.1",
|
||
"react-dom": "^18.3.1"
|
||
},
|
||
"devDependencies": {
|
||
"@testing-library/jest-dom": "^6.4.8",
|
||
"@testing-library/react": "^16.0.0",
|
||
"@types/node": "^20.14.14",
|
||
"@types/react": "^18.3.3",
|
||
"@types/react-dom": "^18.3.0",
|
||
"@vitejs/plugin-react": "^4.3.1",
|
||
"autoprefixer": "^10.4.19",
|
||
"eslint": "^8.57.0",
|
||
"eslint-config-next": "^14.2.5",
|
||
"jsdom": "^24.1.1",
|
||
"postcss": "^8.4.40",
|
||
"tailwindcss": "^3.4.7",
|
||
"typescript": "^5.5.4",
|
||
"vitest": "^2.0.5"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write `tsconfig.json`**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2017",
|
||
"lib": ["dom", "dom.iterable", "esnext"],
|
||
"allowJs": true,
|
||
"skipLibCheck": true,
|
||
"strict": true,
|
||
"noEmit": true,
|
||
"esModuleInterop": true,
|
||
"module": "esnext",
|
||
"moduleResolution": "bundler",
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"jsx": "preserve",
|
||
"incremental": true,
|
||
"plugins": [{ "name": "next" }],
|
||
"paths": { "@/*": ["./*"] }
|
||
},
|
||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||
"exclude": ["node_modules"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write `next.config.js`**
|
||
|
||
```js
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
output: 'export',
|
||
images: {
|
||
unoptimized: true,
|
||
},
|
||
};
|
||
|
||
module.exports = nextConfig;
|
||
```
|
||
|
||
- [ ] **Step 4: Write `postcss.config.js`**
|
||
|
||
```js
|
||
module.exports = {
|
||
plugins: {
|
||
tailwindcss: {},
|
||
autoprefixer: {},
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Write `tailwind.config.ts`**
|
||
|
||
```ts
|
||
import type { Config } from 'tailwindcss';
|
||
|
||
const config: Config = {
|
||
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
'bg-base': '#FBFAF7',
|
||
'bg-pure': '#FFFFFF',
|
||
'bg-dark': '#0A1F44',
|
||
'text-primary': '#0A1F44',
|
||
'text-on-dark': '#F5EBD0',
|
||
'accent-gold': '#B8901f',
|
||
'accent-gold-bright': '#D4AF37',
|
||
'text-muted': '#5a5168',
|
||
'text-muted-dark': '#9aa6c2',
|
||
},
|
||
fontFamily: {
|
||
serif: ['Georgia', 'serif'],
|
||
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
|
||
},
|
||
maxWidth: {
|
||
container: '1152px',
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
};
|
||
|
||
export default config;
|
||
```
|
||
|
||
- [ ] **Step 6: Write `.eslintrc.json`**
|
||
|
||
```json
|
||
{
|
||
"extends": "next/core-web-vitals"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Write `test/mocks/next-font-google.ts`**
|
||
|
||
Vitest doesn't run through Next's compiler, so `next/font/google` can't resolve its font manifest. Alias it to this mock so components importing it stay testable.
|
||
|
||
```ts
|
||
export function Inter() {
|
||
return {
|
||
className: 'font-inter-mock',
|
||
variable: '--font-inter',
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Write `vitest.config.ts`**
|
||
|
||
```ts
|
||
import path from 'path';
|
||
import react from '@vitejs/plugin-react';
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
environment: 'jsdom',
|
||
setupFiles: ['./vitest.setup.ts'],
|
||
globals: true,
|
||
},
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, '.'),
|
||
'next/font/google': path.resolve(
|
||
__dirname,
|
||
'test/mocks/next-font-google.ts'
|
||
),
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 9: Write `vitest.setup.ts`**
|
||
|
||
```ts
|
||
import '@testing-library/jest-dom/vitest';
|
||
|
||
class MockIntersectionObserver {
|
||
observe() {}
|
||
unobserve() {}
|
||
disconnect() {}
|
||
}
|
||
|
||
// @ts-expect-error jsdom doesn't implement IntersectionObserver
|
||
global.IntersectionObserver = MockIntersectionObserver;
|
||
|
||
Object.defineProperty(window, 'matchMedia', {
|
||
writable: true,
|
||
value: (query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: () => {},
|
||
removeListener: () => {},
|
||
addEventListener: () => {},
|
||
removeEventListener: () => {},
|
||
dispatchEvent: () => false,
|
||
}),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 10: Write `components/MotionProvider.tsx`**
|
||
|
||
Wraps the app in Framer Motion's `LazyMotion` so every animated component can import the lightweight `m.*` primitives instead of the full `motion.*` bundle (spec §8.3: "usar `LazyMotion` + `domAnimation` para reducir bundle"). `strict` makes framer-motion throw if a `motion.*` component is ever used underneath by mistake.
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import type { ReactNode } from 'react';
|
||
import { LazyMotion, domAnimation } from 'framer-motion';
|
||
|
||
export function MotionProvider({ children }: { children: ReactNode }) {
|
||
return (
|
||
<LazyMotion features={domAnimation} strict>
|
||
{children}
|
||
</LazyMotion>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11: Write `app/globals.css`**
|
||
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
:root {
|
||
color-scheme: light;
|
||
}
|
||
|
||
*:focus-visible {
|
||
outline: 3px solid #b8901f;
|
||
outline-offset: 2px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Write `app/layout.tsx`**
|
||
|
||
```tsx
|
||
import type { Metadata } from 'next';
|
||
import type { ReactNode } from 'react';
|
||
import { Inter } from 'next/font/google';
|
||
import { MotionProvider } from '@/components/MotionProvider';
|
||
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 un punto de inflexión. Define tu siguiente movimiento.',
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||
return (
|
||
<html lang="es" className={inter.variable}>
|
||
<body className="bg-bg-base font-sans text-text-primary antialiased">
|
||
<MotionProvider>{children}</MotionProvider>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 13: Write the failing test `app/layout.test.tsx`**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render } from '@testing-library/react';
|
||
import RootLayout, { metadata } from './layout';
|
||
|
||
describe('RootLayout', () => {
|
||
it('sets the approved SEO title and description', () => {
|
||
expect(metadata.title).toBe('creeer · tu estratega de crecimiento');
|
||
expect(metadata.description).toContain('Claridad estratégica');
|
||
});
|
||
|
||
it('declares Spanish as the document language', () => {
|
||
const { container } = render(
|
||
<RootLayout>
|
||
<p>contenido</p>
|
||
</RootLayout>
|
||
);
|
||
expect(container.querySelector('html')).toHaveAttribute('lang', 'es');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 14: Write the placeholder `app/page.tsx`**
|
||
|
||
Temporary stub — replaced entirely in Task 11 once all sections exist.
|
||
|
||
```tsx
|
||
export default function Home() {
|
||
return (
|
||
<main>
|
||
<h1>creeer</h1>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 15: Install dependencies**
|
||
|
||
Run: `npm install`
|
||
Expected: installs without errors, creates `node_modules/` and `package-lock.json`.
|
||
|
||
- [ ] **Step 16: Run the test to verify it passes**
|
||
|
||
Run: `npm test -- app/layout.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 17: Verify the static export build works**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds, output includes a line for route `/`, and `out/index.html` exists afterward.
|
||
|
||
- [ ] **Step 18: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json tsconfig.json next.config.js postcss.config.js tailwind.config.ts .eslintrc.json vitest.config.ts vitest.setup.ts test/ app/ components/MotionProvider.tsx
|
||
git commit -m "chore: scaffold Next.js app with Tailwind tokens, fonts, and test runner"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Wordmark component
|
||
|
||
**Files:**
|
||
- Create: `components/Wordmark.tsx`
|
||
- Create: `components/Wordmark.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tailwind tokens from Task 1 (`text-primary`, `text-on-dark`, `accent-gold`, `font-serif`).
|
||
- Produces: `Wordmark({ size?: 'base' | 'lg', onDark?: boolean })` at `components/Wordmark.tsx`. Consumed by Task 4 (Hero).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render } from '@testing-library/react';
|
||
import { Wordmark } from './Wordmark';
|
||
|
||
describe('Wordmark', () => {
|
||
it('renders the middle "e" in gold with heavier weight than the rest', () => {
|
||
const { container } = render(<Wordmark />);
|
||
const goldE = container.querySelector('.font-black.text-accent-gold');
|
||
expect(goldE).not.toBeNull();
|
||
expect(goldE?.textContent).toBe('e');
|
||
});
|
||
|
||
it('renders the trailing period in gold', () => {
|
||
const { container } = render(<Wordmark />);
|
||
const spans = container.querySelectorAll('span.text-accent-gold');
|
||
const period = Array.from(spans).find((el) => el.textContent === '.');
|
||
expect(period).not.toBeUndefined();
|
||
});
|
||
|
||
it('never renders the wordmark in uppercase', () => {
|
||
const { container } = render(<Wordmark />);
|
||
expect(container.textContent?.toLowerCase()).toBe(container.textContent);
|
||
});
|
||
|
||
it('switches to the on-dark ink color when onDark is true', () => {
|
||
const { container } = render(<Wordmark onDark />);
|
||
expect(container.firstElementChild?.className).toContain(
|
||
'text-text-on-dark'
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/Wordmark.test.tsx`
|
||
Expected: FAIL with "Cannot find module './Wordmark'".
|
||
|
||
- [ ] **Step 3: Write `components/Wordmark.tsx`**
|
||
|
||
```tsx
|
||
export function Wordmark({
|
||
size = 'base',
|
||
onDark = false,
|
||
}: {
|
||
size?: 'base' | 'lg';
|
||
onDark?: boolean;
|
||
}) {
|
||
const inkColor = onDark ? 'text-text-on-dark' : 'text-text-primary';
|
||
const sizeClass = size === 'lg' ? 'text-4xl md:text-5xl' : 'text-2xl';
|
||
|
||
return (
|
||
<span className={`font-serif italic font-bold ${sizeClass} ${inkColor}`}>
|
||
cre
|
||
<span className="font-black text-accent-gold">e</span>
|
||
er
|
||
<span className="text-accent-gold">.</span>
|
||
</span>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/Wordmark.test.tsx`
|
||
Expected: PASS, 4 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/Wordmark.tsx components/Wordmark.test.tsx
|
||
git commit -m "feat: add Wordmark component with gold middle-e brand rule"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: MotionSection scroll-reveal wrapper
|
||
|
||
**Files:**
|
||
- Create: `components/MotionSection.tsx`
|
||
- Create: `components/MotionSection.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `framer-motion`'s `m` and `useReducedMotion` (the `m.*` lightweight primitives, not `motion.*` — see `MotionProvider` in Task 1, which enforces this via `strict`).
|
||
- Produces: `MotionSection({ children: ReactNode, className?: string })` at `components/MotionSection.tsx`, rendering a `<section>`. Consumed by Tasks 5–10 (all sections except Hero, which has its own staggered entrance).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { cleanup, render } from '@testing-library/react';
|
||
import { LazyMotion, domAnimation } from 'framer-motion';
|
||
import { MotionSection } from './MotionSection';
|
||
|
||
function mockReducedMotion(matches: boolean) {
|
||
vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
|
||
matches,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: () => {},
|
||
removeListener: () => {},
|
||
addEventListener: () => {},
|
||
removeEventListener: () => {},
|
||
dispatchEvent: () => false,
|
||
}));
|
||
}
|
||
|
||
function renderWithMotion(ui: React.ReactElement) {
|
||
return render(
|
||
<LazyMotion features={domAnimation}>{ui}</LazyMotion>
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('MotionSection', () => {
|
||
it('renders a plain section with no inline animation styles when reduced motion is preferred', () => {
|
||
mockReducedMotion(true);
|
||
const { container } = renderWithMotion(
|
||
<MotionSection>
|
||
<p>contenido</p>
|
||
</MotionSection>
|
||
);
|
||
const section = container.querySelector('section');
|
||
expect(section).not.toBeNull();
|
||
expect(section?.getAttribute('style')).toBeNull();
|
||
});
|
||
|
||
it('applies an initial opacity of 0 before entering the viewport when motion is allowed', () => {
|
||
mockReducedMotion(false);
|
||
const { container } = renderWithMotion(
|
||
<MotionSection>
|
||
<p>contenido</p>
|
||
</MotionSection>
|
||
);
|
||
expect(container.querySelector('section')?.getAttribute('style')).toContain(
|
||
'opacity: 0'
|
||
);
|
||
});
|
||
|
||
it('always renders its children regardless of motion preference', () => {
|
||
mockReducedMotion(true);
|
||
const { getByText } = renderWithMotion(
|
||
<MotionSection>
|
||
<p>contenido visible</p>
|
||
</MotionSection>
|
||
);
|
||
expect(getByText('contenido visible')).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/MotionSection.test.tsx`
|
||
Expected: FAIL with "Cannot find module './MotionSection'".
|
||
|
||
- [ ] **Step 3: Write `components/MotionSection.tsx`**
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import type { ReactNode } from 'react';
|
||
import { m, useReducedMotion } from 'framer-motion';
|
||
|
||
const EASE_OUT_EXPO: [number, number, number, number] = [0.22, 1, 0.36, 1];
|
||
|
||
export function MotionSection({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
const shouldReduceMotion = useReducedMotion();
|
||
|
||
if (shouldReduceMotion) {
|
||
return <section className={className}>{children}</section>;
|
||
}
|
||
|
||
return (
|
||
<m.section
|
||
className={className}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true }}
|
||
transition={{ duration: 0.5, ease: EASE_OUT_EXPO }}
|
||
>
|
||
{children}
|
||
</m.section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/MotionSection.test.tsx`
|
||
Expected: PASS, 3 tests passing.
|
||
|
||
Note: this component renders `m.section` without a `LazyMotion` ancestor in production only through `app/layout.tsx`'s `MotionProvider` (Task 1) — outside of a real render tree, `m` components need that provider to have their animation features loaded, which is why the test above wraps every render in its own `<LazyMotion features={domAnimation}>`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/MotionSection.tsx components/MotionSection.test.tsx
|
||
git commit -m "feat: add MotionSection scroll-reveal wrapper with reduced-motion support"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Hero section (01)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Hero.tsx`
|
||
- Create: `components/sections/Hero.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Wordmark` (Task 2).
|
||
- Produces: `Hero({ variant?: 'hormozi' | 'editorial' })` at `components/sections/Hero.tsx`, default `'editorial'`. Renders the page's single `<h1>` and the CTA linking to `#contacto`. Consumed by Task 11.
|
||
|
||
**Note on the default:** the spec leaves HV-1 vs V-a as the dueña's decision (§4, §10). `variant` defaults to `'editorial'` (V-a) because it's the closer match to the master tone "seguridad + cercanía" — swap the default or the call site in Task 11 the moment she decides.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { Hero } from './Hero';
|
||
|
||
describe('Hero', () => {
|
||
it('renders the editorial variant in Georgia italic by default', () => {
|
||
render(<Hero />);
|
||
const headline = screen.getByRole('heading', { level: 1 });
|
||
expect(headline.className).toContain('font-serif');
|
||
expect(headline.className).toContain('italic');
|
||
});
|
||
|
||
it('renders the hormozi variant in uppercase sans when requested', () => {
|
||
render(<Hero variant="hormozi" />);
|
||
const headline = screen.getByRole('heading', { level: 1 });
|
||
expect(headline.className).toContain('uppercase');
|
||
expect(headline.className).not.toContain('font-serif');
|
||
});
|
||
|
||
it('renders exactly one h1 with the approved headline copy', () => {
|
||
render(<Hero />);
|
||
const headings = screen.getAllByRole('heading', { level: 1 });
|
||
expect(headings).toHaveLength(1);
|
||
expect(headings[0].textContent).toContain('más esfuerzo');
|
||
expect(headings[0].textContent).toContain('más claridad');
|
||
});
|
||
|
||
it('renders the CTA linking to the contact section', () => {
|
||
render(<Hero />);
|
||
const cta = screen.getByRole('link', {
|
||
name: /Define tu siguiente movimiento/,
|
||
});
|
||
expect(cta).toHaveAttribute('href', '#contacto');
|
||
});
|
||
|
||
it('never shows scarcity language in the hero', () => {
|
||
render(<Hero />);
|
||
const text = screen.getByRole('heading', { level: 1 }).textContent ?? '';
|
||
expect(text).not.toMatch(/cupo|cohorte|generaci[oó]n fundadora/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/Hero.test.tsx`
|
||
Expected: FAIL with "Cannot find module './Hero'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/Hero.tsx`**
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { m, useReducedMotion } from 'framer-motion';
|
||
import { Wordmark } from '@/components/Wordmark';
|
||
|
||
const EASE_OUT_EXPO: [number, number, number, number] = [0.22, 1, 0.36, 1];
|
||
const STAGGER_DELAYS = [0, 0.1, 0.2, 0.3];
|
||
|
||
type HeroVariant = 'hormozi' | 'editorial';
|
||
|
||
function HeroHeadline({ variant }: { variant: HeroVariant }) {
|
||
if (variant === 'hormozi') {
|
||
return (
|
||
<h1 className="font-sans text-[32px] font-black uppercase leading-tight tracking-[-1.5px] md:text-[48px]">
|
||
Tu negocio no necesita <span className="text-accent-gold">más esfuerzo</span>.
|
||
<br />
|
||
Necesita{' '}
|
||
<span className="underline decoration-accent-gold-bright decoration-[5px] underline-offset-4">
|
||
más claridad
|
||
</span>
|
||
.
|
||
</h1>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<h1 className="font-serif text-[32px] font-bold italic leading-tight md:text-[48px]">
|
||
Tu negocio no necesita <span className="text-accent-gold">más esfuerzo</span>.
|
||
<br />
|
||
Necesita{' '}
|
||
<span className="underline decoration-accent-gold underline-offset-4">
|
||
más claridad
|
||
</span>
|
||
.
|
||
</h1>
|
||
);
|
||
}
|
||
|
||
export function Hero({ variant = 'editorial' }: { variant?: HeroVariant }) {
|
||
const shouldReduceMotion = useReducedMotion();
|
||
|
||
const item = (delay: number) =>
|
||
shouldReduceMotion
|
||
? {}
|
||
: {
|
||
initial: { opacity: 0, y: 16 },
|
||
animate: { opacity: 1, y: 0 },
|
||
transition: { duration: 0.5, delay, ease: EASE_OUT_EXPO },
|
||
};
|
||
|
||
return (
|
||
<section className="flex min-h-screen flex-col items-center justify-center gap-6 bg-bg-pure px-6 text-center md:px-12">
|
||
<m.div {...item(STAGGER_DELAYS[0])}>
|
||
<Wordmark size="lg" />
|
||
</m.div>
|
||
<m.p
|
||
{...item(STAGGER_DELAYS[1])}
|
||
className="text-[11px] font-semibold uppercase tracking-[3px] text-accent-gold md:text-xs"
|
||
>
|
||
— tu estratega de crecimiento —
|
||
</m.p>
|
||
<m.div {...item(STAGGER_DELAYS[2])}>
|
||
<HeroHeadline variant={variant} />
|
||
</m.div>
|
||
<m.a
|
||
{...item(STAGGER_DELAYS[3])}
|
||
href="#contacto"
|
||
className="mt-4 inline-block bg-text-primary px-8 py-4 text-base font-semibold text-white"
|
||
>
|
||
Define tu siguiente movimiento →
|
||
</m.a>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
Note: Hero's tests only assert `className`/text/`href`, none of which depend on the animation feature bundle being loaded, so `Hero.test.tsx` renders the component directly with no `LazyMotion` wrapper needed (unlike `MotionSection.test.tsx`).
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/Hero.test.tsx`
|
||
Expected: PASS, 5 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/Hero.tsx components/sections/Hero.test.tsx
|
||
git commit -m "feat: add Hero section with hormozi/editorial variant switch"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: AnchorQuote section (02)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/AnchorQuote.tsx`
|
||
- Create: `components/sections/AnchorQuote.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `AnchorQuote()` at `components/sections/AnchorQuote.tsx`. Consumed by Task 11.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { AnchorQuote } from './AnchorQuote';
|
||
|
||
describe('AnchorQuote', () => {
|
||
it('renders the approved anchor quote verbatim', () => {
|
||
render(<AnchorQuote />);
|
||
expect(
|
||
screen.getByText(/no dejan de crecer por falta de claridad/)
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it('does not introduce a second h1 on the page', () => {
|
||
render(<AnchorQuote />);
|
||
expect(screen.queryAllByRole('heading', { level: 1 })).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/AnchorQuote.test.tsx`
|
||
Expected: FAIL with "Cannot find module './AnchorQuote'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/AnchorQuote.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
export function AnchorQuote() {
|
||
return (
|
||
<MotionSection className="bg-bg-base px-6 py-16 md:py-24">
|
||
<blockquote className="mx-auto max-w-container text-center font-serif text-2xl italic leading-snug text-text-primary md:text-[32px]">
|
||
“Las empresas no dejan de crecer por falta de talento; dejan de
|
||
crecer por falta de claridad.”
|
||
</blockquote>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/AnchorQuote.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/AnchorQuote.tsx components/sections/AnchorQuote.test.tsx
|
||
git commit -m "feat: add AnchorQuote section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: ForWhom section (03)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/ForWhom.tsx`
|
||
- Create: `components/sections/ForWhom.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `ForWhom()` at `components/sections/ForWhom.tsx`. Consumed by Task 11.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { ForWhom } from './ForWhom';
|
||
|
||
describe('ForWhom', () => {
|
||
it('renders exactly the three approved qualifying chips in order', () => {
|
||
render(<ForWhom />);
|
||
const items = screen.getAllByRole('listitem');
|
||
expect(items.map((item) => item.textContent)).toEqual([
|
||
'✓ tienes tracción',
|
||
'✓ más esfuerzo no rinde',
|
||
'✓ necesitas claridad',
|
||
]);
|
||
});
|
||
|
||
it('uses an h2, never a second h1', () => {
|
||
render(<ForWhom />);
|
||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||
expect(screen.queryAllByRole('heading', { level: 1 })).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/ForWhom.test.tsx`
|
||
Expected: FAIL with "Cannot find module './ForWhom'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/ForWhom.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
const CHIPS = ['tienes tracción', 'más esfuerzo no rinde', 'necesitas claridad'];
|
||
|
||
export function ForWhom() {
|
||
return (
|
||
<MotionSection className="bg-bg-base px-6 py-16 md:py-24">
|
||
<div className="mx-auto max-w-container text-center">
|
||
<h2 className="font-sans text-2xl font-bold text-text-primary md:text-4xl">
|
||
¿Llegaste a tu punto de inflexión?
|
||
</h2>
|
||
<p className="mt-4 text-base text-text-muted md:text-lg">
|
||
Esto es para ti si...
|
||
</p>
|
||
<ul className="mt-8 flex flex-col items-center gap-3 md:flex-row md:justify-center md:gap-4">
|
||
{CHIPS.map((chip) => (
|
||
<li
|
||
key={chip}
|
||
className="rounded border border-accent-gold px-4 py-2 text-sm font-semibold text-text-primary"
|
||
>
|
||
✓ {chip}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/ForWhom.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/ForWhom.tsx components/sections/ForWhom.test.tsx
|
||
git commit -m "feat: add ForWhom qualifying section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: HowIWork section (04, dark)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/HowIWork.tsx`
|
||
- Create: `components/sections/HowIWork.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `HowIWork()` at `components/sections/HowIWork.tsx`. Consumed by Task 11.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { HowIWork } from './HowIWork';
|
||
|
||
describe('HowIWork', () => {
|
||
it('renders the three mechanism steps in order, numbered, with no icons', () => {
|
||
render(<HowIWork />);
|
||
const items = screen.getAllByRole('listitem');
|
||
expect(items.map((item) => item.textContent)).toEqual([
|
||
'01Inmersión',
|
||
'02Desafiar supuestos',
|
||
'03Estrategia ejecutable',
|
||
]);
|
||
expect(screen.queryAllByRole('img')).toHaveLength(0);
|
||
});
|
||
|
||
it('uses the dark section background', () => {
|
||
const { container } = render(<HowIWork />);
|
||
expect(container.querySelector('section')?.className).toContain(
|
||
'bg-bg-dark'
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/HowIWork.test.tsx`
|
||
Expected: FAIL with "Cannot find module './HowIWork'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/HowIWork.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
const STEPS = [
|
||
{ number: '01', title: 'Inmersión' },
|
||
{ number: '02', title: 'Desafiar supuestos' },
|
||
{ number: '03', title: 'Estrategia ejecutable' },
|
||
];
|
||
|
||
export function HowIWork() {
|
||
return (
|
||
<MotionSection className="bg-bg-dark px-6 py-16 md:py-24">
|
||
<div className="mx-auto max-w-container">
|
||
<h2 className="text-center font-sans text-2xl font-bold text-text-on-dark md:text-4xl">
|
||
Cómo trabajo
|
||
</h2>
|
||
<ol className="mt-12 grid gap-10 md:grid-cols-3">
|
||
{STEPS.map((step) => (
|
||
<li key={step.number} className="text-center">
|
||
<span className="font-serif text-5xl font-bold italic text-accent-gold-bright md:text-6xl">
|
||
{step.number}
|
||
</span>
|
||
<p className="mt-4 text-lg font-semibold text-text-on-dark">
|
||
{step.title}
|
||
</p>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/HowIWork.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/HowIWork.tsx components/sections/HowIWork.test.tsx
|
||
git commit -m "feat: add HowIWork dark mechanism section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Disciplines section (05)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Disciplines.tsx`
|
||
- Create: `components/sections/Disciplines.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `Disciplines()` at `components/sections/Disciplines.tsx`. Consumed by Task 11.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { Disciplines } from './Disciplines';
|
||
|
||
describe('Disciplines', () => {
|
||
it('renders the three disciplines as tools, not as the core promise', () => {
|
||
render(<Disciplines />);
|
||
expect(screen.getByText('Comunicación')).toBeInTheDocument();
|
||
expect(screen.getByText('Digital')).toBeInTheDocument();
|
||
expect(screen.getByText('Experiencias')).toBeInTheDocument();
|
||
});
|
||
|
||
it('gives each card a gold top border', () => {
|
||
const { container } = render(<Disciplines />);
|
||
expect(container.querySelectorAll('.border-t-4')).toHaveLength(3);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/Disciplines.test.tsx`
|
||
Expected: FAIL with "Cannot find module './Disciplines'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/Disciplines.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
const DISCIPLINES = ['Comunicación', 'Digital', 'Experiencias'];
|
||
|
||
export function Disciplines() {
|
||
return (
|
||
<MotionSection className="bg-bg-base px-6 py-16 md:py-24">
|
||
<div className="mx-auto max-w-container">
|
||
<h2 className="text-center font-sans text-2xl font-bold text-text-primary md:text-4xl">
|
||
Las herramientas
|
||
</h2>
|
||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||
{DISCIPLINES.map((discipline) => (
|
||
<div
|
||
key={discipline}
|
||
className="border-t-4 border-accent-gold px-6 py-8 text-center"
|
||
>
|
||
<p className="font-sans text-lg font-semibold text-text-primary">
|
||
{discipline}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/Disciplines.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/Disciplines.tsx components/sections/Disciplines.test.tsx
|
||
git commit -m "feat: add Disciplines section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: About section (06)
|
||
|
||
**Files:**
|
||
- Create: `components/sections/About.tsx`
|
||
- Create: `components/sections/About.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `About()` at `components/sections/About.tsx`. Consumed by Task 11.
|
||
|
||
**Note:** no professional photo exists yet (spec §5, §10 — pending). Renders a navy/gold gradient placeholder labeled for screen readers; swap the `div` for a Next `<Image src=... alt=... />` the moment a photo is provided (`images.unoptimized: true` from Task 1 already makes this drop-in safe under static export).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { About } from './About';
|
||
|
||
describe('About', () => {
|
||
it('labels the portrait placeholder for screen readers until a real photo is provided', () => {
|
||
render(<About />);
|
||
expect(
|
||
screen.getByRole('img', { name: /Retrato de la estratega/ })
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it('renders the first-person bio and the institutional quote', () => {
|
||
render(<About />);
|
||
expect(screen.getByText(/Hoy ayudo a empresarios/)).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText(/El tiempo no se compra, se invierte/)
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/About.test.tsx`
|
||
Expected: FAIL with "Cannot find module './About'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/About.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
export function About() {
|
||
return (
|
||
<MotionSection className="bg-bg-base px-6 py-16 md:py-24">
|
||
<div className="mx-auto flex max-w-container flex-col items-center gap-8 md:flex-row md:gap-12">
|
||
<div
|
||
role="img"
|
||
aria-label="Retrato de la estratega de creeer (foto pendiente)"
|
||
className="h-48 w-48 shrink-0 rounded bg-gradient-to-br from-bg-dark to-accent-gold"
|
||
/>
|
||
<div className="text-center md:text-left">
|
||
<h2 className="font-sans text-2xl font-bold text-text-primary md:text-4xl">
|
||
La mente detrás de creeer
|
||
</h2>
|
||
<p className="mt-4 text-base text-text-muted md:text-lg">
|
||
+30 años construyendo marcas. Hoy ayudo a empresarios con
|
||
tracción a encontrar la claridad estratégica que los lleva a su
|
||
siguiente movimiento.
|
||
</p>
|
||
<p className="mt-4 font-serif text-lg italic text-text-primary">
|
||
“El tiempo no se compra, se invierte.”
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/About.test.tsx`
|
||
Expected: PASS, 2 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/About.tsx components/sections/About.test.tsx
|
||
git commit -m "feat: add About section with portrait placeholder"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Closing section (07, dark) + contact links
|
||
|
||
**Files:**
|
||
- Create: `components/sections/Closing.tsx`
|
||
- Create: `components/sections/Closing.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MotionSection` (Task 3).
|
||
- Produces: `Closing()` at `components/sections/Closing.tsx`, rendering the `id="contacto"` anchor target that Hero's CTA (Task 4) scrolls to. Consumed by Task 11.
|
||
|
||
**Note:** WhatsApp number, email, and LinkedIn URL are placeholders pending the dueña's real contact details (spec §10). Replace the three values in `CONTACT_LINKS` the moment she provides them — no other code changes needed.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { Closing } from './Closing';
|
||
|
||
describe('Closing', () => {
|
||
it('exposes the three contact channels with accessible labels', () => {
|
||
render(<Closing />);
|
||
expect(
|
||
screen.getByRole('link', { name: 'Escribir por WhatsApp' })
|
||
).toHaveAttribute('href', 'https://wa.me/521234567890');
|
||
expect(
|
||
screen.getByRole('link', { name: 'Escribir por correo electrónico' })
|
||
).toHaveAttribute('href', 'mailto:hola@creeer.com');
|
||
expect(
|
||
screen.getByRole('link', { name: 'Ver perfil de LinkedIn' })
|
||
).toHaveAttribute('href', 'https://www.linkedin.com/in/creeer');
|
||
});
|
||
|
||
it('points the closing CTA at the primary contact channel', () => {
|
||
render(<Closing />);
|
||
const cta = screen.getByRole('link', {
|
||
name: 'Define tu siguiente movimiento →',
|
||
});
|
||
expect(cta).toHaveAttribute('href', 'https://wa.me/521234567890');
|
||
});
|
||
|
||
it('provides the #contacto anchor target for the hero CTA to scroll to', () => {
|
||
const { container } = render(<Closing />);
|
||
expect(container.querySelector('#contacto')).not.toBeNull();
|
||
});
|
||
|
||
it('uses the dark section background', () => {
|
||
const { container } = render(<Closing />);
|
||
expect(container.querySelector('section')?.className).toContain(
|
||
'bg-bg-dark'
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- components/sections/Closing.test.tsx`
|
||
Expected: FAIL with "Cannot find module './Closing'".
|
||
|
||
- [ ] **Step 3: Write `components/sections/Closing.tsx`**
|
||
|
||
```tsx
|
||
import { MotionSection } from '@/components/MotionSection';
|
||
|
||
const CONTACT_LINKS = [
|
||
{
|
||
label: 'WhatsApp',
|
||
href: 'https://wa.me/521234567890',
|
||
ariaLabel: 'Escribir por WhatsApp',
|
||
},
|
||
{
|
||
label: 'Email',
|
||
href: 'mailto:hola@creeer.com',
|
||
ariaLabel: 'Escribir por correo electrónico',
|
||
},
|
||
{
|
||
label: 'LinkedIn',
|
||
href: 'https://www.linkedin.com/in/creeer',
|
||
ariaLabel: 'Ver perfil de LinkedIn',
|
||
},
|
||
];
|
||
|
||
export function Closing() {
|
||
return (
|
||
<MotionSection className="bg-bg-dark px-6 py-16 md:py-24">
|
||
<div id="contacto" className="mx-auto max-w-container text-center">
|
||
<p className="font-serif text-2xl italic text-text-on-dark md:text-[32px]">
|
||
“Devuélvele claridad al rumbo de tu empresa.”
|
||
</p>
|
||
<a
|
||
href={CONTACT_LINKS[0].href}
|
||
className="mt-8 inline-block bg-accent-gold-bright px-8 py-4 text-base font-semibold text-bg-dark"
|
||
>
|
||
Define tu siguiente movimiento →
|
||
</a>
|
||
<ul className="mt-10 flex flex-col items-center gap-4 md:flex-row md:justify-center md:gap-8">
|
||
{CONTACT_LINKS.map((link) => (
|
||
<li key={link.label}>
|
||
<a
|
||
href={link.href}
|
||
aria-label={link.ariaLabel}
|
||
className="text-sm font-semibold uppercase tracking-wide text-text-on-dark underline decoration-accent-gold-bright underline-offset-4"
|
||
>
|
||
{link.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</MotionSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- components/sections/Closing.test.tsx`
|
||
Expected: PASS, 4 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add components/sections/Closing.tsx components/sections/Closing.test.tsx
|
||
git commit -m "feat: add Closing section with contact links"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Assemble the page, structured data, and verify the build
|
||
|
||
**Files:**
|
||
- Modify: `app/page.tsx` (replace the Task 1 placeholder)
|
||
- Create: `app/page.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Hero` (Task 4), `AnchorQuote` (Task 5), `ForWhom` (Task 6), `HowIWork` (Task 7), `Disciplines` (Task 8), `About` (Task 9), `Closing` (Task 10).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```tsx
|
||
import { describe, expect, it } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import Home from './page';
|
||
|
||
describe('Home page', () => {
|
||
it('renders exactly one h1 across the whole page', () => {
|
||
render(<Home />);
|
||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||
});
|
||
|
||
it('renders the seven sections in spec order', () => {
|
||
const { container } = render(<Home />);
|
||
expect(container.querySelectorAll('section')).toHaveLength(7);
|
||
});
|
||
|
||
it('repeats the primary CTA copy at the top and the bottom of the page', () => {
|
||
render(<Home />);
|
||
expect(
|
||
screen.getAllByText('Define tu siguiente movimiento →')
|
||
).toHaveLength(2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- app/page.test.tsx`
|
||
Expected: FAIL — the placeholder page only renders one `<h1>` and zero `<section>` elements, so the section-count and CTA-repetition assertions fail.
|
||
|
||
- [ ] **Step 3: Replace `app/page.tsx`**
|
||
|
||
```tsx
|
||
import { Hero } from '@/components/sections/Hero';
|
||
import { AnchorQuote } from '@/components/sections/AnchorQuote';
|
||
import { ForWhom } from '@/components/sections/ForWhom';
|
||
import { HowIWork } from '@/components/sections/HowIWork';
|
||
import { Disciplines } from '@/components/sections/Disciplines';
|
||
import { About } from '@/components/sections/About';
|
||
import { Closing } from '@/components/sections/Closing';
|
||
|
||
const STRUCTURED_DATA = {
|
||
'@context': 'https://schema.org',
|
||
'@type': 'ProfessionalService',
|
||
name: 'creeer',
|
||
description:
|
||
'Claridad estratégica para empresarios con tracción que llegaron a un punto de inflexión.',
|
||
url: 'https://creeer.com',
|
||
};
|
||
|
||
export default function Home() {
|
||
return (
|
||
<main>
|
||
<Hero variant="editorial" />
|
||
<AnchorQuote />
|
||
<ForWhom />
|
||
<HowIWork />
|
||
<Disciplines />
|
||
<About />
|
||
<Closing />
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(STRUCTURED_DATA) }}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS, all test files green (Task 1 through Task 11).
|
||
|
||
- [ ] **Step 5: Verify lint passes**
|
||
|
||
Run: `npm run lint`
|
||
Expected: "No ESLint warnings or errors".
|
||
|
||
- [ ] **Step 6: Verify the static export build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds; `out/index.html` exists and contains the wordmark text `creeer`, the seven `<section` tags, and the JSON-LD `<script type="application/ld+json">` block.
|
||
|
||
- [ ] **Step 7: Manual visual check**
|
||
|
||
Run: `npm run dev`, open `http://localhost:3000`, and confirm against the spec:
|
||
- Scroll through all 7 sections in order; sections 04 and 07 are navy with gold accents, the rest are light.
|
||
- The hero entrance animates in (wordmark → eyebrow → headline → CTA).
|
||
- Toggle `variant="hormozi"` in `app/page.tsx` locally to eyeball the other hero option for the dueña's later review, then revert to `"editorial"` before committing.
|
||
- In OS/browser settings enable "reduce motion" and reload — all animations should disappear with content still visible.
|
||
Stop the dev server (Ctrl+C) when done.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/page.tsx app/page.test.tsx
|
||
git commit -m "feat: assemble the seven-section landing page with structured data"
|
||
```
|
||
|
||
---
|
||
|
||
## After this plan
|
||
|
||
- Hand `hero-finalists.md` back to the dueña for the HV-1 vs V-a decision — flipping it is a one-line `variant` change in `app/page.tsx`.
|
||
- Once she provides a photo, replace the placeholder `div` in `About.tsx` with `next/image`.
|
||
- Once she provides real contact info, update the three entries in `Closing.tsx`'s `CONTACT_LINKS`.
|
||
- Consider adding an Open Graph image once final copy/photo are set (spec §8.1).
|