{
  "id": "add-a-pricing-page",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "es",
  "url": "/es/playbooks/add-a-pricing-page",
  "title": "Prompt-to-PR: Añadir una Página de Precios",
  "description": "SOP para generar una página de precios de producción con alternancia mensual/anual, matriz de funciones y enlaces a Stripe Checkout en un proyecto Next.js Tailwind.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "tailwind",
    "typescript"
  ],
  "difficulty": "easy",
  "updated": "2026-06-08",
  "markdown": "Una de las páginas más generadas en sesiones de coding con IA. Este manual restringe al agente para que produzca enlaces reales a Stripe Checkout en lugar de botones de marcador de posición.\n\n## 1. Requisito\n\nAñadir una página `/pricing` con dos o tres niveles, un conmutador de facturación mensual/anual (anual ofrece un 20% de descuento), una matriz de comparación de funciones e integración con Stripe Checkout. Los precios deben provenir de variables de entorno, no estar codificados en JSX.\n\n## 2. Primer Prompt\n\n```txt title=\"First Prompt\"\nAdd a /pricing page to this Next.js 15 App Router project with Tailwind.\n\nRequirements:\n1. Create `src/app/pricing/page.tsx` — a Server Component that renders\n   `<PricingCards>`.\n2. Create `src/components/PricingCards.tsx` — a Client Component with:\n   - Monthly/annual toggle (useState). Annual prices are monthly × 0.8.\n   - Three tiers: Hobby (free), Pro, Business. Read plan data from\n     `src/config/pricing.ts` — not hardcoded in JSX.\n   - A feature matrix table below the cards showing which features each tier\n     includes. Use checkmarks and dashes; do not use emojis in code.\n   - A \"Get started\" CTA for Hobby (links to /sign-up) and \"Subscribe\" for\n     paid tiers.\n3. Create `src/config/pricing.ts` exporting a `PLANS` array. Each plan has:\n   { id, name, monthlyPriceUsd, stripePriceIdMonthly, stripePriceIdAnnually,\n     features: string[], highlighted: boolean }\n   Read Stripe price IDs from env vars:\n     NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY\n     NEXT_PUBLIC_STRIPE_PRICE_PRO_ANNUALLY\n     NEXT_PUBLIC_STRIPE_PRICE_BIZ_MONTHLY\n     NEXT_PUBLIC_STRIPE_PRICE_BIZ_ANNUALLY\n4. Create `src/app/api/checkout/route.ts` (POST). Accept `{ priceId, userId }`.\n   Create a Stripe Checkout Session (mode: \"subscription\") with:\n   - success_url: NEXT_PUBLIC_APP_URL + /dashboard?checkout=success\n   - cancel_url: NEXT_PUBLIC_APP_URL + /pricing\n   Return `{ url }`.\n5. In PricingCards, the Subscribe button POSTs to /api/checkout and redirects\n   to the returned url.\n6. Install `stripe` package. Use env var STRIPE_SECRET_KEY.\n```\n\n## 3. Cambios de Archivos Esperados\n\n```txt\npackage.json                             (stripe)\nsrc/app/pricing/page.tsx                 (new — Server Component shell)\nsrc/components/PricingCards.tsx          (new — Client Component with toggle)\nsrc/config/pricing.ts                    (new — plan definitions)\nsrc/app/api/checkout/route.ts            (new — Stripe Checkout Session)\n.env.local.example                       (STRIPE_* and NEXT_PUBLIC_* vars)\n```\n\n## 4. Lista de Verificación de Revisión\n\n- `STRIPE_SECRET_KEY` nunca se importa en un Componente Cliente ni se expone mediante `NEXT_PUBLIC_`.\n- La Sesión de Checkout usa `mode: \"subscription\"` no `mode: \"payment\"` para facturación recurrente.\n- Los IDs de precio anual son objetos de Precio de Stripe separados, no un descuento calculado en código.\n- El conmutador muestra claramente el ahorro anual (por ejemplo, \"Ahorra 20%\").\n- El array `PLANS` es la única fuente de verdad: la matriz de funciones reutiliza los mismos datos, no una tabla codificada aparte.\n- Manejo de errores en el POST de checkout: devuelve un 500 con un mensaje si Stripe lanza una excepción.\n- No queda `console.log` de claves de Stripe o datos de clientes en el código.\n\n## 5. Comandos de Prueba\n\n```bash\nbun dev\n\n# Visit http://localhost:3000/pricing\n# Toggle monthly/annual — confirm prices update correctly\n# Confirm annual = monthly * 0.8\n\n# Test checkout endpoint with a test price ID\ncurl -X POST http://localhost:3000/api/checkout \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"priceId\":\"price_test_xxx\",\"userId\":\"user_1\"}' | jq .url\n# Expect a Stripe Checkout URL\n\nbun tsc --noEmit\n```\n\n## 6. Fallos Comunes\n\n- **`STRIPE_SECRET_KEY` filtrada en el lado del cliente** — el agente importa `stripe` en `PricingCards.tsx`. Mover todas las llamadas a Stripe a la ruta de API.\n- **El conmutador anual muestra el mismo precio que el mensual** — el agente calcula `price * 0.8` pero olvida usar el estado `isAnnual`. Confirmar que el estado del conmutador se pasa a la visualización del precio.\n- **Checkout devuelve 500 con \"No such price\"** — la variable de entorno del ID de precio no está configurada o está en el entorno incorrecto (prueba vs producción). Verificar con `stripe prices retrieve <id>` en la CLI de Stripe.\n- **`success_url` es relativa** — Stripe requiere una URL absoluta. Prefijar con `NEXT_PUBLIC_APP_URL`.\n\n## 7. Prompt de Corrección\n\n```txt title=\"Fix Prompt\"\nThe annual/monthly toggle renders correctly visually but the Subscribe button\nalways sends the monthly priceId regardless of which toggle state is active.\n\nIn PricingCards.tsx, the `priceId` passed to the checkout POST must be\n`isAnnual ? plan.stripePriceIdAnnually : plan.stripePriceIdMonthly`.\nCheck that the isAnnual state variable is read at the point the button's\nonClick handler calls the checkout API.\n```\n\n## 8. Descripción del PR\n\n```md title=\"PR description\"\n## Feature: Pricing page with Stripe Checkout\n\n- `/pricing` page with monthly/annual toggle and three tiers (Hobby, Pro, Business)\n- Feature matrix driven from `src/config/pricing.ts` — one source of truth\n- POST `/api/checkout` creates a Stripe Subscription Checkout Session\n- Price IDs read from env vars — no hardcoded Stripe IDs in code\n- Annual plan = 20% discount via separate Stripe Price objects\n\n**Required env vars**: `STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PRICE_*`,\n`NEXT_PUBLIC_APP_URL` (see `.env.local.example`)\n```"
}