diff --git a/.env.semple b/.env.semple index fd8eecc..1295653 100644 --- a/.env.semple +++ b/.env.semple @@ -7,11 +7,14 @@ NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard OPENAI_API_KEY= +OPENAI_API_KEY= +OPENAI_ORGANIZATION_ID= REPLICATE_API_TOKEN= -DATABASE_URL= +DATABASE_URL="mysql://root:example@localhost:3306/iasaas" -STRIPE_API_KEY= +STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID= -NEXT_PUBLIC_APP_URL="http://localhost:3000" \ No newline at end of file +NEXT_PUBLIC_APP_URL="http://localhost:3000" diff --git a/README.md b/README.md index c403366..5a2bfdf 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,124 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# Synthetic -## Getting Started +O Synthetic e um modelo de aplicativo SaaS, totalmente funcional, mostra, de forma didática, um ambiente para gerar conteúdo através de IA. Para utilizá-lo você faz o registro com e-mail ou autorização via rede social (Google ou Facebook, etc). Após o registro a plataforma permite a assinatura do serviço que permite gerar conteúdo através de APIs de Inteligência Artificial. -First, run the development server: +Aos interessados, existe a possibilidade de substituir o serviço por qualquer outro, aproveitando a estrutura e os recursos da aplicação. Este modelo foi elaborado para exemplificar, de forma didática, o processo de um modelo de um Micro SaaS. + +![alt text](./doc/land-page.png) + +## Pré-requisitos + +- node (20.11.1 - utilizado) +- npm (10.2.4 - utilizado) ou gerenciador de biblioteca de sua preferência +- git (2.34.1 - utilizado) + +## Techs + +- React 18 - linguagem de programação - +- Next 14 - framework - +- Typescript - tipagem - +- Shadcn ui - componentes - +- Tailwind - estilização e ui - +- Lucide-react - ícones - +- Prisma - orm / persistência - +- Next-intl - internacionalização - +- Eslint - padronização, qualidade e estilo de código - +- Axios - Cliente HTTP baseado em Promise - +- React-markdown - React component to render markdown - +- Zod - Biblioteca de declaração e validação de esquema TypeScript-first - + +[](#servicos) + +## Serviços na web + +- Clerk/nextjs - Autenticação e Autorização - +- Stripe - gateway de pagamento - +- Openai - Inteligência Artificial - +- Replicate - Inteligência Artificial - +- Crisp - Plataforma de mensagens multifunções e multicanal - +- Database - mySql - + +![alt text](./doc/dashboard.png) + +## Instalar e Executar + +1. Clone + + Escolha a pasta onde deseja armazenar o projeto e digite os comandos abaixo: + + ``` + git clone https://github.com/esbnet/synthetic + ``` + +2. Instalar dependências + + Na pasta raiz do projeto, digite o seguinte comando: + + ``` + npm install + ``` + +3. Configurar variáveis de ambiente + + Para que o sistema rode é necessário configurar as variáveis de ambiente. Para isso, deverá ser consultado a documentação de cada serviço utilizado. Link acima. + Na pasta raiz, crie o arquivo `.env` e inclua as variáveis abaixo com seus respectivos valores. + +``` +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= + +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard + +# IA's +OPENAI_API_KEY= +OPENAI_API_KEY= +OPENAI_ORGANIZATION_ID= + +REPLICATE_API_TOKEN= + +# Database +DATABASE_URL="mysql://root:example@localhost:3306/iasaas" //Config your database URL + +# STRIPE_SECRET_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID= + +NEXT_PUBLIC_APP_URL="http://localhost:3000" -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +4. Criar banco de dados e tabelas + + É pré-requisito configurar as variáveis de ambiente para que o sistema tenha as credenciais de acesso ao Supabase. + Na pasta raiz, digite: + + ``` + npx prisma migrate dev + ``` + + Este é o comando que criará o banco de dados e as tabelas no ambiente do Bando de Dados. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +5. Executar o projeto -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + Ainda na pasta raiz do projeto, após realizar todos os procedimentos acima, rode o comando: -## Learn More + ``` + npm run dev + ``` -To learn more about Next.js, take a look at the following resources: +

-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +
+Bons estudos... -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! +


-## Deploy on Vercel +Me pagar um café (pix): :coffee: -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +![me pague um café](./doc/pix.png) -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +
diff --git a/app/(dashboard)/(routes)/code/page.tsx b/app/(dashboard)/(routes)/code/page.tsx index 12c4094..8d7c9d9 100644 --- a/app/(dashboard)/(routes)/code/page.tsx +++ b/app/(dashboard)/(routes)/code/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Empty from "@/components/empty"; -import Heading from "@/components/heading"; +import { Heading } from "@/components/heading"; import Loader from "@/components/loader"; import BotAvatar from "@/components/ui/bot-avatar"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,8 @@ import ReactMarkdown from "react-markdown"; import { useForm } from "react-hook-form"; +import { useProModal } from "@/hooks/use-pro-modal"; +import toast from "react-hot-toast"; import * as z from "zod"; import { formSchema } from "./constants"; @@ -29,6 +31,8 @@ type userMessage = { }; export default function CodePage() { + const proModal = useProModal(); + const [messages, setMessages] = useState([]); const router = useRouter(); const form = useForm>({ @@ -60,9 +64,12 @@ export default function CodePage() { console.log(messages); form.reset(); - } catch (error) { - // TODO: Open Pro Modal - console.log(error); + } catch (error: any) { + if (error?.response?.status === 403) { + proModal.onOpen(); + } else { + toast.error("Something went wrong"); + } } finally { // form.reset(); router.refresh(); diff --git a/app/(dashboard)/(routes)/conversation/page.tsx b/app/(dashboard)/(routes)/conversation/page.tsx index 18d0f20..2fc950d 100644 --- a/app/(dashboard)/(routes)/conversation/page.tsx +++ b/app/(dashboard)/(routes)/conversation/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Empty from "@/components/empty"; -import Heading from "@/components/heading"; import Loader from "@/components/loader"; import BotAvatar from "@/components/ui/bot-avatar"; import { Button } from "@/components/ui/button"; @@ -21,6 +20,9 @@ import remarkGfm from "remark-gfm"; import { useForm } from "react-hook-form"; +import { Heading } from "@/components/heading"; +import { useProModal } from "@/hooks/use-pro-modal"; +import toast from "react-hot-toast"; import * as z from "zod"; import { formSchema } from "./constants"; @@ -30,6 +32,7 @@ type userMessage = { }; export default function Conversation() { + const proModal = useProModal(); const [messages, setMessages] = useState([]); const router = useRouter(); const form = useForm>({ @@ -61,11 +64,14 @@ export default function Conversation() { console.log(messages); form.reset(); - } catch (error) { - // TODO: Open Pro Modal - console.log(error); + } catch (error: any) { + if (error?.response?.status === 403) { + proModal.onOpen(); + } else { + toast.error("Something went wrong"); + } } finally { - // form.reset(); + form.reset(); router.refresh(); } }; diff --git a/app/(dashboard)/(routes)/image/page.tsx b/app/(dashboard)/(routes)/image/page.tsx index d849fbf..9a2310b 100644 --- a/app/(dashboard)/(routes)/image/page.tsx +++ b/app/(dashboard)/(routes)/image/page.tsx @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import Empty from "@/components/empty"; -import Heading from "@/components/heading"; +import { Heading } from "@/components/heading"; import Loader from "@/components/loader"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; @@ -26,7 +26,9 @@ import { Card, CardFooter } from "@/components/ui/card"; import Image from "next/image"; import { amountOptions, formSchema, resolutionOptions } from "./constants"; +import { useProModal } from "@/hooks/use-pro-modal"; import { Download, Image as ImageIcon } from "lucide-react"; +import toast from "react-hot-toast"; type userMessage = { role: "user"; @@ -34,6 +36,8 @@ type userMessage = { }; export default function ImagePage() { + const proModal = useProModal(); + const router = useRouter(); const [images, setImages] = useState([]); @@ -58,9 +62,12 @@ export default function ImagePage() { setImages(urls); form.reset(); - } catch (error) { - // TODO: Open Pro Modal - console.log(error); + } catch (error: any) { + if (error?.response?.status === 403) { + proModal.onOpen(); + } else { + toast.error("Something went wrong"); + } } finally { // form.reset(); router.refresh(); diff --git a/app/(dashboard)/(routes)/music/page.tsx b/app/(dashboard)/(routes)/music/page.tsx index 322244c..bbd5838 100644 --- a/app/(dashboard)/(routes)/music/page.tsx +++ b/app/(dashboard)/(routes)/music/page.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import Empty from "@/components/empty"; -import Heading from "@/components/heading"; import Loader from "@/components/loader"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; @@ -15,11 +14,16 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; +import { Heading } from "@/components/heading"; +import { useProModal } from "@/hooks/use-pro-modal"; import { Music } from "lucide-react"; +import toast from "react-hot-toast"; import * as z from "zod"; import { formSchema } from "./constants"; export default function MusicPage() { + const proModal = useProModal(); + const [music, setMusic] = useState(); const router = useRouter(); @@ -41,9 +45,12 @@ export default function MusicPage() { setMusic(response.data.audio); form.reset(); - } catch (error) { - // TODO: Open Pro Modal - console.log(error); + } catch (error: any) { + if (error?.response?.status === 403) { + proModal.onOpen(); + } else { + toast.error("Something went wrong"); + } } finally { // form.reset(); router.refresh(); diff --git a/app/(dashboard)/(routes)/settings/constants.ts b/app/(dashboard)/(routes)/settings/constants.ts new file mode 100644 index 0000000..0ef0262 --- /dev/null +++ b/app/(dashboard)/(routes)/settings/constants.ts @@ -0,0 +1,7 @@ +import * as z from "zod"; + +export const formSchema = z.object({ + prompt: z.string().min(1, { + message: "Prompt is required.", + }), +}); diff --git a/app/(dashboard)/(routes)/settings/page.tsx b/app/(dashboard)/(routes)/settings/page.tsx new file mode 100644 index 0000000..be576d3 --- /dev/null +++ b/app/(dashboard)/(routes)/settings/page.tsx @@ -0,0 +1,31 @@ +import { Settings } from "lucide-react"; + +import { Heading } from "@/components/heading"; +import { SubscriptionButton } from "@/components/subscription-button"; +import { checkSubscription } from "@/lib/subscription"; + +const SettingsPage = async () => { + const isPro = await checkSubscription(); + + return ( +
+ +
+
+ {isPro + ? "You are currently on a Pro plan." + : "You are currently on a free plan."} +
+ +
+
+ ); +}; + +export default SettingsPage; diff --git a/app/(dashboard)/(routes)/video/constants.ts b/app/(dashboard)/(routes)/video/constants.ts index 0ef0262..aee6ac0 100644 --- a/app/(dashboard)/(routes)/video/constants.ts +++ b/app/(dashboard)/(routes)/video/constants.ts @@ -2,6 +2,6 @@ import * as z from "zod"; export const formSchema = z.object({ prompt: z.string().min(1, { - message: "Prompt is required.", + message: "Video Prompt is required.", }), }); diff --git a/app/(dashboard)/(routes)/video/page.tsx b/app/(dashboard)/(routes)/video/page.tsx index 12f9026..e235365 100644 --- a/app/(dashboard)/(routes)/video/page.tsx +++ b/app/(dashboard)/(routes)/video/page.tsx @@ -1,36 +1,29 @@ "use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import axios from "axios"; +import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; import Empty from "@/components/empty"; -import Heading from "@/components/heading"; +import { Heading } from "@/components/heading"; import Loader from "@/components/loader"; -import BotAvatar from "@/components/ui/bot-avatar"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import UserAvatar from "@/components/user-avatar"; - -import { cn } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import axios from "axios"; -import { Video } from "lucide-react"; -import { useRouter } from "next/navigation"; - -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - -import { useForm } from "react-hook-form"; -import * as z from "zod"; +import { useProModal } from "@/hooks/use-pro-modal"; +import { VideoIcon } from "lucide-react"; +import toast from "react-hot-toast"; import { formSchema } from "./constants"; -type userMessage = { - role: "user"; - content: string; -}; +export default function VideoPage() { + const proModal = useProModal(); + + const [video, setVideo] = useState(); -export default function Conversation() { - const [messages, setMessages] = useState([]); const router = useRouter(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -41,31 +34,20 @@ export default function Conversation() { const isLoading = form.formState.isSubmitting; - const onSubmit = async (data: z.infer) => { + const onSubmit = async (values: z.infer) => { try { - const userMessage = { - role: "user", - content: data.prompt, - }; - - const newMessages = [...messages, userMessage]; - - const response = await axios.post("/api/conversation", { - messages: newMessages, - }); - - setMessages((current) => { - return [...current, userMessage, response.data]; - }); - - console.log(messages); + setVideo(undefined); + const response = await axios.post("/api/video", values); + setVideo(response.data[0]); form.reset(); - } catch (error) { - // TODO: Open Pro Modal - console.log(error); + } catch (error: any) { + if (error?.response?.status === 403) { + proModal.onOpen(); + } else { + toast.error("Something went wrong"); + } } finally { - // form.reset(); router.refresh(); } }; @@ -73,11 +55,11 @@ export default function Conversation() { return (
@@ -95,7 +77,7 @@ export default function Conversation() { className="border-0 outline-none focus-visible:ring-0 focus-visible:ring-transparent " disabled={isLoading} - placeholder="How can I help you?" + placeholder="Clown fish swimming around a coral reef" {...field} /> @@ -119,42 +101,17 @@ export default function Conversation() {
)} - {messages.length === 0 && !isLoading && ( - - )} -
- {messages.map((message, index) => ( -
- {message.role === "user" ? : } - {/*

{message.content}

*/} + {!video && !isLoading && } - ( -
-
-                      
- ), - code: ({ node, ...props }) => ( - - ), - }} - className="text-sm overflow-hidden leading-7" - > - {message.content || ""} -
-
- ))} -
+ {video && ( + + )}
diff --git a/app/(landing)/layout.tsx b/app/(landing)/layout.tsx new file mode 100644 index 0000000..18523ea --- /dev/null +++ b/app/(landing)/layout.tsx @@ -0,0 +1,11 @@ +export default function LandLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 60b73cc..609d75c 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -1,18 +1,13 @@ -import { Button } from "@/components/ui/button"; -import Link from "next/link"; +import LandingContent from "@/components/landing-content"; +import LandingHero from "@/components/landing-hero"; +import LandingNavbar from "@/components/landing-navibar"; export default function LandingPage() { return ( -
- Landing Page (desprotegido) -
- - - - - - -
+
+ + +
); } diff --git a/app/api/stripe/route.ts b/app/api/stripe/route.ts new file mode 100644 index 0000000..47c7e38 --- /dev/null +++ b/app/api/stripe/route.ts @@ -0,0 +1,67 @@ +import { auth, currentUser } from "@clerk/nextjs"; +import { NextResponse } from "next/server"; + +import prismadb from "@/lib/prismadb"; +import { stripe } from "@/lib/stripe"; +import { absoluteUrl } from "@/lib/utils"; + +const settingsUrl = absoluteUrl("/settings"); + +export async function GET() { + try { + const { userId } = auth(); + const user = await currentUser(); + + if (!userId || !user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const userSubscription = await prismadb.userSubscription.findUnique({ + where: { + userId, + }, + }); + + if (userSubscription && userSubscription.stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: userSubscription.stripeCustomerId, + return_url: settingsUrl, + }); + + return new NextResponse(JSON.stringify({ url: stripeSession.url })); + } + + const stripeSession = await stripe.checkout.sessions.create({ + success_url: settingsUrl, + cancel_url: settingsUrl, + payment_method_types: ["card"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: user.emailAddresses[0].emailAddress, + line_items: [ + { + price_data: { + currency: "brl", + product_data: { + name: "Synthetic Pro Membership", + description: "Unlimited AI Generations", + }, + unit_amount: 2000, + recurring: { + interval: "month", + }, + }, + quantity: 1, + }, + ], + metadata: { + userId, + }, + }); + + return new NextResponse(JSON.stringify({ url: stripeSession.url })); + } catch (error) { + console.log("[STRIPE_ERROR]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +} diff --git a/app/api/video/route.ts b/app/api/video/route.ts new file mode 100644 index 0000000..b2d3b3b --- /dev/null +++ b/app/api/video/route.ts @@ -0,0 +1,53 @@ +import { auth } from "@clerk/nextjs"; +import { NextResponse } from "next/server"; + +import { checkApiLimit, incrementApiLimit } from "@/lib/api-limit"; +import { checkSubscription } from "@/lib/subscription"; + +import Replicate from "replicate"; + +const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, +}); + +export async function POST(req: Request) { + try { + const { userId } = auth(); + const body = await req.json(); + const { prompt } = body; + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + if (!prompt) { + return new NextResponse("Prompt is required", { status: 400 }); + } + + const freeTrial = await checkApiLimit(); + const isPro = await checkSubscription(); + + if (!freeTrial && !isPro) { + return new NextResponse( + "Free trial has expired. Please upgrade to pro.", + { status: 403 } + ); + } + + const response = await replicate.run( + "anotherjesse/zeroscope-v2-xl:9f747673945c62801b13b84701c783929c0ee784e4748ec062204894dda1a351", + { + input: { prompt }, + } + ); + + if (!isPro) { + await incrementApiLimit(); + } + + return NextResponse.json(response); + } catch (error) { + console.log("[VIDEO_ERROR]", error); + return new NextResponse("Internal Error", { status: 500 }); + } +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..d83483b --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,67 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import Stripe from "stripe"; + +import prismadb from "@/lib/prismadb"; +import { stripe } from "@/lib/stripe"; + +export async function POST(req: Request) { + const body = await req.text(); + const signature = headers().get("Stripe-Signature") as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (error: any) { + return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 }); + } + + const session = event.data.object as Stripe.Checkout.Session; + + if (event.type === "checkout.session.completed") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + if (!session?.metadata?.userId) { + return new NextResponse("User id is required", { status: 400 }); + } + + await prismadb.userSubscription.create({ + data: { + userId: session?.metadata?.userId, + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }); + } + + if (event.type === "invoice.payment_succeeded") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + await prismadb.userSubscription.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }); + } + + return new NextResponse(null, { status: 200 }); +} diff --git a/app/globals.css b/app/globals.css index b300ff3..7c33401 100644 --- a/app/globals.css +++ b/app/globals.css @@ -19,7 +19,8 @@ body, --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; + /* --primary: 222.2 47.4% 11.2%; */ + --primary: 248 90% 66%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; diff --git a/app/layout.tsx b/app/layout.tsx index 4f111de..246ba36 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,11 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import CrispProvider from "@/components/crisp-provider"; +import { ModalProvider } from "@/components/modal-provider"; +import ToasterProvider from "@/components/toaster-provider"; import { ClerkProvider } from "@clerk/nextjs"; -import {ReactNode} from "react"; +import { ReactNode } from "react"; const inter = Inter({ subsets: ["latin"] }); @@ -16,8 +19,8 @@ const inter = Inter({ subsets: ["latin"] }); // } export const metadata: Metadata = { - title: "Genius", - description: "AI Platform", + title: "Synthetic", + description: "AI Hub", }; export default function RootLayout({ @@ -28,7 +31,12 @@ export default function RootLayout({ return ( - {children} + + + + + {children} + ); diff --git a/components/crisp-chat.tsx b/components/crisp-chat.tsx new file mode 100644 index 0000000..0293e17 --- /dev/null +++ b/components/crisp-chat.tsx @@ -0,0 +1,10 @@ +import { Crisp } from "crisp-sdk-web"; +import { useEffect } from "react"; + +export function CrispChat() { + useEffect(() => { + Crisp.configure("a888eef6-1664-443f-877e-d7507c169ed9"); + }, []); + + return null; +} diff --git a/components/crisp-provider.tsx b/components/crisp-provider.tsx new file mode 100644 index 0000000..cccc7f0 --- /dev/null +++ b/components/crisp-provider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { CrispChat } from "./crisp-chat"; + +export default function CrispProvider() { + return ; +} diff --git a/components/free-counter.tsx b/components/free-counter.tsx index 2c445af..1439d53 100644 --- a/components/free-counter.tsx +++ b/components/free-counter.tsx @@ -1,11 +1,11 @@ import { Zap } from "lucide-react"; import { useEffect, useState } from "react"; -import { useProModal } from "@/app/hooks/use-pro-modal"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { MAX_FREE_COUNTS } from "@/constants"; +import { useProModal } from "@/hooks/use-pro-modal"; export const FreeCounter = ({ isPro = false, diff --git a/components/heading.tsx b/components/heading.tsx index 48c6675..185e516 100644 --- a/components/heading.tsx +++ b/components/heading.tsx @@ -9,7 +9,7 @@ interface HeadingProps { bgColor?: string; } -export default function Heading({ +export function Heading({ title, description, icon: Icon, diff --git a/components/landing-content.tsx b/components/landing-content.tsx new file mode 100644 index 0000000..3460caf --- /dev/null +++ b/components/landing-content.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; + +const Testimonials = [ + { + name: "Alberto", + avatar: "A", + title: "Developer", + description: "This is the best application I've used!", + }, + { + name: "Claudio", + avatar: "C", + title: "Engineer", + description: "This is the best application I've used!", + }, + { + name: "Fernando", + avatar: "F", + title: "Designer", + description: "This is the best application I've used!", + }, + { + name: "Alex", + avatar: "A", + title: "Q.A.", + description: "This is the best application I've used!", + }, + { + name: "Gilberto", + avatar: "G", + title: "Analyst", + description: "This is the best application I've used!", + }, +]; + +export default function LandingContent() { + return ( +
+

+ Testimonials +

+
+ {Testimonials.map((item, index) => ( + + + +
+

{item.name}

+

{item.title}

+
+
+ + {item.description} + +
+
+ ))} +
+
+ ); +} diff --git a/components/landing-hero.tsx b/components/landing-hero.tsx new file mode 100644 index 0000000..f78857e --- /dev/null +++ b/components/landing-hero.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import Link from "next/link"; +import TypewriterComponent from "typewriter-effect"; +import { Button } from "./ui/button"; +export default function LandingHero() { + const { isSignedIn } = useAuth(); + return ( +
+
+

The Best AI Tool for

+
+ +
+
+
+ Create content using AI 10x faster. +
+
+ + + +
+
+ No credit card required. +
+
+ ); +} diff --git a/components/landing-navibar.tsx b/components/landing-navibar.tsx new file mode 100644 index 0000000..4283825 --- /dev/null +++ b/components/landing-navibar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import { Montserrat } from "next/font/google"; +import Image from "next/image"; +import Link from "next/link"; + +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; + +const font = Montserrat({ + weight: "600", + subsets: ["latin"], +}); + +export default function LandingNavbar() { + const { isSignedIn } = useAuth(); + + return ( + + ); +} diff --git a/components/loader.tsx b/components/loader.tsx index 879b497..82c9c15 100644 --- a/components/loader.tsx +++ b/components/loader.tsx @@ -6,7 +6,7 @@ export default function Loader() {
Loading...
-

Genius is thinking...

+

Sinthetic is thinking...

); } diff --git a/components/modal-provider.tsx b/components/modal-provider.tsx new file mode 100644 index 0000000..c264358 --- /dev/null +++ b/components/modal-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { ProModal } from "@/components/pro-modal"; +import { useEffect, useState } from "react"; + +export function ModalProvider() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + <> + + + ); +} diff --git a/components/pro-modal.tsx b/components/pro-modal.tsx index 5d9f442..dcea988 100644 --- a/components/pro-modal.tsx +++ b/components/pro-modal.tsx @@ -17,9 +17,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { tools } from "@/constants"; -// import { useProModal } from "@/hooks/use-pro-modal"; +import { useProModal } from "@/hooks/use-pro-modal"; import { cn } from "@/lib/utils"; -import {useProModal} from "@/app/hooks/use-pro-modal"; export const ProModal = () => { const proModal = useProModal(); @@ -44,7 +43,7 @@ export const ProModal = () => {
- Upgrade to Genius + Upgrade to Synthetic pro diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 1271dde..d758eea 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -60,7 +60,6 @@ const routes = [ label: "Settings", icon: Settings, href: "/settings", - // color: "text-zinc-400", }, ]; @@ -81,7 +80,7 @@ export function Sidebar({ Logo

- Genius + Synthetic

diff --git a/components/subscription-button.tsx b/components/subscription-button.tsx new file mode 100644 index 0000000..288f5bc --- /dev/null +++ b/components/subscription-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import axios from "axios"; +import { Zap } from "lucide-react"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; + +import { Button } from "@/components/ui/button"; + +export const SubscriptionButton = ({ isPro = false }: { isPro: boolean }) => { + const [loading, setLoading] = useState(false); + + const onClick = async () => { + try { + setLoading(true); + + const response = await axios.get("/api/stripe"); + + window.location.href = response.data.url; + } catch (error) { + toast.error("Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/components/toaster-provider.tsx b/components/toaster-provider.tsx new file mode 100644 index 0000000..0f199a1 --- /dev/null +++ b/components/toaster-provider.tsx @@ -0,0 +1,6 @@ +"use client"; +import { Toaster } from "react-hot-toast"; + +export default function ToasterProvider() { + return ; +} diff --git a/constants.ts b/constants.ts index a264af4..071e928 100644 --- a/constants.ts +++ b/constants.ts @@ -1,6 +1,6 @@ import { Code, ImageIcon, MessageSquare, Music, VideoIcon } from "lucide-react"; -export const MAX_FREE_COUNTS = 20; +export const MAX_FREE_COUNTS = 2; export const tools = [ { diff --git a/doc/dashboard.png b/doc/dashboard.png new file mode 100644 index 0000000..7deb4b0 Binary files /dev/null and b/doc/dashboard.png differ diff --git a/doc/generation.png b/doc/generation.png new file mode 100644 index 0000000..3f4d0a0 Binary files /dev/null and b/doc/generation.png differ diff --git a/doc/image.png b/doc/image.png new file mode 100644 index 0000000..b4df3e7 Binary files /dev/null and b/doc/image.png differ diff --git a/doc/land-page.png b/doc/land-page.png new file mode 100644 index 0000000..e8d3a09 Binary files /dev/null and b/doc/land-page.png differ diff --git a/doc/pix.png b/doc/pix.png new file mode 100644 index 0000000..00cbee8 Binary files /dev/null and b/doc/pix.png differ diff --git a/app/hooks/use-pro-modal.ts b/hooks/use-pro-modal.ts similarity index 100% rename from app/hooks/use-pro-modal.ts rename to hooks/use-pro-modal.ts diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..234a12d --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe"; + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2023-10-16", + typescript: true, +}); diff --git a/lib/utils.ts b/lib/utils.ts index e43b884..19cb10a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -6,5 +6,7 @@ export function cn(...inputs: ClassValue[]) { } export function absoluteUrl(path: string) { + console.log("API ADRESS:" + `${process.env.NEXT_PUBLIC_APP_URL}${path}`); + return `${process.env.NEXT_PUBLIC_APP_URL}${path}`; } diff --git a/middleware.ts b/middleware.ts index f6bdbb6..5781643 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,9 +1,9 @@ import { authMiddleware } from "@clerk/nextjs"; export default authMiddleware({ - publicRoutes: ["/", "/api/webhook"], + publicRoutes: ["/", "/api/webhook", "/settings"], }); export const config = { - matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], + matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], }; diff --git a/package-lock.json b/package-lock.json index 4c72587..0c284ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "crisp-sdk-web": "^1.0.22", "lucide-react": "^0.341.0", "next": "14.1.0", "next-intl": "^3.9.1", @@ -32,8 +33,10 @@ "remark-gfm": "^4.0.0", "replicate": "^0.27.1", "server-only": "^0.0.1", + "stripe": "^14.20.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", + "typewriter-effect": "^2.21.0", "zod": "^3.22.4", "zustand": "^4.5.2" }, @@ -2332,7 +2335,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -2600,6 +2602,11 @@ "node": ">= 0.6" } }, + "node_modules/crisp-sdk-web": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.22.tgz", + "integrity": "sha512-5LakqSr638Cv1J/FuM/RhCGVEaHmrbPpEY14h9GdoEUeNF+jzDhV2KwTVVL5hDwTo5Vhc1J+hprUzgqUv9xH4Q==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2689,7 +2696,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -2898,7 +2904,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.4" }, @@ -2910,7 +2915,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -3706,7 +3710,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -3879,7 +3882,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -3920,7 +3922,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -3932,7 +3933,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3944,7 +3944,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -5916,7 +5915,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6223,6 +6221,11 @@ "node": ">=8" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6443,7 +6446,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -6489,6 +6491,20 @@ "node": ">=6.0.0" } }, + "node_modules/qs": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", + "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6516,6 +6532,14 @@ "node": ">=8" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -6572,8 +6596,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-markdown": { "version": "9.0.1", @@ -7021,7 +7044,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", - "dev": true, "dependencies": { "define-data-property": "^1.1.2", "es-errors": "^1.3.0", @@ -7069,12 +7091,11 @@ } }, "node_modules/side-channel": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", - "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", - "dev": true, + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" @@ -7355,6 +7376,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "14.20.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.20.0.tgz", + "integrity": "sha512-+3EP8GSWnKVHNATChhDzwAKk3nqSJKQOf2Q+dMGdgEk2sQXWYoA8GXY0A1TjL0m6895FVNavgvno6+0+6lC+kw==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-to-object": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.5.tgz", @@ -7729,6 +7762,19 @@ "node": ">=14.17" } }, + "node_modules/typewriter-effect": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/typewriter-effect/-/typewriter-effect-2.21.0.tgz", + "integrity": "sha512-Y3VL1fuJpUBj0gS4OTXBLzy1gnYTYaBuVuuO99tGNyTkkub5CXi+b/hsV7Og9fp6HlhogOwWJwgq7iXI5sQlEg==", + "dependencies": { + "prop-types": "^15.8.1", + "raf": "^3.4.1" + }, + "peerDependencies": { + "react": "^17.x || ^18.x", + "react-dom": "^17.x || ^18.x" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index 72709eb..6c5418b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "postinstall": "prisma generate" }, "dependencies": { "@clerk/nextjs": "^4.29.8", @@ -21,6 +22,7 @@ "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "crisp-sdk-web": "^1.0.22", "lucide-react": "^0.341.0", "next": "14.1.0", "next-intl": "^3.9.1", @@ -33,8 +35,10 @@ "remark-gfm": "^4.0.0", "replicate": "^0.27.1", "server-only": "^0.0.1", + "stripe": "^14.20.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", + "typewriter-effect": "^2.21.0", "zod": "^3.22.4", "zustand": "^4.5.2" }, @@ -50,4 +54,4 @@ "tailwindcss": "^3.3.0", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20240305164355_init/migration.sql b/prisma/migrations/20240308225203_init/migration.sql similarity index 100% rename from prisma/migrations/20240305164355_init/migration.sql rename to prisma/migrations/20240308225203_init/migration.sql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11403ae..b36d9d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,6 +8,12 @@ datasource db { relationMode = "prisma" } +// datasource db { +// provider = "postgresql" +// url = env("POSTGRES_PRISMA_URL") // uses connection pooling +// directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection +// } + model UserApiLimit { id String @id @default(cuid()) userId String @unique