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 ( +
+ {/* Header */} +
+
+ +
+ +
+ Íris + +
+
+ + {/* Content */} +
+
+
+ +
+ +

+ Pagamento cancelado +

+ +

+ Você cancelou o processo de pagamento. Se foi por engano ou + se precisar de ajuda, estamos aqui para você. +

+ +
+ + Tentar novamente + + + + + Voltar para o início + +
+ +
+

+ + Dúvidas? Fale conosco pelo WhatsApp +

+
+
+
+
+ ); +} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 0000000..492dbc7 --- /dev/null +++ b/src/app/checkout/page.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+ +
+ +
+ Íris + + + + Voltar + +
+
+ + {/* Content */} +
+
+

+ Escolha seu plano +

+

+ Todos os planos incluem acesso à nossa plataforma completa, + suporte via chat e garantia de 7 dias. +

+
+ +
+ {plans.map((plan) => ( +
+ {plan.popular && ( +
+ Mais Popular +
+ )} + +
+

{plan.name}

+

{plan.description}

+ +
+ + R$ {plan.price} + + /mês +
+ +

{plan.sessions}

+ + + +
    + {plan.features.map((feature, i) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ ))} +
+ + {/* Garantia */} +
+
+ + Garantia de 7 dias ou seu dinheiro de volta +
+
+
+
+ ); +} diff --git a/src/app/checkout/sucesso/page.tsx b/src/app/checkout/sucesso/page.tsx new file mode 100644 index 0000000..4e01743 --- /dev/null +++ b/src/app/checkout/sucesso/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ +
+ +
+ Íris + +
+
+ + {/* Content */} +
+
+
+ +
+ +

+ Bem-vindo à Íris! 🎉 +

+ +

+ Sua assinatura foi confirmada com sucesso. + Você já pode começar a usar todos os recursos da plataforma. +

+ +
+

+ Próximos passos: +

+
+
+
+ +
+
+

Agende sua primeira sessão

+

+ Nossa equipe entrará em contato em até 24h para agendar sua primeira sessão com um BCBA. +

+
+
+ +
+
+ +
+
+

Explore o chat com IA

+

+ Use nossa assistente para tirar dúvidas e receber orientações 24 horas por dia. +

+
+
+ +
+
+ +
+
+

Complete o perfil

+

+ Quanto mais informações tivermos, melhor poderemos personalizar o atendimento. +

+
+
+
+
+ + + Acessar meu Dashboard + + +
+
+
+ ); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..72d3c5c --- /dev/null +++ b/src/lib/stripe.ts @@ -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;