feat: Fase 2 - Integração Stripe

- Biblioteca Stripe configurada
- API de checkout para criar sessões de pagamento
- Webhook para processar eventos (subscription created/updated/deleted)
- Página de planos com os 3 níveis (Essencial R97, Completo R97, Intensivo R97)
- Páginas de sucesso e cancelamento
- Efeito de confete na confirmação
This commit is contained in:
2026-02-07 00:23:19 -03:00
parent e12cfbe91c
commit b8d47b8a97
8 changed files with 714 additions and 4 deletions

53
package-lock.json generated
View File

@@ -10,18 +10,22 @@
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/client": "^6.19.2",
"@stripe/stripe-js": "^8.7.0",
"bcrypt": "^6.0.0",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"stripe": "^20.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1376,6 +1380,15 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stripe/stripe-js": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz",
"integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1677,6 +1690,13 @@
"@types/node": "*"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1702,7 +1722,7 @@
"version": "20.19.32",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
"integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2748,6 +2768,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -6585,6 +6615,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.3.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6922,7 +6969,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@@ -11,18 +11,22 @@
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/client": "^6.19.2",
"@stripe/stripe-js": "^8.7.0",
"bcrypt": "^6.0.0",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"stripe": "^20.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { stripe, PLANS, PlanId } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
const body = await request.json();
const { planId } = body as { planId: PlanId };
if (!planId || !PLANS[planId]) {
return NextResponse.json(
{ error: 'Plano inválido' },
{ status: 400 }
);
}
const plan = PLANS[planId];
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { subscription: true },
});
if (!user) {
return NextResponse.json(
{ error: 'Usuário não encontrado' },
{ status: 404 }
);
}
// Criar ou recuperar customer no Stripe
let customerId = user.subscription?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Atualizar subscription com customerId
await prisma.subscription.upsert({
where: { userId: user.id },
update: { stripeCustomerId: customerId },
create: {
userId: user.id,
plan: planId,
status: 'pending',
stripeCustomerId: customerId,
},
});
}
// Criar sessão de checkout
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'brl',
product_data: {
name: `Íris TEA - Plano ${plan.name}`,
description: plan.description,
},
unit_amount: plan.price,
recurring: {
interval: 'month',
},
},
quantity: 1,
},
],
mode: 'subscription',
success_url: `${process.env.AUTH_URL}/checkout/sucesso?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.AUTH_URL}/checkout/cancelado`,
metadata: {
userId: user.id,
planId,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Erro no checkout:', error);
return NextResponse.json(
{ error: 'Erro ao criar sessão de checkout' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json(
{ error: 'Webhook signature verification failed' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const planId = session.metadata?.planId;
if (userId && planId) {
await prisma.subscription.update({
where: { userId },
data: {
status: 'active',
plan: planId,
stripeSubId: session.subscription as string,
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 dias
},
});
}
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const sub = await prisma.subscription.findFirst({
where: { stripeCustomerId: customerId },
});
if (sub) {
// @ts-expect-error - Stripe types may vary
const periodEnd = subscription.current_period_end || subscription.currentPeriodEnd;
await prisma.subscription.update({
where: { id: sub.id },
data: {
status: subscription.status === 'active' ? 'active' : 'cancelled',
currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : undefined,
},
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await prisma.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: {
status: 'cancelled',
},
});
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
const sub = await prisma.subscription.findFirst({
where: { stripeCustomerId: customerId },
});
if (sub) {
await prisma.subscription.update({
where: { id: sub.id },
data: {
status: 'active',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
}
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
await prisma.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: {
status: 'expired',
},
});
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook handler error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,64 @@
'use client';
import Link from 'next/link';
import { Sparkles, XCircle, ArrowLeft, MessageCircle } from 'lucide-react';
export default function CheckoutCanceladoPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white flex flex-col">
{/* Header */}
<header className="p-4">
<div className="max-w-4xl mx-auto">
<Link href="/" className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl gradient-rainbow flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<span className="text-2xl font-bold text-gradient">Íris</span>
</Link>
</div>
</header>
{/* Content */}
<div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="max-w-md text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<XCircle className="w-12 h-12 text-gray-400" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Pagamento cancelado
</h1>
<p className="text-gray-600 mb-8">
Você cancelou o processo de pagamento. Se foi por engano ou
se precisar de ajuda, estamos aqui para você.
</p>
<div className="space-y-4">
<Link
href="/checkout"
className="block w-full bg-indigo-600 text-white py-3 rounded-xl font-medium hover:bg-indigo-700 transition"
>
Tentar novamente
</Link>
<Link
href="/"
className="flex items-center justify-center gap-2 w-full text-gray-600 py-3 hover:text-gray-900 transition"
>
<ArrowLeft className="w-4 h-4" />
Voltar para o início
</Link>
</div>
<div className="mt-8 p-4 bg-indigo-50 rounded-xl">
<p className="text-sm text-indigo-700 flex items-center justify-center gap-2">
<MessageCircle className="w-4 h-4" />
Dúvidas? Fale conosco pelo WhatsApp
</p>
</div>
</div>
</div>
</div>
);
}

193
src/app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,193 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Sparkles, Check, ArrowLeft, Loader2 } from 'lucide-react';
const plans = [
{
id: 'essencial',
name: 'Essencial',
price: 297,
sessions: '2 sessões/mês',
description: 'Para famílias iniciando a jornada',
features: [
'2 sessões de 50min com BCBA',
'Assistente IA 24/7',
'Relatórios básicos mensais',
'Grupo de apoio online',
],
popular: false,
},
{
id: 'completo',
name: 'Completo',
price: 497,
sessions: '4 sessões/mês',
description: 'O mais escolhido pelas famílias',
features: [
'4 sessões de 50min com BCBA',
'Assistente IA 24/7 avançado',
'Relatórios detalhados semanais',
'Supervisão parental mensal',
'Grupo de apoio online',
'Materiais exclusivos',
],
popular: true,
},
{
id: 'intensivo',
name: 'Intensivo',
price: 897,
sessions: '8 sessões/mês',
description: 'Para resultados acelerados',
features: [
'8 sessões de 50min com BCBA',
'Assistente IA 24/7 premium',
'Relatórios detalhados semanais',
'Supervisão parental quinzenal',
'Suporte prioritário WhatsApp',
'Consultas emergenciais',
'Materiais exclusivos',
'Desconto em cursos',
],
popular: false,
},
];
export default function CheckoutPage() {
const router = useRouter();
const { data: session, status } = useSession();
const [loading, setLoading] = useState<string | null>(null);
const handleCheckout = async (planId: string) => {
if (status === 'unauthenticated') {
router.push('/login');
return;
}
setLoading(planId);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ planId }),
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error(data.error || 'Erro ao processar');
}
} catch (error) {
console.error('Erro no checkout:', error);
alert('Erro ao processar pagamento. Tente novamente.');
} finally {
setLoading(null);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-indigo-50 to-white">
{/* Header */}
<header className="bg-white border-b border-gray-100">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl gradient-rainbow flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<span className="text-2xl font-bold text-gradient">Íris</span>
</Link>
<Link href="/" className="flex items-center gap-2 text-gray-600 hover:text-gray-900">
<ArrowLeft className="w-4 h-4" />
Voltar
</Link>
</div>
</header>
{/* Content */}
<div className="max-w-6xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Escolha seu plano
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Todos os planos incluem acesso à nossa plataforma completa,
suporte via chat e garantia de 7 dias.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{plans.map((plan) => (
<div
key={plan.id}
className={`bg-white rounded-2xl shadow-lg overflow-hidden ${
plan.popular ? 'ring-2 ring-indigo-600 relative' : ''
}`}
>
{plan.popular && (
<div className="bg-indigo-600 text-white text-center py-2 text-sm font-medium">
Mais Popular
</div>
)}
<div className="p-8">
<h3 className="text-2xl font-bold text-gray-900">{plan.name}</h3>
<p className="text-gray-500 mt-1">{plan.description}</p>
<div className="mt-6">
<span className="text-5xl font-bold text-gray-900">
R$ {plan.price}
</span>
<span className="text-gray-500">/mês</span>
</div>
<p className="text-indigo-600 font-medium mt-2">{plan.sessions}</p>
<button
onClick={() => handleCheckout(plan.id)}
disabled={loading !== null}
className={`w-full mt-8 py-3 rounded-xl font-medium transition flex items-center justify-center gap-2 ${
plan.popular
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
} disabled:opacity-50`}
>
{loading === plan.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
'Começar agora'
)}
</button>
<ul className="mt-8 space-y-3">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-gray-600">{feature}</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
{/* Garantia */}
<div className="mt-12 text-center">
<div className="inline-flex items-center gap-2 bg-green-50 text-green-700 px-6 py-3 rounded-full">
<Check className="w-5 h-5" />
<span className="font-medium">Garantia de 7 dias ou seu dinheiro de volta</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import Link from 'next/link';
import { useEffect } from 'react';
import { Sparkles, CheckCircle, ArrowRight, Calendar, MessageCircle, FileText } from 'lucide-react';
import confetti from 'canvas-confetti';
export default function CheckoutSucessoPage() {
useEffect(() => {
// Efeito de confete ao carregar
const duration = 3 * 1000;
const end = Date.now() + duration;
const frame = () => {
confetti({
particleCount: 2,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: ['#6366f1', '#8b5cf6', '#a855f7'],
});
confetti({
particleCount: 2,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: ['#6366f1', '#8b5cf6', '#a855f7'],
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
};
frame();
}, []);
return (
<div className="min-h-screen bg-gradient-to-b from-green-50 to-white flex flex-col">
{/* Header */}
<header className="p-4">
<div className="max-w-4xl mx-auto">
<Link href="/" className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl gradient-rainbow flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<span className="text-2xl font-bold text-gradient">Íris</span>
</Link>
</div>
</header>
{/* Content */}
<div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="max-w-2xl text-center">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Bem-vindo à Íris! 🎉
</h1>
<p className="text-xl text-gray-600 mb-8">
Sua assinatura foi confirmada com sucesso.
Você pode começar a usar todos os recursos da plataforma.
</p>
<div className="bg-white rounded-2xl shadow-lg p-8 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Próximos passos:
</h2>
<div className="space-y-4 text-left">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h3 className="font-medium text-gray-900">Agende sua primeira sessão</h3>
<p className="text-gray-500 text-sm">
Nossa equipe entrará em contato em até 24h para agendar sua primeira sessão com um BCBA.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h3 className="font-medium text-gray-900">Explore o chat com IA</h3>
<p className="text-gray-500 text-sm">
Use nossa assistente para tirar dúvidas e receber orientações 24 horas por dia.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h3 className="font-medium text-gray-900">Complete o perfil</h3>
<p className="text-gray-500 text-sm">
Quanto mais informações tivermos, melhor poderemos personalizar o atendimento.
</p>
</div>
</div>
</div>
</div>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 bg-indigo-600 text-white px-8 py-4 rounded-full text-lg font-medium hover:bg-indigo-700 transition"
>
Acessar meu Dashboard
<ArrowRight className="w-5 h-5" />
</Link>
</div>
</div>
</div>
);
}

54
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,54 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// @ts-expect-error - Use latest API version
apiVersion: '2024-12-18.acacia',
typescript: true,
});
export const PLANS = {
essencial: {
name: 'Essencial',
price: 29700, // em centavos (R$ 297)
sessions: 2,
description: '2 sessões de teleterapia/mês + Chat IA 24/7',
features: [
'2 sessões de 50min com BCBA',
'Assistente IA 24/7',
'Relatórios básicos mensais',
'Grupo de apoio online',
],
},
completo: {
name: 'Completo',
price: 49700, // R$ 497
sessions: 4,
description: '4 sessões de teleterapia/mês + Supervisão + Chat IA 24/7',
features: [
'4 sessões de 50min com BCBA',
'Assistente IA 24/7 avançado',
'Relatórios detalhados semanais',
'Supervisão parental mensal',
'Grupo de apoio online',
'Materiais exclusivos',
],
},
intensivo: {
name: 'Intensivo',
price: 89700, // R$ 897
sessions: 8,
description: '8 sessões de teleterapia/mês + Supervisão + Suporte prioritário',
features: [
'8 sessões de 50min com BCBA',
'Assistente IA 24/7 premium',
'Relatórios detalhados semanais',
'Supervisão parental quinzenal',
'Suporte prioritário WhatsApp',
'Consultas emergenciais',
'Materiais exclusivos',
'Desconto em cursos',
],
},
} as const;
export type PlanId = keyof typeof PLANS;