diff --git a/package-lock.json b/package-lock.json index 19cee25..c344dd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index bce6bdb..17d3916 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..975e251 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..8c7d30b --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -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 } + ); + } +} diff --git a/src/app/checkout/cancelado/page.tsx b/src/app/checkout/cancelado/page.tsx new file mode 100644 index 0000000..4b3325a --- /dev/null +++ b/src/app/checkout/cancelado/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import Link from 'next/link'; +import { Sparkles, XCircle, ArrowLeft, MessageCircle } from 'lucide-react'; + +export default function CheckoutCanceladoPage() { + return ( +
+ Você cancelou o processo de pagamento. Se foi por engano ou + se precisar de ajuda, estamos aqui para você. +
+ +
+
+ Todos os planos incluem acesso à nossa plataforma completa, + suporte via chat e garantia de 7 dias. +
+{plan.description}
+ +{plan.sessions}
+ + + ++ Sua assinatura foi confirmada com sucesso. + Você já pode começar a usar todos os recursos da plataforma. +
+ ++ Nossa equipe entrará em contato em até 24h para agendar sua primeira sessão com um BCBA. +
++ Use nossa assistente para tirar dúvidas e receber orientações 24 horas por dia. +
++ Quanto mais informações tivermos, melhor poderemos personalizar o atendimento. +
+