diff --git a/app/(chat)/chat/[id]/actions.ts b/app/(chat)/chat/[id]/actions.ts new file mode 100644 index 000000000..8be7192e4 --- /dev/null +++ b/app/(chat)/chat/[id]/actions.ts @@ -0,0 +1,9 @@ +'use server' + +import { User } from '@/lib/types' +import { kv } from '@vercel/kv' + +export async function getUser(email: string) { + const user = await kv.hgetall(`user:${email}`) + return user +} \ No newline at end of file diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index c8de38044..9e3a98a7a 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -29,7 +29,7 @@ export async function generateMetadata({ } export default async function ChatPage({ params }: ChatPageProps) { - const session = (await auth()) as Session + const session = (await auth()) as unknown as Session const missingKeys = await getMissingKeys() if (!session?.user) { diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 21b79f91d..40d3578ce 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -6,12 +6,12 @@ import { Session } from '@/lib/types' import { getMissingKeys } from '@/app/actions' export const metadata = { - title: 'Next.js AI Chatbot' + title: 'LexGPT Chatbots' } export default async function IndexPage() { const id = nanoid() - const session = (await auth()) as Session + const session = (await auth()) as unknown as Session const missingKeys = await getMissingKeys() return ( diff --git a/app/actions.ts b/app/actions.ts index c624f561e..477761f2b 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -153,4 +153,4 @@ export async function getMissingKeys() { return keysRequired .map(key => (process.env[key] ? '' : key)) .filter(key => key !== '') -} +} \ No newline at end of file diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 000000000..34f0db100 --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,68 @@ +// /api/session route +import { auth } from '@/auth'; +import { kv } from '@vercel/kv'; +import { getUser } from '@/app/login/actions'; +import { stripe } from '@/lib/stripe' +import Stripe from 'stripe'; +import { User } from '@/lib/types'; + +export async function POST(req: Request) { + if (req.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }); + } + + // Authenticate the user + const session = await auth(); + + // Fetch user details from Vercel KV + const user = await kv.hgetall(`user:${session?.user?.email}`); + + if (!user) { + return new Response(JSON.stringify({ error: 'User not found' }), { status: 404 }); + } + + // Fetch the Stripe customer info using the stored Stripe ID + let stripeCustomer: Stripe.Customer | null = null; + if (user.stripeId) { + try { + const customer = await stripe.customers.retrieve(user.stripeId); + + // Type guard to handle both Customer and DeletedCustomer cases + if (!customer.deleted) { + stripeCustomer = customer; // `customer` is guaranteed to be a `Stripe.Customer` here + } else { + console.error('Customer is deleted:', customer); + } + } catch (error) { + console.error('Error fetching Stripe customer:', error); + } + } + + // Check if there's an active subscription in Stripe + let activeSubscription = 'free'; + if (stripeCustomer) { + const subscriptions = await stripe.subscriptions.list({ + customer: stripeCustomer.id, + status: 'active', + limit: 1, + }); + + if (subscriptions.data.length > 0) { + const subscription = subscriptions.data[0]; + if (subscription.items.data[0].plan.id.includes('premium')) { + activeSubscription = 'premium'; + } else if (subscription.items.data[0].plan.id.includes('basic')) { + activeSubscription = 'basic'; + } + } + } + + // Return user data along with Stripe subscription info + return new Response( + JSON.stringify({ + ...user, + plan: activeSubscription, // Override the plan from Stripe if active subscription exists + }), + { status: 200 } + ); +} diff --git a/app/api/stj/route.ts b/app/api/stj/route.ts new file mode 100644 index 000000000..1aa36b2ea --- /dev/null +++ b/app/api/stj/route.ts @@ -0,0 +1,70 @@ +import { storeDocumentsInPinecone } from "@/lib/chat/embeddingsProviders"; +import { NextResponse } from "next/server"; +import slugify from 'slugify'; +import { Pinecone } from "@pinecone-database/pinecone" + +export async function POST(req: Request) { + try { + const body = await req.json(); + const namespace = slugify(body.search, { lower: true, strict: false, trim: true }); + + const pinecone = new Pinecone({ + apiKey: process.env.PINECONE_API_KEY || '', + }); + + const index = pinecone.index('lexgpt'); + const indexStats = await index.describeIndexStats(); + const namespaces = indexStats.namespaces || {}; + + // Checar se o namespace existe + if (namespaces.hasOwnProperty(namespace)) { + console.log(`Namespace "${namespace}" already exists.`); + + // TODO Puxar dados do pinecone ou retornar erro + return NextResponse.json({ success: true, data: 'data' }); + } else { + + const response = await fetch('https://lextgpt-puppeteer.onrender.com/scrape', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Failed to fetch data: ${response.statusText}`); + } + + const data = await response.json(); + + await storeDocumentsInPinecone(data, namespace); + + return NextResponse.json({ success: true, data: 'data' }); + } + } catch (error: any) { + return NextResponse.json( + { success: false, message: error.message }, + { status: 500 } + ); + } +} + + +// [ +// { +// "process": "\n\tRESP 1913638\n\t", +// "relator": "Ministro GURGEL DE FARIA (1160)", +// "classe": "S1 - PRIMEIRA SEÇÃO", +// "ementa": " \n\t\t\"A contratação de servidores públicos temporários sem concurso\npúblico, mas baseada em legislação local, por si só, não configura a\nimprobidade administrativa prevista no art. 11 da Lei 8.429/1992,\npor estar ausente o elemento subjetivo (dolo) necessário para a\nconfiguração do ato de improbidade violador dos princípios da\nadministração pública\".Veja o Tema Repetitivo 1108\n\t\t", +// "acordao": "\n\t\t\n\n\t\tPROCESSUAL CIVIL E ADMINISTRATIVO. RECURSO ESPECIAL REPRESENTATIVODA CONTROVÉRSIA. IMPROBIDADE. CONTRATAÇÃO DE SERVIDOR TEMPORÁRIO.AUTORIZAÇÃO. LEI LOCAL. DOLO. AFASTAMENTO.1. Em face dos princípios a que está submetida a administração pública (art. 37 da CF/1988) e tendo em vista a supremacia deles, sendo representantes daquela os agentes públicos passíveis de serem alcançados pela lei de improbidade, o legislador ordinário quis impedir o ajuizamento de ações temerárias, evitando, com isso, além de eventuais perseguições políticas e o descrédito social de atos ou decisões político-administrativos, +// "link": "https://processo.stj.jus.br/processo/pesquisa/?num_registro=202003436012%27" +// }, +// { +// "process": "\n\tRESP 1926832\n\t", +// "relator": "Ministro GURGEL DE FARIA (1160)", +// "classe": "S1 - PRIMEIRA SEÇÃO", +// "ementa": " \n\t\t\"A contratação de servidores públicos temporários sem concurso\npúblico, mas baseada em legislação local, por si só, não configura a\nimprobidade administrativa prevista no art. 11 da Lei n. 8.429/1992,\npor estar ausente o elemento subjetivo (dolo) necessário para a\nconfiguração do ato de improbidade violador dos princípios da\nadministração pública\".Veja o Tema Repetitivo 1108\n\t\t", +// "acordao": "\n\t\t\n\n\t\tPROCESSUAL CIVIL E ADMINISTRATIVO. RECURSO ESPECIAL REPRESENTATIVODA CONTROVÉRSIA. IMPROBIDADE. CONTRATAÇÃO DE SERVIDOR TEMPORÁRIO.AUTORIZAÇÃO. LEI LOCAL. DOLO. AFASTAMENTO.1. Em face dos princípios a que está submetida a administração pública (art. 37 da CF/1988) e tendo em vista a supremacia deles, sendo representantes daquela os agentes públicos passíveis de serem alcançados pela lei de improbidade, o legislador ordinário quis impedir o ajuizamento de ações temerárias, evitando, com isso, al", +// "link": "https://processo.stj.jus.br/processo/pesquisa/?num_registro=202100720958%27" +// }, \ No newline at end of file diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 000000000..34a716e84 --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,57 @@ +import { stripe } from '@/lib/stripe' +import { translatePlan } from '@/lib/utils'; +import { NextRequest, NextResponse } from 'next/server'; +import { Stripe } from 'stripe'; +import { Plan, Period } from "@/lib/types"; + +export async function POST(request: NextRequest) { + try { + // you can implement some basic check here like, is user valid or not + const data = await request.json(); + const { priceId, session } = data; + + const period_plan = priceId.split('_') + const period = period_plan[0] as Period + const plan = period_plan[1] as Plan + + let userExists = false; + + try { + const stripeUser = await stripe.customers.retrieve(session.stripeId) + userExists = stripeUser && !stripeUser.deleted + } catch(error) { + console.log(error) + } + + const checkoutSession: Stripe.Checkout.Session = + await stripe.checkout.sessions.create({ + //payment_method_types: ['card', 'apple_pay'], + line_items: [ + { + price: priceId, + quantity: 1, + } + ], + custom_text: { + submit: { + message: `Você está assinando o plano ${translatePlan[plan]} ${translatePlan[period]}.`, + }, + }, + customer: userExists ? session?.stripeId : undefined, + customer_email: userExists ? undefined : session?.email, + mode: 'subscription', + success_url: `${process.env.VERCEL_URL}/checkout/result?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.VERCEL_URL}/checkout`, + metadata: { + userId: session.id, + email: session.email, + period, + plan, + } + }); + return NextResponse.json({ result: checkoutSession, ok: true }); + } catch (error) { + console.log(error); + return new NextResponse('Internal Server', { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/webhooks/actions.ts b/app/api/webhooks/actions.ts new file mode 100644 index 000000000..87ea43382 --- /dev/null +++ b/app/api/webhooks/actions.ts @@ -0,0 +1,76 @@ +'use server' + +import { kv } from '@vercel/kv' +import { getUser } from '@/app/login/actions' +import { auth } from '@/auth' +import { stripe } from '@/lib/stripe' +//import { subscription, User, Session } from '@/lib/types' +import { fromUnixTime } from 'date-fns' +import { User } from '@/lib/types' + +export async function updateSubscription({ + email, + customer, + period, + plan, +}: User) { + console.log('Atualizando mensalidade: ', email) + + try { + const user = await getUser(email as string) + const newUser = { + ...user, + plan, + period, + stripeId: customer, + startDate: new Date(), + chargeDate: period === 'month' ? fromUnixTime(Date.now() / 1000 + 30 * 24 * 60 * 60) : period === 'anual' ? fromUnixTime(Date.now() / 1000 + 365 * 24 * 60 * 60) : null, + } + console.log('updating user', newUser) + + await kv.hmset(`user:${email}`, newUser) + + } catch(error) { + console.log(error) + throw new Error('Ocorreu um erro ao adicionar usuário ',error.message) + } +} + +export async function cancelStripeSubscriptions(stripeId: string, filterId?: string) { + // filterId is the current id that will not be cancelled + try { + const subscriptions = await stripe.subscriptions.list({ + customer: stripeId, + }) + + subscriptions.data.forEach(async (subscription) => { + if (subscription.id !== filterId) { + await stripe.subscriptions.cancel(subscription.id) + } + }) + } catch(error) { + console.log(error) + } +} + +export async function removeSubscription(email?: string, stripeId: string) { + let userEmail = email + + if (!email) { + const stripeUser = await stripe.customers.retrieve(stripeId) + userEmail = stripeUser.email + } + + console.log('Cancelando mensalidade: ', userEmail) + + const subscription = { + email: userEmail, + customer: stripeId, + plan: 'free', + period: null, + } + + await cancelStripeSubscriptions(stripeId); + await updateSubscription(subscription) + +} \ No newline at end of file diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 000000000..7af033280 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,358 @@ +/* + * + *================================================= + * Stripe Webhook Route. + *================================================= + * This route is used to handle Stripe Webhooks. + * - https://stripe.com/docs/webhooks + * @author gh/tego101 + * @version 1.0.0 + * @url https://github.com/tego101/nextjs-14-stripe-webhooks + */ +import Stripe from "stripe"; +import { updateSubscription, cancelStripeSubscriptions, removeSubscription } from '@/app/api/webhooks/actions'; + +type EventName = + // Checkout: https://stripe.com/docs/payments/checkout + | "checkout.session.completed" + | "checkout.session.async_payment_succeeded" + | "checkout.session.async_payment_failed" + | "checkout.session.expired" + // Charge: https://stripe.com/docs/api/charges + | "charge.succeeded" + | "charge.failed" + | "charge.refunded" + | "charge.expired" + // Disputes: https://stripe.com/docs/disputes + | "charge.dispute.created" + | "charge.dispute.updated" + | "charge.dispute.funds_reinstated" + | "charge.dispute.funds_withdrawn" + | "charge.dispute.closed" + // Customer: https://stripe.com/docs/api/customers + | "customer.created" + | "customer.updated" + | "customer.deleted" + | "customer.subscription.created" + | "customer.subscription.updated" + | "customer.subscription.deleted" + | "customer.subscription.paused" + | "customer.subscription.resumed"; + +async function handleStripeWebhook(body: any) { + // const mode = body.data?.object?.mode; + const id = body.data?.object?.id; + // const obj = body.data?.object?.object; + const stat = body.data?.object?.status; + const status = body.data?.object?.payment_status; + // const payment_intent = body.data?.object?.payment_intent; + // const subId = body.data?.object?.subscription; + // const stripeInvoiceId = body.data?.object?.invoice; + // const user = body.data?.object?.metadata?.userId; + // const meta = body.data?.object?.metadata; + // const stripe_invoice = body.data?.object?.invoice; + const type = body.type as EventName; + const email = body.data?.object?.metadata?.email || body.data?.object?.customer_email || body.data?.object?.billing_details?.email; + const customer = body.data?.object?.customer; + const period = body.data?.object?.metadata?.period; + const plan = body.data?.object?.metadata?.plan; + + console.log(body.data) + + // console.log everything above REMOVE BEFORE PRODUCTION. + // console.log("mode --->", mode); + // console.log("webhook type --->", type); + // console.log("id --->", id); + // console.log("obj --->", obj); + // console.log("stat --->", stat); + // console.log("status --->", status); + // console.log("payment_intent --->", payment_intent); + // console.log("subId --->", subId); + // console.log("stripeInvoiceId --->", stripeInvoiceId); + // console.log("user --->", user); + // console.log("meta --->", meta); + // console.log("stripe_invoice --->", stripe_invoice); + + + // Switch on the event type. + switch (type) { + case "checkout.session.completed": + case "checkout.session.async_payment_succeeded": + //console.log(JSON.stringify(body, null, 2)) + // When a checkout session is completed or payment succeeds + + if (stat === 'complete' || stat === 'succeeded') { + // Update the user's subscription and plan in KV + await updateSubscription({ + email, + customer, + period, + plan, + }); + return new Response(JSON.stringify({ message: "Subscription updated!" }), { status: 200 }); + } + break; + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer Subscription: Updated + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer's subscription is updated. + */ + case "customer.subscription.updated": + // Add logic for handling updates to a customer's subscription + await cancelStripeSubscriptions(customer,id) + + return new Response( + JSON.stringify({ message: "Customer subscription updated!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Session Expired. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a session expires. + */ + case "checkout.session.expired": + // logic to handle expired sessions. + + return new Response( + JSON.stringify({ message: "Payments marked canceled!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge: Succeeded. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a payment is successful. + */ + case "charge.succeeded": + // logic to handle successful charges. + + + return new Response(JSON.stringify({ message: "Payment completed!" }), { + status: 200, + }); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge: Refunded. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a charge fails. + */ + case "charge.expired": + case "charge.refunded": + case "charge.failed": + case "customer.subscription.deleted": + // logic to handle failed charges. + await removeSubscription(email, customer); + + return new Response(JSON.stringify({ message: "Payment Updated!" }), { + status: 200, + }); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge Dispute: Created. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a dispute is created. + */ + case "charge.dispute.created": + // logic here... + + return new Response( + JSON.stringify({ message: "Dispute details added!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge Dispute: Updated. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a dispute is created. + */ + case "charge.dispute.updated": + // logic here... + + return new Response( + JSON.stringify({ message: "Dispute details updated!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge Dispute: Funds re-instated. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a dispute\'s funds are re-instated. + */ + case "charge.dispute.funds_reinstated": + // logic here.. + + return new Response( + JSON.stringify({ message: "Dispute details updated!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Charge Dispute: Funds withdrawn. + * =~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a dispute\'s funds are withdrawn. + */ + case "charge.dispute.funds_withdrawn": + // logic here... + + return new Response( + JSON.stringify({ message: "Dispute details updated!" }), + { + status: 200, + } + ); + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer: Created + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a new customer is created. + */ + case "customer.created": + // Add logic for handling customer creation + return new Response(JSON.stringify({ message: "Customer created!" }), { + status: 200, + }); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer: Updated + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer's details are updated. + */ + case "customer.updated": + // Add logic for handling customer updates + return new Response(JSON.stringify({ message: "Customer updated!" }), { + status: 200, + }); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer: Deleted + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer is deleted. + */ + case "customer.deleted": + // Add logic for handling customer deletion + return new Response(JSON.stringify({ message: "Customer deleted!" }), { + status: 200, + }); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer Subscription: Created + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer's subscription is created. + */ + case "customer.subscription.created": + // Add logic for handling the creation of a customer subscription + return new Response( + JSON.stringify({ message: "Customer subscription created!" }), + { + status: 200, + } + ); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer Subscription: Paused + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer's subscription is paused. + */ + case "customer.subscription.paused": + // Add logic for handling the pausing of a customer's subscription + return new Response( + JSON.stringify({ message: "Customer subscription paused!" }), + { + status: 200, + } + ); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * Customer Subscription: Resumed + * =~~~~~~~~~~~~~~~~~~~~~~~~= + * This is the webhook that is fired when a customer's subscription is resumed. + */ + case "customer.subscription.resumed": + // Add logic for handling the resumption of a customer's subscription + return new Response( + JSON.stringify({ message: "Customer subscription resumed!" }), + { + status: 200, + } + ); + + /* + * =~~~~~~~~~~~~~~~~~~~~~~~= + * Default + * =~~~~~~~~~~~~~~~~~~~~~~~= + */ + default: + return new Response(JSON.stringify({ error: "Invalid event type" }), { + status: 400, + }); + } +} + +export async function POST(request: Request) { + try { + // Request Body. + const rawBody = await request.text(); + const body = JSON.parse(rawBody); + + let event; + + // Verify the webhook signature + try { + const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!stripeWebhookSecret) { + throw new Error("STRIPE_WEBHOOK_SECRET not set"); + } + + const sig = request.headers.get("Stripe-Signature"); + if (!sig) { + throw new Error("Stripe Signature missing"); + } + + // Assuming you have a Stripe instance configured + event = Stripe.webhooks.constructEvent(rawBody, sig, stripeWebhookSecret); + } catch (err) { + console.error(`⚠️ Webhook signature verification failed.`, err.message); + return new Response( + JSON.stringify({ error: "Webhook signature verification failed" }), + { + status: 400, + } + ); + } + + const webhookResponse = await handleStripeWebhook(event); // Ensure handleStripeWebhook is properly implemented + + return new Response(webhookResponse?.body, { + status: webhookResponse?.status || 200, + }); + } catch (error) { + console.error("Error in Stripe webhook handler:", error); + return new Response(JSON.stringify({ error: "Webhook handler failed." }), { + status: 500, // Changed to 500, indicating a server error + }); + } +} + +export async function GET(request: Request, response: Response) { + // Bad Request or how ever you want to respond. + return new Response(JSON.stringify({ error: "Bad Request" }), { + status: 400, + }); +} \ No newline at end of file diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx new file mode 100644 index 000000000..5fd048d73 --- /dev/null +++ b/app/checkout/page.tsx @@ -0,0 +1,174 @@ +"use client"; + +import CancelDialog from "@/components/cancel-dialog"; +import SubscribeComponent from "@/components/subscribe"; +import { Plan, User } from "@/lib/types"; +import { translatePlan } from "@/lib/utils"; +import { CheckIcon } from "@radix-ui/react-icons"; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from "react"; +import { format } from 'date-fns' + +const priceIds = { + month: { + basic: 'month_basic', + premium: 'month_premium', + }, + anual: { + basic: 'anual_basic', + premium: 'anual_premium', + }, +}; + +interface PlanCardProps { + price: string; + features: string[]; + planKey: Plan; + priceId?: typeof priceIds; +} + +export default function DonatePage(): JSX.Element { + const router = useRouter(); + const [period, setPeriod] = useState<'month' | 'anual'>('month'); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchSession = async () => { + const response = await fetch('/api/session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!data) { + router.push("/login"); + return; + } + + setSession(data); + setLoading(false); + }; + + fetchSession(); + }, [router]); + + const currentPlan = () => { + if (loading) return 'Carregando...'; + if (!session || session.plan === 'free') return translatePlan['free']; + if (session.plan) return `${translatePlan[session.plan]} ${translatePlan[session.period]}`; + return 'free'; // Default to free if no plan is found + }; + + function PlanCard({ price, features, planKey, priceId }: PlanCardProps) { + return ( +
+
+

{translatePlan[planKey]}

+

{price}

+
+
    + {features.map((feature: string) => ( +
  • +
  • + ))} +
+
+
+
+ {loading ? + + : planKey === 'free' && session?.plan !== 'free' ? + + + + : (session?.plan === planKey && (session.period === period || session.period === null )) ? ( + + ) : ( + + )} +
+
+ ); + } + + return ( +
+
+

Assine Lexgpt

+

Escolha o plano que melhor se adequa a você

+
+
+

+ Plano atual: {currentPlan()} +

+ {session?.startDate && +

+ {session?.plan === 'free' ? 'Criado' : 'Assinado' } em: {format(session.startDate, 'dd/MM/yyyy')} +

} + {session?.chargeDate && +

+ Próxima fatura: {format(session.chargeDate, 'dd/MM/yyyy')} +

} +
+
+ +
+ +
+ {/* Free Plan */} + + + {/* Basic Plan */} + + + {/* Premium Plan */} + +
+ {session && session.plan !== 'free' && + + + } +
+

Cancelamento a qualquer momento. Sem taxas de cancelamento.

+
+
+ ); +} diff --git a/app/checkout/result/error.tsx b/app/checkout/result/error.tsx new file mode 100644 index 000000000..f232b06ba --- /dev/null +++ b/app/checkout/result/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function Error({ error }: { error: Error }) { + return

{error.message}

; +} \ No newline at end of file diff --git a/app/checkout/result/layout.tsx b/app/checkout/result/layout.tsx new file mode 100644 index 000000000..5959a4fad --- /dev/null +++ b/app/checkout/result/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Checkout Session Result", +}; + +export default function ResultLayout({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/app/checkout/result/page.tsx b/app/checkout/result/page.tsx new file mode 100644 index 000000000..a9d6686e0 --- /dev/null +++ b/app/checkout/result/page.tsx @@ -0,0 +1,105 @@ +import type { Stripe } from "stripe"; +import { stripe } from "@/lib/stripe"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { translatePlan } from '@/lib/utils'; + +export default async function ResultPage({ + searchParams, +}: { + searchParams: { session_id: string }; +}): Promise { + if (!searchParams.session_id) { + return ; + } + + if (searchParams.session_id === 'cancel') { + return ; + } + + let checkoutSession: Stripe.Checkout.Session | null = null; + try { + checkoutSession = await stripe.checkout.sessions.retrieve( + searchParams.session_id, + { expand: ["line_items", "payment_intent"] } + ); + } catch (error) { + console.error("Error retrieving checkout session:", error); + } + + if (!checkoutSession || checkoutSession.status !== "complete") { + return ; + } + + return ; +} + +function ErrorCard({ title, description }: { title: string, description: string }) { + return ( +
+
+

❌ {title}

+

{description}

+
+ + + +
+
+
+ ); +} + +function CancelCard() { + return ( +
+
+

⚠️ Assinatura Cancelada

+

+ Suas assinaturas e faturas foram canceladas. +

+
+ + + +
+
+
+ ); +} + +function SuccessCard({ checkoutSession }: { checkoutSession: Stripe.Checkout.Session }) { + const { email, name } = checkoutSession.customer_details || {}; + const { period, plan } = checkoutSession.metadata || {}; + + return ( +
+
+

✅ Assinatura Confirmada!

+

+ Obrigado por assinar o LexGPT. Sua jornada com IA começa agora! +

+
+

Seu Nome: {name}

+

E-mail: {email}

+

Plano: {translatePlan[plan]}

+

Período: {translatePlan[period]}

+

+ Você receberá um e-mail de confirmação em breve com todos os detalhes da sua assinatura. +

+
+
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index a32b2230f..0f6bce9e4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,14 +13,14 @@ export const metadata = { ? new URL(`https://${process.env.VERCEL_URL}`) : undefined, title: { - default: 'Next.js AI Chatbot', - template: `%s - Next.js AI Chatbot` + default: 'LexGPT', + template: `%s - LexGPT - AI Chatbot` }, - description: 'An AI-powered chatbot template built with Next.js and Vercel.', + description: 'Um chat especializado em responder jurisprudência e leis.', icons: { icon: '/favicon.ico', shortcut: '/favicon-16x16.png', - apple: '/apple-touch-icon.png' + apple: '/lexgpt.png' } } diff --git a/app/opengraph-image.png b/app/opengraph-image.png deleted file mode 100644 index 278b197ef..000000000 Binary files a/app/opengraph-image.png and /dev/null differ diff --git a/app/signup/actions.ts b/app/signup/actions.ts index 492586ae4..88522d2af 100644 --- a/app/signup/actions.ts +++ b/app/signup/actions.ts @@ -24,10 +24,19 @@ export async function createUser( id: crypto.randomUUID(), email, password: hashedPassword, - salt + salt, + plan: 'free', + period: null, + stripeId: null, + startDate: new Date(), } - await kv.hmset(`user:${email}`, user) + // Store the new user in the database + await kv.hmset(`user:${email}`, user); + + // Add the user key to the `all_users` set for tracking + await kv.sadd('all_users', `user:${email}`); + console.log(user, 'user created') return { type: 'success', @@ -36,6 +45,34 @@ export async function createUser( } } +export async function editUser( + email: string, + plan: string, + period: string +) { + const user = await getUser(email) + + if (user) { + const updatedUser = { + ...user, + plan, + period + } + + await kv.hmset(`user:${email}`, updatedUser) + + return { + type: 'success', + resultCode: ResultCode.UserEdited + } + } else { + return { + type: 'error', + resultCode: ResultCode.UserNotFound + } + } +} + interface Result { type: string resultCode: ResultCode diff --git a/app/twitter-image.png b/app/twitter-image.png deleted file mode 100644 index 278b197ef..000000000 Binary files a/app/twitter-image.png and /dev/null differ diff --git a/auth.ts b/auth.ts index 7542992bd..ea094d832 100644 --- a/auth.ts +++ b/auth.ts @@ -31,11 +31,10 @@ export const { auth, signIn, signOut } = NextAuth({ ) const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) - if (hashedPassword === user.password) { + if (hashedPassword === user.password) return user - } else { - return null - } + + return null } return null diff --git a/components/cancel-dialog.tsx b/components/cancel-dialog.tsx new file mode 100644 index 000000000..e376db9fd --- /dev/null +++ b/components/cancel-dialog.tsx @@ -0,0 +1,42 @@ +import { removeSubscription } from "@/app/api/webhooks/actions"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { User } from "@/lib/types"; +import { translatePlan } from '@/lib/utils'; +import { useRouter } from "next/navigation"; + +export default function CancelDialog({ children, session }: { children: React.ReactNode, session: User }) { + const router = useRouter(); + + return ( + + + {children} + + + + Você deseja cancelar sua mensalidade atual? + + Seu plano atual é o {translatePlan[session.plan]}. Ao confirmar sua mensalidade voltará ao plano gratuito. + + + + Cancelar + { + await removeSubscription(session.email, session.stripeId); + router.push('/checkout/result?session_id=cancel'); + }}>Confirmar + + + + ) +} \ No newline at end of file diff --git a/components/chat-history.tsx b/components/chat-history.tsx index 88b6ff440..d0274b548 100644 --- a/components/chat-history.tsx +++ b/components/chat-history.tsx @@ -15,7 +15,7 @@ export async function ChatHistory({ userId }: ChatHistoryProps) { return (
-

Chat History

+

Histórico

- New Chat + Novo Chat
- {messages.length === 0 && + {/* TODO: revome this false */} + {messages.length === 0 && false && exampleMessages.map((example, index) => (
{ initialMessages?: Message[] @@ -26,10 +27,16 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) { const [messages] = useUIState() const [aiState] = useAIState() + const [user, setUser] = useState({}) + const [_, setNewChatId] = useLocalStorage('newChatId', id) useEffect(() => { if (session?.user) { + getUser(session.user.email).then(subsInfo => { + setUser(subsInfo as unknown as Session) + }) + if (!path.includes('chat') && messages.length === 1) { window.history.replaceState({}, '', `/chat/${id}`) } diff --git a/components/clear-history.tsx b/components/clear-history.tsx index 69cf70ec3..547549c13 100644 --- a/components/clear-history.tsx +++ b/components/clear-history.tsx @@ -37,19 +37,19 @@ export function ClearHistory({ - Are you absolutely sure? + Você tem certeza? - This will permanently delete your chat history and remove your data - from our servers. + Isso vai apagar permanentemente o histórico do chat e remover seus dados + dos nossos servidores. - Cancel + Cancelar { @@ -66,7 +66,7 @@ export function ClearHistory({ }} > {isPending && } - Delete + Deletar diff --git a/components/empty-screen.tsx b/components/empty-screen.tsx index 1bdbd5957..b172af497 100644 --- a/components/empty-screen.tsx +++ b/components/empty-screen.tsx @@ -1,36 +1,29 @@ -import { UseChatHelpers } from 'ai/react' +// import { UseChatHelpers } from 'ai/react' -import { Button } from '@/components/ui/button' +// import { Button } from '@/components/ui/button' import { ExternalLink } from '@/components/external-link' -import { IconArrowRight } from '@/components/ui/icons' +// import { IconArrowRight } from '@/components/ui/icons' + +import { useChat } from '@/context/chatContext' export function EmptyScreen() { + const chats = useChat() as any + + const description = chats?.description || 'Bot com tecnologia gpt-4o para assuntos juridicos. Desenvolvemos com transparência para que você saiba o que está dentro deste GPT e quais suas limitações. Confiamos na IA, mas verifique no site antes de citar.' + return (

- Welcome to Next.js AI Chatbot! + Bem vindo ao LexGPT!

- This is an open source AI chatbot app template built with{' '} - Next.js, the{' '} - - Vercel AI SDK + {description} + Acompanhe nosso changelog em {' '} + + news.lexgpt.com.br - , and{' '} - - Vercel KV - - . -

-

- It uses{' '} - - React Server Components - {' '} - to combine text with generative UI as output of the LLM. The UI state - is synced through the SDK so the model is aware of your interactions - as they happen. + .

diff --git a/components/footer.tsx b/components/footer.tsx index 5ed21579a..0319eed8b 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -12,12 +12,8 @@ export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { )} {...props} > - Open source AI chatbot built with{' '} - Next.js and{' '} - - Vercel AI SDK - - . + LexGPT, o ChatGPT dos advogados{' '} +

) } diff --git a/components/header.tsx b/components/header.tsx index 3b41a04a4..1a581fdf6 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,20 +1,17 @@ import * as React from 'react' import Link from 'next/link' - -import { cn } from '@/lib/utils' import { auth } from '@/auth' -import { Button, buttonVariants } from '@/components/ui/button' +import { Button } from '@/components/ui/button' import { - IconGitHub, - IconNextChat, IconSeparator, - IconVercel } from '@/components/ui/icons' import { UserMenu } from '@/components/user-menu' import { SidebarMobile } from './sidebar-mobile' import { SidebarToggle } from './sidebar-toggle' import { ChatHistory } from './chat-history' import { Session } from '@/lib/types' +import Image from 'next/image' +import AirtableSelector from './ui/airtableSelector' async function UserOrLogin() { const session = (await auth()) as Session @@ -29,8 +26,7 @@ async function UserOrLogin() { ) : ( - - + Next Chat )}
@@ -47,7 +43,9 @@ async function UserOrLogin() { ) } -export function Header() { +export async function Header() { + const session = (await auth()) as Session + return (
@@ -55,25 +53,15 @@ export function Header() {
-
- - - GitHub - - - - Deploy to Vercel - Deploy - +
+ {/* Logo */} + + Next Chat + +
+
+ {/* Selector */} +
) diff --git a/components/login-form.tsx b/components/login-form.tsx index 0a6910d63..87a36ab0b 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -30,7 +30,7 @@ export default function LoginForm() { className="flex flex-col items-center gap-4 space-y-3" >
-

Please log in to continue.

+

Faça seu login para entrar

@@ -63,7 +63,7 @@ export default function LoginForm() { id="password" type="password" name="password" - placeholder="Enter password" + placeholder="Insira a senha" required minLength={6} /> @@ -77,7 +77,7 @@ export default function LoginForm() { href="/signup" className="flex flex-row gap-1 text-sm text-zinc-400" > - No account yet?
Sign up
+ Não possui uma conta?
Cadastrar-se
) diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx index 2271edbd5..bf38e12a2 100644 --- a/components/prompt-form.tsx +++ b/components/prompt-form.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import { useState } from 'react' import Textarea from 'react-textarea-autosize' import { useActions, useUIState } from 'ai/rsc' @@ -18,6 +19,9 @@ import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' import { nanoid } from 'nanoid' import { useRouter } from 'next/navigation' +import { useChat, useChatDispatch } from '@/context/chatContext' +import { toast } from 'sonner' + export function PromptForm({ input, setInput @@ -28,14 +32,23 @@ export function PromptForm({ const router = useRouter() const { formRef, onKeyDown } = useEnterSubmit() const inputRef = React.useRef(null) - const { submitUserMessage } = useActions() + const { submitUserMessage, describeImage } = useActions() const [_, setMessages] = useUIState() + const chats = useChat() + const [showActions, setShowActions] = useState(false) React.useEffect(() => { if (inputRef.current) { inputRef.current.focus() } - }, []) + + if (input === '/') + setShowActions(true) + else + setShowActions(false) + }, [input]) + + const fileRef = React.useRef(null) return (
[...currentMessages, responseMessage]) + try { + const responseMessage = await submitUserMessage(value, chats) + setMessages(currentMessages => [...currentMessages, responseMessage]) + } catch { + toast( +
+ Você atingiu o limite de mensagens. Aguarde um momento e tente novemente. +
+ ) + } }} > + { + if (!event.target.files) { + toast.error('No file selected') + return + } + + const file = event.target.files[0] + + if (file.type.startsWith('video/')) { + const responseMessage = await describeImage('') + setMessages(currentMessages => [ + ...currentMessages, + responseMessage + ]) + } else { + const reader = new FileReader() + reader.readAsDataURL(file) + + reader.onloadend = async () => { + const base64String = reader.result + const responseMessage = await describeImage(base64String) + setMessages(currentMessages => [ + ...currentMessages, + responseMessage + ]) + } + } + }} + /> + + {showActions && +
    +
  • setInput('/stj ')}> + /stj Buscar no STJ +
  • +
+ }
- - - - - New Chat - + {/* TODO: Implementar file upload */} +