{
  "id": "add-a-pricing-page",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "pt",
  "url": "/pt/playbooks/add-a-pricing-page",
  "title": "Prompt-to-PR: Adicionar Página de Preços",
  "description": "SOP para gerar uma página de preços de produção com alternância mensal/anual, matriz de recursos e links de Stripe Checkout em um projeto 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": "Uma das páginas mais geradas em sessões de codificação com IA. Este guia restringe o agente a produzir links reais e funcionais do Stripe Checkout, em vez de botões de espaço reservado.\n\n## 1. Requisito\n\nAdicione uma página `/pricing` com dois ou três planos, uma alternância de cobrança mensal/anual (anual dá 20% de desconto), uma matriz de comparação de recursos e integração com Stripe Checkout. Os preços devem vir de variáveis de ambiente, não sendo codificados em JSX.\n\n## 2. Primeiro 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. Mudanças Esperadas nos Arquivos\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 Verificação\n\n- `STRIPE_SECRET_KEY` nunca é importado em um Componente Cliente ou exposto via `NEXT_PUBLIC_`.\n- A Sessão de Checkout usa `mode: \"subscription\"` e não `mode: \"payment\"` para cobrança recorrente.\n- Os IDs de preço anual são objetos Stripe Price separados — não um desconto calculado no código.\n- A alternância mostra claramente a economia anual (ex.: \"Economize 20%\").\n- O array `PLANS` é a única fonte da verdade — a matriz de recursos reutiliza os mesmos dados, não uma tabela separada codificada.\n- Tratamento de erro no POST do checkout: retorna um 500 com uma mensagem se o Stripe lançar uma exceção.\n- Nenhum `console.log` de chaves Stripe ou dados do cliente deixados no código.\n\n## 5. Comandos de Teste\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. Falhas Comuns\n\n- **`STRIPE_SECRET_KEY` vazado no lado do cliente** — o agente importa `stripe` em `PricingCards.tsx`. Mova todas as chamadas Stripe para a rota da API.\n- **Alternância anual mostra o mesmo preço que o mensal** — o agente calcula `price * 0.8` mas esquece de usar o estado `isAnnual`. Confirme que o estado da alternância é passado para a exibição do preço.\n- **Checkout retorna 500 com \"No such price\"** — variável de ambiente do ID do preço não definida ou ambiente errado (teste vs produção). Verifique com `stripe prices retrieve <id>` no Stripe CLI.\n- **`success_url` é relativa** — Stripe requer uma URL absoluta. Prefixe com `NEXT_PUBLIC_APP_URL`.\n\n## 7. Prompt de Correção\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. Descrição do 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```"
}