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:
53
package-lock.json
generated
53
package-lock.json
generated
@@ -10,18 +10,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.33.0",
|
"framer-motion": "^12.33.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -1376,6 +1380,15 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1677,6 +1690,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1702,7 +1722,7 @@
|
|||||||
"version": "20.19.32",
|
"version": "20.19.32",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.32.tgz",
|
||||||
"integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
|
"integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2748,6 +2768,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -6585,6 +6615,23 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -6922,7 +6969,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -11,18 +11,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.33.0",
|
"framer-motion": "^12.33.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
103
src/app/api/checkout/route.ts
Normal file
103
src/app/api/checkout/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/api/webhooks/stripe/route.ts
Normal file
124
src/app/api/webhooks/stripe/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/checkout/cancelado/page.tsx
Normal file
64
src/app/checkout/cancelado/page.tsx
Normal 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
193
src/app/checkout/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/app/checkout/sucesso/page.tsx
Normal file
121
src/app/checkout/sucesso/page.tsx
Normal 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ê já 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
54
src/lib/stripe.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user