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

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>
);
}