commit ef9ccf22d37a5184f958270b42f38156cd723043 Author: rathi Date: Sun Nov 24 20:56:03 2024 -0500 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..910e5d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# Connect to your database (I use Neon. For setup you can checkout: https://youtu.be/ZAjOep8-M-Y?si=L94KHhlbV27y-V_O). +DATABASE_URL="" +DATABASE_URL_UNPOOLED="" + +# Change to your domain when deploying. +APP_URL="http://localhost:3000" + +# Go to: https://generate-secret.vercel.app/32 and copy the generated secret key. +AUTH_SECRET="" + + +# Go to https://github.com/settings/developers and click on OAuth Apps then New OAuth App. Fill in the details as follows: +# 1. Homepage URL: http://localhost:3000 (change to your domain when deploying). +# 2. Authorization callback URL: http://localhost:3000/api/auth/callback/github (change to your domain when deploying). +# 3. Click on Register Application. +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + + +# Go to https://console.cloud.google.com and click on 'Create Project' at the top and do as follows: +# 1. Name your project and click create. +# 2. Go to the search and look for 'APIs and Services', go to it. +# 3. Click on OAuth consent screen on the sidebar, select external and fill out the App Name, Support Email, and Developer Contact Information. You may 'Add Domain' if you have one, else leave it blank. +# 4. Click on Save and Continue until you reach the summary page. +# 5. Click on Credentials on the sidebar, click on 'Create Credentials' and select OAuth client ID. +# 6. Click on 'Web Application' and click on add URI for both Authorized JavaScript Origins and Authorized Redirect URIs. Fill in the details as follows: + +# Authorized JavaScript Origins: http://localhost:3000 (change to your domain when deploying). +# Authorized Redirect URIs: http://localhost:3000/api/auth/callback/google (change to your domain when deploying). + +# 7. Click on Create and copy the Client ID and Client Secret. +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# API can be found at https://resend.com/home in the API section. +RESEND_API_KEY="" + + +# API Keys can be found at https://stripe.com for more setup information you can checkout: https://youtu.be/5hSEEod_BuA?si=377LS1z0ThLcs46T. +STRIPE_API_KEY="" +STRIPE_WEBHOOK_SECRET="" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ce2ca7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.env +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c50384f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "none", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1958d93 --- /dev/null +++ b/README.md @@ -0,0 +1,309 @@ +# Nizzy Starter + +An SaaS (Service as a Software) template starter kit already set up, that requires bare minimum configuration. For more details, watch [Free & Open Source SaaS Template Starter Kit](https://youtu.be/Q6jDdtbkMIU) on YouTube. + +## Tech Stack + +- NextJS 14 +- Prisma +- TailwindCSS + +## Saas Features + +- Simple elegant UI +- Light and dark mode +- Manual authentication and OAuth (Google and GitHub) +- Database integration using Prisma +- Admin analytics page +- Payment system using Stripe +- Email integration with Resend and React Email + +## Getting started + +Here will be a step by step guide of how to setup the SaaS starter kit on your machine. + +### Cloning the repository + +Firstly, you need to clone the repository. You can do it like this: + +```bash +# Using Git +git clone https://github.com/NizarAbiZaher/nizzy-starter.git + +# Using GitHub CLI +gh repo clone NizarAbiZaher/nizzy-starter +``` + +Alternatively, you can also clone it using [GitHub Desktop](https://desktop.github.com/) application. + +### Setup using NPM + +This starter kit currently uses NPM as the package manager, if you use any other package manager follow [this section](#setup-using-different-package-managers). + +Follow the steps below to correctly setup the project on your machine, and ensure everything works as expected. + +First, install the modules and dependencies: + +```bash +npm install +``` + +When that's done, you need to setup your `.env` file. On the root folder of your project you should see a file called `.env.example`. That file already contains a written guide to get the variables you need, but bellow will be the same steps in case you can't find it. + +### Setup using different package managers + +Follow the steps below to correctly setup the project on your machine, and ensure everything works as expected. + +Since you wont use NPM, you need to remove the `package-lock.json` to prevent any possible conflicts. + +Install the modules and dependencies: + +```bash +# Using pnpm +pnpm install + +# Using yarn +yarn install + +# Using bun +bun install +``` + +If you dont use any of these, find the equivalent command for your package manager. + +#### Create the environment variables file + +On the root folder of your project, create a file called `.env` or `.env.local` and paste the following: + +```env +DATABASE_URL="" +DATABASE_URL_UNPOOLED="" +APP_URL="http://localhost:3000" +AUTH_SECRET="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +RESEND_API_KEY="" +STRIPE_API_KEY="" +STRIPE_WEBHOOK_SECRET="" +``` + +You are not required to use quotes your variables, although its recommended because in some cases, due to some special characters, it can mess up the variable. + +#### Connect your database + +You can use any database of your choice, i like to use [Neon](https://neon.tech/). You can watch [this video](https://youtu.be/ZAjOep8-M-Y?si=L94KHhlbV27y-V_O) if you want help related to database configuration. + +If you already have a database prepared and know how to use it, you can just get the Database URL and the Database URL Unpooled. + +Example: + +```env +DATABASE_URL="postgresql://username:password@hostname:port/database_name" +DATABASE_URL_UNPOOLED="postgresql://username:password@hostname:port/database_name" +``` +#### App URL + +Change this value when deploying your application. + +Example: + +```env +APP_URL="https://example.com/" or "http://localhost:3000/" +``` + +#### Auth Secret + +The auth system was made using NextAuth, which required an auth secret for it to work. You can use any Secret generator, such as [https://generate-secret.vercel.app/32](https://generate-secret.vercel.app/32). + +Example: + +```env +AUTH_SECRET="bae23fc1c21a41743058c56a366459a2" +``` + +#### GitHub Client ID and Client Secret + +Go to [GitHub Developers](https://github.com/settings/developers) and click on **OAuth Apps** then **New OAuth App**. Fill in the details as follows: + +1. Homepage URL: http://localhost:3000 (change to your domain when deploying) +2. Authorization callback URL: http://localhost:3000/api/auth/callback/github (replace `localhost:3000` with your domain when deploying) +3. Click on Register Application + +After creating a new OAuth App, copy the required Client ID and Client Secret and populate the respective variables. + +Example: + +```env +GITHUB_CLIENT_ID="abc123def456ghi789jkl" +GITHUB_CLIENT_SECRET="s3cr3tK3yV@lu3!" +``` + +Disclaimer: This was generated as an example from ChatGPT and will not work. + +#### Google Client ID and Client Secret + +Go to [Google Cloud Console](https://console.cloud.google.com) and click on **Create project** at the top and do as follows: + +1. Name your project and click create +2. Go to the search, find and go to **APIs and Services**. +3. Click on **OAuth Consent Screen** on the sidebar, select **External** and fill out the **App Name**, **Support Email**, and **Developer Contact Information**. You may **Add Domain** if you have one, else leave it blank. +4. Click on **Save and Continue** until you reach the summary page. +5. Click on **Credentials** on the sidebar, click on **Create Credentials** and select **OAuth client ID** +6. Click on **Web Application** then on **Add URI** for both **Authorized JavaScript Origins** and **Authorized Redirect URIs**. Fill in the details as follows: + - Authorized JavaScript Origins: http://localhost:3000 (change to your domain when deploying) + - Authorized Redirect URIs: http://localhost:3000/api/auth/callback/google (change to your domain when deploying) + +After creating a new OAuth App, copy the required Client ID and Client Secret and populate the respective variables. + +Example: + +```env +GOOGLE_CLIENT_ID="1234567890-abcde12345fghij67890klmno.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="XyZ_1234567890AbCdEfGhIj" +``` + +#### Resend API Key + +Go to [Resend Website](https://resend.com/home) and on the **API Keys** section create a new API key. Fill the inputs and configure the settings as you wish, then copy the key and populate the respective variable. + +Example: + +```env +RESEND_API_KEY="re_2514c2801e44bed9a3bdf9e094e2ce1c" +``` + +#### Stripe API Key and Webhook Secret + +You can find both at [Stripe](https://stripe.com). For more setup information you can checkout [this video](https://youtu.be/5hSEEod_BuA?si=377LS1z0ThLcs46T). + +Example: + +```env +STRIPE_API_KEY="sk_test_4eC39HqLyjWDarjtT1zdp7dc" +STRIPE_WEBHOOK_SECRET="whsec_1234567890abcdef1234567890abcdef" +``` + +#### Full `.env` file example + +```env +DATABASE_URL="postgresql://username:password@hostname:port/database_name" +DATABASE_URL_UNPOOLED="postgresql://username:password@hostname:port/database_name" +APP_URL="https://example.com/" +AUTH_SECRET="bae23fc1c21a41743058c56a366459a2" +GITHUB_CLIENT_ID="abc123def456ghi789jkl" +GITHUB_CLIENT_SECRET="s3cr3tK3yV@lu3!" +GOOGLE_CLIENT_ID="1234567890-abcde12345fghij67890klmno.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="XyZ_1234567890AbCdEfGhIj" +RESEND_API_KEY="re_2514c2801e44bed9a3bdf9e094e2ce1c" +STRIPE_API_KEY="sk_test_4eC39HqLyjWDarjtT1zdp7dc" +STRIPE_WEBHOOK_SECRET="whsec_1234567890abcdef1234567890abcdef" +``` + +### Generating the Database tables + +Now that the env variables are setup, we need to create the tables in your database in order for the authentication and analytics to work. To do so, run the following command: + +```bash +npm run database +``` + +If you have any trouble in this process, please open an issue or join my discord for help. + +### Running the project + +In order to verify if everything is working properly and to further customize the project to your own SaaS, you need to start a local development server. You can do so using any of the following commands: + +```bash +# Using npm +npm run dev + +# Using pnpm +pnpm run dev + +# Using yarn +yarn run dev + +# Using bun +bun run dev +``` + +It should output something like this: + +```bash + ▲ Next.js 14.2.3 + - Local: http://localhost:3000 + - Environments: .env.local + + ✓ Starting... + ✓ Ready in 2.4s +``` + +If this is the case, you can just **CTRL** + **Click** on http://localhost:3000, or manually opening it on your browser. + +If this is not the case, make sure you didn't miss any of the steps before. If the error persists and you don't know what to do, open an issue [here](https://github.com/NizarAbiZaher/nizzy-starter/issues). + +### That's it! + +You should now be able to edit and change whatever you'd like, if you are having trouble with specific libraries, make sure to check out the respective documentation and properly research about the issue. + +Make sure you remove the Alert at the top if you intend to use this starter kit. To do so go to `app/layout.tsx` and remove the `` component (you may also delete it from `components/alert.tsx`). + +### Theme configuration + +The project is theme based, which means it is based on one color for the entire thing. The theme color is [Vivid blue](https://www.colorhexa.com/1f8eef) and it's defined in HSL. + +You can find the theme colors in `app/globals.css`. To change the main theme color replace the `--primary` with HSL or RBG, without comas seperating them (I'm not sure if it is possible to use other color models, but feel free to try it out). + +Here is how you can do it using CSS variables: + +```css +/* Using HSL */ +--primary: 208 87% 53%; /* Background colors, borders,... */ +--primary-foreground: 0 0% 0%; /* Foreground in buttons */ + +/* Using RBG Decimal */ +--primary: 31 142 239; +--primary-foreground: 0 0 0; + +/* Using RBG Percent */ +--primary: 12.2% 55.7% 93.7%; +--primary-foreground: 0% 0% 0%; +``` + +Here's how you can change the `tailwind.config.ts` file to the color model you want to use: + +```ts +// Using HSL +primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' +}, + +// Using RGB Decimal/Percent +primary: { + DEFAULT: 'rgb(var(--primary))', + foreground: 'rgb(var(--primary-foreground))' +}, +``` + +The `tailwind.config.ts` file doesn't only have the content above, so find the matching code and update accordingly. + +The reason for the colors not being defined like `hsl(208, 87%, 53%)` is because the project uses tailwind for styling, and with the current definition we can control the alpha within the class, for example `bg-primary/20` (this will make the primary color have an alpha value of 0.2). + +If you do change the main color please consider changing the `--primary-foreground` as well, since it is currently a shade of white and it might not look good on the color you chose. + +### Aditional information + +SaaS Starter Kit Author: NizarAbiZaher + +#### Special thanks to all the contributors below: + +- [dpaulos6](https://github.com/dpaulos6) - *README, Theme Config and UI* + +### Socials + +- [YouTube](https://www.youtube.com/@NizzyABI) +- [GitHub](https://github.com/NizarAbiZaher) +- [Discord Community](https://discord.com/invite/nizar) diff --git a/actions/login.ts b/actions/login.ts new file mode 100644 index 0000000..84f8efa --- /dev/null +++ b/actions/login.ts @@ -0,0 +1,62 @@ +'use server' +import * as z from 'zod' +import { LoginSchema } from '@/schemas' +import { signIn } from '@/auth' +import { AuthError } from 'next-auth' +import { generateVerificationToken } from '@/lib/tokens' +import { getUserByEmail } from '@/data/user' +import { sendVerificationEmail } from '@/lib/mail' + +export const login = async (values: z.infer) => { + // Validate fields + const validatedFields = LoginSchema.safeParse(values) + + // If fields are not valid + if (!validatedFields.success) { + return { error: 'Invalid fields' } + } + // If fields are valid + const { email, password } = validatedFields.data + const exisitingUser = await getUserByEmail(email) + + if (!exisitingUser || !exisitingUser.email || !exisitingUser.password) { + return { error: 'Email does not exisit' } + } + + if (!exisitingUser.emailVerified) { + const verificationToken = await generateVerificationToken( + exisitingUser.email + ) + + await sendVerificationEmail( + verificationToken.email, + verificationToken.token + ) + + return { success: 'Confirmation email sent!' } + } + + try { + const result = await signIn('credentials', { + redirect: false, + email, + password + }) + + if (result?.error) { + return { error: result.error } + } + + return { success: 'Logged In!' } + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return { error: 'Invalid credentials' } + default: + return { error: 'Something went wrong' } + } + } + throw error + } +} diff --git a/actions/logout.ts b/actions/logout.ts new file mode 100644 index 0000000..792f8f5 --- /dev/null +++ b/actions/logout.ts @@ -0,0 +1,7 @@ +'use server' + +import { signOut } from '@/auth' + +export const logout = async () => { + await signOut() +} diff --git a/actions/new-password.ts b/actions/new-password.ts new file mode 100644 index 0000000..20d1795 --- /dev/null +++ b/actions/new-password.ts @@ -0,0 +1,70 @@ +'use server' +import * as z from 'zod' +import { NewPasswordSchema } from '@/schemas' +import { getPasswordResetTokenByToken } from '@/data/password-reset-token' +import { getUserByEmail } from '@/data/user' +import bcrypt from 'bcrypt' +import { db } from '@/lib/db' + +export const newPassword = async ( + values: z.infer, + token?: string | null +) => { + // if error, return error + if (!token) { + return { error: 'Token is required' } + } + + // validate fields + const validatedFields = NewPasswordSchema.safeParse(values) + + // if not fields are not valid, return error + if (!validatedFields.success) { + return { error: 'Invalid Fields' } + } + + // extract password + const { password } = validatedFields.data + + // token validation + const existingToken = await getPasswordResetTokenByToken(token) + + // if token not found, return error + if (!existingToken) { + return { error: 'Invalid Token' } + } + + // check if token is expired (if it is less than the date we set, which is an hour) + const hasExpired = new Date(existingToken.expires) < new Date() + + // if expired, return error + if (hasExpired) { + return { error: 'Token has expired' } + } + + // check exisiting user + const existingUser = await getUserByEmail(existingToken.email) + + // if user not found, return error + if (!existingUser) { + return { error: 'Email not found' } + } + // hash password + const hashedPassword = await bcrypt.hash(password, 10) + + // update db + await db.user.update({ + where: { id: existingUser.id }, + data: { + password: hashedPassword + } + }) + + // delete token + await db.passwordResetToken.delete({ + where: { id: existingToken.id } + }) + + // return success message + return { success: 'Password updated successfully' } +} diff --git a/actions/new-verification.ts b/actions/new-verification.ts new file mode 100644 index 0000000..1e9c1c8 --- /dev/null +++ b/actions/new-verification.ts @@ -0,0 +1,41 @@ +'use server' + +import { db } from '@/lib/db' +import { getUserByEmail } from '@/data/user' + +import { getVerificationTokenByToken } from '@/data/verification-token' + +export const newVerification = async (token: string) => { + // if no token, display message + const exisitingToken = await getVerificationTokenByToken(token) + + if (!exisitingToken) { + return { error: 'Token does not exisit!' } + } + // if token has expired, display message + const hasExpired = new Date(exisitingToken.expires) < new Date() + + if (hasExpired) { + return { error: 'Token has expired!' } + } + // if user does not exist, display message + const existingUser = await getUserByEmail(exisitingToken.email) + + if (!existingUser) { + return { error: 'User does not exisit!' } + } + // update email value when they verify + await db.user.update({ + where: { id: existingUser.id }, + data: { + emailVerified: new Date(), + email: exisitingToken.email + } + }) + // delete token + await db.verificationToken.delete({ + where: { id: exisitingToken.id } + }) + + return { success: 'Email verified! Login to continue' } +} diff --git a/actions/register.ts b/actions/register.ts new file mode 100644 index 0000000..c494ab4 --- /dev/null +++ b/actions/register.ts @@ -0,0 +1,39 @@ +'use server' +import * as z from 'zod' +import { RegisterSchema } from '@/schemas' +import bcrypt from 'bcrypt' +import { db } from '@/lib/db' +import { getUserByEmail } from '@/data/user' +import { generateVerificationToken } from '@/lib/tokens' +import { sendVerificationEmail } from '@/lib/mail' + +export const register = async (values: z.infer) => { + const validatedFields = RegisterSchema.safeParse(values) + + if (!validatedFields.success) { + return { error: 'Invalid fields' } + } + + const { name, email, password } = validatedFields.data + const hashedPassword = await bcrypt.hash(password, 10) + + const exisitingUser = await getUserByEmail(email) + + if (exisitingUser) { + return { error: 'Email already exists!' } + } + + await db.user.create({ + data: { + name, + email, + password: hashedPassword + } + }) + + const verificationToken = await generateVerificationToken(email) + + await sendVerificationEmail(verificationToken.email, verificationToken.token) + + return { success: 'Email sent!' } +} diff --git a/actions/reset.ts b/actions/reset.ts new file mode 100644 index 0000000..d1b0baa --- /dev/null +++ b/actions/reset.ts @@ -0,0 +1,32 @@ +'use server' +import * as z from 'zod' +import { getUserByEmail } from '@/data/user' +import { ResetSchema } from '@/schemas' +import { sendPasswordResetEmail } from '@/lib/mail' +import { generatePasswordResetToken } from '@/lib/tokens' + +export const reset = async (values: z.infer) => { + // validate fields + const validatedFields = ResetSchema.safeParse(values) + // check if fields are valid + if (!validatedFields.success) { + return { error: 'Invalid email!' } + } + // extract email + const { email } = validatedFields.data + + // check exisiting user + const existingUser = await getUserByEmail(email) + // if user does not exist + if (!existingUser) { + return { error: 'Email does not exist!' } + } + //send reset email + const passwordResetToken = await generatePasswordResetToken(email) + await sendPasswordResetEmail( + passwordResetToken.email, + passwordResetToken.token + ) + // success message + return { success: 'Reset email sent 📫' } +} diff --git a/app/(root)/(routes)/(auth)/layout.tsx b/app/(root)/(routes)/(auth)/layout.tsx new file mode 100644 index 0000000..7c6b4ad --- /dev/null +++ b/app/(root)/(routes)/(auth)/layout.tsx @@ -0,0 +1,7 @@ +const AuthLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
{children}
+ ) +} + +export default AuthLayout diff --git a/app/(root)/(routes)/(auth)/login/page.tsx b/app/(root)/(routes)/(auth)/login/page.tsx new file mode 100644 index 0000000..daf2fb1 --- /dev/null +++ b/app/(root)/(routes)/(auth)/login/page.tsx @@ -0,0 +1,124 @@ +'use client' +import * as z from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { LoginSchema } from '@/schemas' +import { Input } from '@/components/ui/input' +import { CardWrapper } from '@/components/auth/card-wrapper' +import { Button } from '@/components/ui/button' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { useEffect, useRef, useTransition } from 'react' +import { login } from '@/actions/login' +import toast from 'react-hot-toast' + +export default function Page() { + const searchParams = useSearchParams() + const urlError = + searchParams.get('error') === 'OAuthAccountNotLinked' + ? 'Email already in use with different provider!' + : '' + + const [isPending, startTransition] = useTransition() + const hasDisplayedError = useRef(false) + useEffect(() => { + if (urlError && !hasDisplayedError.current) { + toast.error(urlError) + hasDisplayedError.current = true + } + }, [urlError]) + + const form = useForm>({ + resolver: zodResolver(LoginSchema), + defaultValues: { + email: '', + password: '' + } + }) + + const onSubmit = (values: z.infer) => { + startTransition(() => { + login(values).then((data) => { + if (data?.error) { + toast.error(data.error) + } + if (data?.success) { + toast.success(data.success) + form.reset({ email: '', password: '' }) + window.location.href = '/' + } + }) + }) + } + return ( + +
+ +
+ ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + + )} + /> +
+ +
+ +
+ ) +} diff --git a/app/(root)/(routes)/(auth)/new-password/page.tsx b/app/(root)/(routes)/(auth)/new-password/page.tsx new file mode 100644 index 0000000..10f75b5 --- /dev/null +++ b/app/(root)/(routes)/(auth)/new-password/page.tsx @@ -0,0 +1,67 @@ +'use client' + +import { CardWrapper } from '@/components/auth/card-wrapper' +import { newVerification } from '@/actions/new-verification' +import { useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' + +export default function NewVerificationForm() { + const [error, setError] = useState() + const [success, setSuccess] = useState() + const [hasErrorToastShown, setHasErrorToastShown] = useState(false) + + const searchParams = useSearchParams() + const token = searchParams.get('token') + const router = useRouter() + + const onSubmit = useCallback(() => { + if (!token) { + toast.error('No token provided') + return + } + newVerification(token) + .then((data) => { + if (data?.error) { + setTimeout(() => { + setError(data.error) + }, 500) + } else if (data?.success) { + toast.success(data.success) + setTimeout(() => { + router.push('/login') + }, 100) + } + }) + .catch(() => { + const errorMessage = 'Something went wrong' + setError(errorMessage) + }) + }, [token, router]) + + useEffect(() => { + onSubmit() + }, [onSubmit]) + + useEffect(() => { + if (error && !hasErrorToastShown) { + const timer = setTimeout(() => { + toast.error(error) + setHasErrorToastShown(true) + }, 100) + return () => clearTimeout(timer) // Cleanup the timeout if component unmounts + } + }, [error, hasErrorToastShown]) + + return ( + +
+ {!success && !error &&

Verifying...

} +
+
+ ) +} diff --git a/app/(root)/(routes)/(auth)/new-verification/page.tsx b/app/(root)/(routes)/(auth)/new-verification/page.tsx new file mode 100644 index 0000000..10f75b5 --- /dev/null +++ b/app/(root)/(routes)/(auth)/new-verification/page.tsx @@ -0,0 +1,67 @@ +'use client' + +import { CardWrapper } from '@/components/auth/card-wrapper' +import { newVerification } from '@/actions/new-verification' +import { useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' + +export default function NewVerificationForm() { + const [error, setError] = useState() + const [success, setSuccess] = useState() + const [hasErrorToastShown, setHasErrorToastShown] = useState(false) + + const searchParams = useSearchParams() + const token = searchParams.get('token') + const router = useRouter() + + const onSubmit = useCallback(() => { + if (!token) { + toast.error('No token provided') + return + } + newVerification(token) + .then((data) => { + if (data?.error) { + setTimeout(() => { + setError(data.error) + }, 500) + } else if (data?.success) { + toast.success(data.success) + setTimeout(() => { + router.push('/login') + }, 100) + } + }) + .catch(() => { + const errorMessage = 'Something went wrong' + setError(errorMessage) + }) + }, [token, router]) + + useEffect(() => { + onSubmit() + }, [onSubmit]) + + useEffect(() => { + if (error && !hasErrorToastShown) { + const timer = setTimeout(() => { + toast.error(error) + setHasErrorToastShown(true) + }, 100) + return () => clearTimeout(timer) // Cleanup the timeout if component unmounts + } + }, [error, hasErrorToastShown]) + + return ( + +
+ {!success && !error &&

Verifying...

} +
+
+ ) +} diff --git a/app/(root)/(routes)/(auth)/register/page.tsx b/app/(root)/(routes)/(auth)/register/page.tsx new file mode 100644 index 0000000..d758684 --- /dev/null +++ b/app/(root)/(routes)/(auth)/register/page.tsx @@ -0,0 +1,126 @@ +'use client' +import * as z from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { RegisterSchema } from '@/schemas' +import { Input } from '@/components/ui/input' +import { CardWrapper } from '@/components/auth/card-wrapper' +import { Button } from '@/components/ui/button' +import { useTransition } from 'react' +import { register } from '@/actions/register' +import toast from 'react-hot-toast' + +export default function Page() { + const [isPending, startTransition] = useTransition() + + const form = useForm>({ + resolver: zodResolver(RegisterSchema), + defaultValues: { + email: '', + password: '', + name: '' + } + }) + + const onSubmit = (values: z.infer) => { + startTransition(() => { + register(values).then((data) => { + if (data?.error) { + toast.error(data.error) + } + if (data?.success) { + toast.success(data.success) + form.reset({ email: '', password: '', name: '' }) + } + }) + }) + } + + return ( + +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> +
+
+ +
+ +
+ ) +} diff --git a/app/(root)/(routes)/(auth)/reset/page.tsx b/app/(root)/(routes)/(auth)/reset/page.tsx new file mode 100644 index 0000000..8c33903 --- /dev/null +++ b/app/(root)/(routes)/(auth)/reset/page.tsx @@ -0,0 +1,86 @@ +'use client' +import * as z from 'zod' +import { CardWrapper } from '@/components/auth/card-wrapper' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { ResetSchema } from '@/schemas' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' + +import { useState, useTransition } from 'react' +import { reset } from '@/actions/reset' +import { toast } from 'react-hot-toast' + +export default function ResetForm() { + const [isPending, startTransition] = useTransition() + + const form = useForm>({ + resolver: zodResolver(ResetSchema), + defaultValues: { + email: '' + } + }) + + const onSubmit = (values: z.infer) => { + startTransition(() => { + reset(values).then((data) => { + if (data?.error) { + toast.error(data.error) + } + if (data?.success) { + toast.success(data.success) + form.reset({ email: '' }) + } + }) + }) + } + + return ( + +
+ +
+ ( + + Email + + + + + + )} + /> +
+ + +
+ +
+ ) +} diff --git a/app/(root)/(routes)/customize/_components/bar-chart-mixed.tsx b/app/(root)/(routes)/customize/_components/bar-chart-mixed.tsx new file mode 100644 index 0000000..bf72330 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/bar-chart-mixed.tsx @@ -0,0 +1,84 @@ +'use client' + +import { TrendingUp } from 'lucide-react' +import { Bar, BarChart, XAxis, YAxis } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { browser: 'chrome', visitors: 275, fill: 'var(--color-chrome)' }, + { browser: 'safari', visitors: 200, fill: 'var(--color-safari)' }, + { browser: 'firefox', visitors: 187, fill: 'var(--color-firefox)' }, + { browser: 'edge', visitors: 173, fill: 'var(--color-edge)' }, + { browser: 'other', visitors: 90, fill: 'var(--color-other)' } +] + +const chartConfig = { + visitors: { + label: 'Visitors' + }, + chrome: { + label: 'Chrome', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Safari', + color: 'hsl(var(--chart-2))' + }, + firefox: { + label: 'Firefox', + color: 'hsl(var(--chart-3))' + }, + edge: { + label: 'Edge', + color: 'hsl(var(--chart-4))' + }, + other: { + label: 'Other', + color: 'hsl(var(--chart-5))' + } +} satisfies ChartConfig + +export function BarChartMixed() { + return ( + + + + chartConfig[value as keyof typeof chartConfig]?.label + } + /> + + } + /> + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/bar-chart-multiple.tsx b/app/(root)/(routes)/customize/_components/bar-chart-multiple.tsx new file mode 100644 index 0000000..7d35278 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/bar-chart-multiple.tsx @@ -0,0 +1,61 @@ +'use client' + +import { TrendingUp } from 'lucide-react' +import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { month: 'January', desktop: 186, mobile: 80 }, + { month: 'February', desktop: 305, mobile: 200 }, + { month: 'March', desktop: 237, mobile: 120 }, + { month: 'April', desktop: 73, mobile: 190 }, + { month: 'May', desktop: 209, mobile: 130 }, + { month: 'June', desktop: 214, mobile: 140 } +] + +const chartConfig = { + desktop: { + label: 'Desktop', + color: 'hsl(var(--chart-1))' + }, + mobile: { + label: 'Mobile', + color: 'hsl(var(--chart-2))' + } +} satisfies ChartConfig + +export function BarChartMultiple() { + return ( + + + + value.slice(0, 3)} + /> + } + /> + + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/index.ts b/app/(root)/(routes)/customize/_components/index.ts new file mode 100644 index 0000000..4923967 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/index.ts @@ -0,0 +1,6 @@ +export { BarChartMixed } from './bar-chart-mixed' +export { BarChartMultiple } from './bar-chart-multiple' +export { LineChartMultiple } from './line-chart-multiple' +export { PieChartDonut } from './pie-chart-donut' +export { RadarChartLines } from './radar-chart-lines' +export { RadialChartGrid } from './radial-chart-grid' diff --git a/app/(root)/(routes)/customize/_components/line-chart-multiple.tsx b/app/(root)/(routes)/customize/_components/line-chart-multiple.tsx new file mode 100644 index 0000000..ad64540 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/line-chart-multiple.tsx @@ -0,0 +1,77 @@ +'use client' + +import { TrendingUp } from 'lucide-react' +import { CartesianGrid, Line, LineChart, XAxis } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { month: 'January', desktop: 186, mobile: 80 }, + { month: 'February', desktop: 305, mobile: 200 }, + { month: 'March', desktop: 237, mobile: 120 }, + { month: 'April', desktop: 73, mobile: 190 }, + { month: 'May', desktop: 209, mobile: 130 }, + { month: 'June', desktop: 214, mobile: 140 } +] + +const chartConfig = { + desktop: { + label: 'Desktop', + color: 'hsl(var(--chart-1))' + }, + mobile: { + label: 'Mobile', + color: 'hsl(var(--chart-2))' + } +} satisfies ChartConfig + +export function LineChartMultiple() { + return ( + + + + value.slice(0, 3)} + /> + } /> + + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/pie-chart-donut.tsx b/app/(root)/(routes)/customize/_components/pie-chart-donut.tsx new file mode 100644 index 0000000..043cfea --- /dev/null +++ b/app/(root)/(routes)/customize/_components/pie-chart-donut.tsx @@ -0,0 +1,110 @@ +'use client' + +import * as React from 'react' +import { TrendingUp } from 'lucide-react' +import { Label, Pie, PieChart } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { browser: 'chrome', visitors: 275, fill: 'var(--color-chrome)' }, + { browser: 'safari', visitors: 200, fill: 'var(--color-safari)' }, + { browser: 'firefox', visitors: 287, fill: 'var(--color-firefox)' }, + { browser: 'edge', visitors: 173, fill: 'var(--color-edge)' }, + { browser: 'other', visitors: 190, fill: 'var(--color-other)' } +] + +const chartConfig = { + visitors: { + label: 'Visitors' + }, + chrome: { + label: 'Chrome', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Safari', + color: 'hsl(var(--chart-2))' + }, + firefox: { + label: 'Firefox', + color: 'hsl(var(--chart-3))' + }, + edge: { + label: 'Edge', + color: 'hsl(var(--chart-4))' + }, + other: { + label: 'Other', + color: 'hsl(var(--chart-5))' + } +} satisfies ChartConfig + +export function PieChartDonut() { + const totalVisitors = React.useMemo(() => { + return chartData.reduce((acc, curr) => acc + curr.visitors, 0) + }, []) + + return ( + + + } + /> + + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/radar-chart-lines.tsx b/app/(root)/(routes)/customize/_components/radar-chart-lines.tsx new file mode 100644 index 0000000..eb281ed --- /dev/null +++ b/app/(root)/(routes)/customize/_components/radar-chart-lines.tsx @@ -0,0 +1,47 @@ +'use client' + +import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from 'recharts' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { month: 'January', desktop: 186 }, + { month: 'February', desktop: 285 }, + { month: 'March', desktop: 237 }, + { month: 'April', desktop: 203 }, + { month: 'May', desktop: 209 }, + { month: 'June', desktop: 264 } +] + +const chartConfig = { + desktop: { + label: 'Desktop', + color: 'hsl(var(--chart-1))' + } +} satisfies ChartConfig + +export function RadarChartLines() { + return ( + + + } /> + + + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/radial-chart-grid.tsx b/app/(root)/(routes)/customize/_components/radial-chart-grid.tsx new file mode 100644 index 0000000..183dfb0 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/radial-chart-grid.tsx @@ -0,0 +1,70 @@ +'use client' + +import { TrendingUp } from 'lucide-react' +import { PolarGrid, RadialBar, RadialBarChart } from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' +const chartData = [ + { browser: 'chrome', visitors: 275, fill: 'var(--color-chrome)' }, + { browser: 'safari', visitors: 200, fill: 'var(--color-safari)' }, + { browser: 'firefox', visitors: 187, fill: 'var(--color-firefox)' }, + { browser: 'edge', visitors: 173, fill: 'var(--color-edge)' }, + { browser: 'other', visitors: 90, fill: 'var(--color-other)' } +] + +const chartConfig = { + visitors: { + label: 'Visitors' + }, + chrome: { + label: 'Chrome', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Safari', + color: 'hsl(var(--chart-2))' + }, + firefox: { + label: 'Firefox', + color: 'hsl(var(--chart-3))' + }, + edge: { + label: 'Edge', + color: 'hsl(var(--chart-4))' + }, + other: { + label: 'Other', + color: 'hsl(var(--chart-5))' + } +} satisfies ChartConfig + +export function RadialChartGrid() { + return ( + + + } + /> + + + + + ) +} diff --git a/app/(root)/(routes)/customize/_components/radial-chart-shape.tsx b/app/(root)/(routes)/customize/_components/radial-chart-shape.tsx new file mode 100644 index 0000000..719a040 --- /dev/null +++ b/app/(root)/(routes)/customize/_components/radial-chart-shape.tsx @@ -0,0 +1,89 @@ +'use client' + +import { TrendingUp } from 'lucide-react' +import { + Label, + PolarGrid, + PolarRadiusAxis, + RadialBar, + RadialBarChart +} from 'recharts' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { ChartConfig, ChartContainer } from '@/components/ui/chart' +const chartData = [ + { browser: 'safari', visitors: 1260, fill: 'var(--color-safari)' } +] + +const chartConfig = { + visitors: { + label: 'Visitors' + }, + safari: { + label: 'Safari', + color: 'hsl(var(--chart-2))' + } +} satisfies ChartConfig + +export function RadialChartShape() { + return ( + + + + + + + + + ) +} diff --git a/app/(root)/(routes)/customize/page.tsx b/app/(root)/(routes)/customize/page.tsx new file mode 100644 index 0000000..ba04d1d --- /dev/null +++ b/app/(root)/(routes)/customize/page.tsx @@ -0,0 +1,188 @@ +'use client' +import { ColorPicker } from '@/components/color-picker' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Slider } from '@/components/ui/slider' +import { TriangleAlert } from 'lucide-react' +import React, { useEffect, useState, useTransition } from 'react' +import { + BarChartMixed, + BarChartMultiple, + PieChartDonut, + LineChartMultiple, + RadialChartGrid, + RadarChartLines +} from './_components' +import { Calendar } from '@/components/ui/calendar' +import { DateRange } from 'react-day-picker' +import { RadialChartShape } from './_components/radial-chart-shape' +import { Progress } from '@/components/ui/progress' +import { Switch } from '@/components/ui/switch' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'react-hot-toast' + +export default function () { + const [progress, setProgress] = useState(0) + const [email, setEmail] = useState('') + + useEffect(() => { + const interval = setInterval(() => { + setProgress((prevProgress) => { + const newProgress = (prevProgress + 10) % 101 + return newProgress + }) + }, 1000) + + return () => clearInterval(interval) + }, []) + + const handleSubmit = async (e: any) => { + e.preventDefault() + const email = e.target.email.value + try { + const response = await fetch('/api/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }) + const data = await response.json() + + if (data.message) { + toast.success(data.message) + setEmail('') + } else { + console.error(data, 'ha') + } + } catch (error) { + console.error('Error:', error) + } + } + + return ( +
+
+ + + If you refresh the page, it will reset the colors. Update{' '} + globals.css{' '} + with your new colors. + +
+
+
+
+ Primary color + +
+
+ Primary text color + +
+
+
+ Components +
+ + + + + + + + + Get the Starter Kit + + Receive the Starter Kit for free in your mailbox. + + +
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+ + + + +
+
+
+
+
+
+
+
+ Chart Color 1 + +
+
+ Chart Color 2 + +
+
+ Chart Color 3 + +
+
+ Chart Color 4 + +
+
+ Chart Color 5 + +
+
+
+ Charts +
+ + + +
+ + +
+
+ + +
+
+
+
+
+ ) +} diff --git a/app/(root)/(routes)/dashboard/_components/barchart.tsx b/app/(root)/(routes)/dashboard/_components/barchart.tsx new file mode 100644 index 0000000..c0ff989 --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/barchart.tsx @@ -0,0 +1,81 @@ +'use client' +import { useTheme } from 'next-themes' +import { + BarChart as BarGraph, + ResponsiveContainer, + XAxis, + YAxis, + Bar, + CartesianGrid, + Tooltip, + Legend +} from 'recharts' +import { ChartConfig, ChartContainer } from "@/components/ui/chart" +import { light_theme } from '@/lib/theme-constant' +import { CandlestickChart } from 'lucide-react' +import { ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" + +const chartConfig = { + desktop: { + label: "Desktop", + color: "hsl(var(--primary))", + }, + mobile: { + label: "Mobile", + color: "hsl(var(--primary))", + }, +} satisfies ChartConfig + +export type BarChartProps = { + data: { month: string; total: number }[] +} + +export default function BarChart({ data }: BarChartProps) { + const { theme } = useTheme() + + return ( +
+
+

Sales Data

+ +
+ + + + + + } /> + `$${value}`} + /> + + + + + +
+ ) +} diff --git a/app/(root)/(routes)/dashboard/_components/dashboard-card.tsx b/app/(root)/(routes)/dashboard/_components/dashboard-card.tsx new file mode 100644 index 0000000..7066763 --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/dashboard-card.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils' +import { LucideIcon } from 'lucide-react' +interface DashboardCardProps { + label: string + Icon: LucideIcon + amount: any + description: string +} + +export const DashboardCard = ({ + label, + Icon, + amount, + description +}: DashboardCardProps) => { + return ( +
+ {/* Label & Icon */} +
+

{label}

+ +
+ {/* Amount & Description */} +
+

{amount}

+

{description}

+
+
+ ) +} + +export function DashboardCardContent( + props: React.HTMLAttributes +) { + return ( +
+ ) +} diff --git a/app/(root)/(routes)/dashboard/_components/goal.tsx b/app/(root)/(routes)/dashboard/_components/goal.tsx new file mode 100644 index 0000000..2baa204 --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/goal.tsx @@ -0,0 +1,32 @@ +import { Progress } from '@/components/ui/progress' +import { Target } from 'lucide-react' + +export type GoalDataProps = { + value: number + goal: number +} + +export default function GoalDataCard(props: GoalDataProps) { + return ( +
+
+

Goal Progress

+ +
+
+
+
+ +
+
+
+

Goal: ${props.goal}

+

${Math.round(props.value)} made

+
+
+
+ ) +} diff --git a/app/(root)/(routes)/dashboard/_components/line-graph.tsx b/app/(root)/(routes)/dashboard/_components/line-graph.tsx new file mode 100644 index 0000000..225c719 --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/line-graph.tsx @@ -0,0 +1,93 @@ +'use client' +import { useTheme } from 'next-themes' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + LabelList +} from 'recharts' +import { light_theme } from '@/lib/theme-constant' +import { User } from 'lucide-react' + +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter +} from '@/components/ui/card' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent +} from '@/components/ui/chart' + +export type LineGraphProps = { + data: { month: string; users: number }[] +} + +export default function LineGraph({ data }: LineGraphProps) { + const { theme } = useTheme() + const chartConfig = { + desktop: { + label: 'Desktop', + color: 'hsl(var(--primary))' + }, + mobile: { + label: 'Mobile', + color: 'hsl(var(--primary))' + } + } + + return ( + + + Number Of Users + + + + + + + value.slice(0, 3)} + /> + } + /> + + + + + + + + + ) +} diff --git a/app/(root)/(routes)/dashboard/_components/user-data-card.tsx b/app/(root)/(routes)/dashboard/_components/user-data-card.tsx new file mode 100644 index 0000000..9413f47 --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/user-data-card.tsx @@ -0,0 +1,30 @@ +export type UserDataProps = { + name: string + email: string + image: any + time: string +} + +export default function UserDataCard(props: UserDataProps) { + const defaultImage = '/mesh.avif' + return ( +
+
+ avatar +
+

{props.name}

+
+ {props.email} +
+
+
+

{props.time}

+
+ ) +} diff --git a/app/(root)/(routes)/dashboard/_components/user-purchase-data.tsx b/app/(root)/(routes)/dashboard/_components/user-purchase-data.tsx new file mode 100644 index 0000000..4349caa --- /dev/null +++ b/app/(root)/(routes)/dashboard/_components/user-purchase-data.tsx @@ -0,0 +1,32 @@ +import { CreditCard } from 'lucide-react' + +export type UserPurchaseDataProps = { + name: string + email: string + image: string + saleAmount: string +} + +export default function UserPurchaseDataCard(props: UserPurchaseDataProps) { + const defaultImage = '/mesh.avif' + return ( +
+
+ avatar +
+

{props.name}

+
+ {props.email} +
+
+
+

{props.saleAmount}

+
+ ) +} diff --git a/app/(root)/(routes)/dashboard/layout.tsx b/app/(root)/(routes)/dashboard/layout.tsx new file mode 100644 index 0000000..d959041 --- /dev/null +++ b/app/(root)/(routes)/dashboard/layout.tsx @@ -0,0 +1,9 @@ +const DashboardLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ) +} + +export default DashboardLayout diff --git a/app/(root)/(routes)/dashboard/page.tsx b/app/(root)/(routes)/dashboard/page.tsx new file mode 100644 index 0000000..031c049 --- /dev/null +++ b/app/(root)/(routes)/dashboard/page.tsx @@ -0,0 +1,300 @@ +import { Metadata } from 'next' +import { + Calendar, + CheckCircle2, + Clock, + ListTodo, + Plus, + UserRoundCheck +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { db } from '@/lib/db' + +export const metadata: Metadata = { + title: 'Dashboard', + description: 'Task management and team collaboration dashboard' +} + +export default async function DashboardPage() { + const session = await auth() + + if (!session) { + redirect('/login') + } + + // Fetch tasks (placeholder - implement actual DB queries) + const tasks = [ + { id: 1, title: 'Design new landing page', status: 'In Progress', dueDate: '2023-12-01', progress: 60 }, + { id: 2, title: 'Implement authentication', status: 'Todo', dueDate: '2023-12-05', progress: 0 }, + { id: 3, title: 'Write documentation', status: 'Done', dueDate: '2023-11-30', progress: 100 } + ] + + return ( +
+
+

Dashboard

+
+ +
+
+ + + + Overview + Tasks + Calendar + + + +
+ +
+
+ + Total Tasks +
+
12
+
+
+ +
+
+ + In Progress +
+
4
+
+
+ +
+
+ + Completed +
+
8
+
+
+ +
+
+ + Team Members +
+
6
+
+
+
+ +
+ +
+

Recent Tasks

+
+ {tasks.map(task => ( +
+
+

{task.title}

+

Due: {task.dueDate}

+
+
+ {task.status} +
+
+
+
+
+ ))} +
+
+ + + +
+

Upcoming Deadlines

+
+
+ +
+

Design Review

+

Tomorrow at 2:00 PM

+
+
+
+ +
+

Team Meeting

+

Friday at 10:00 AM

+
+
+
+
+
+
+ + + +
+

All Tasks

+ +
+ +
+ {/* Todo Column */} + +

+ + Todo +

+
+ {tasks.filter(t => t.status === 'Todo').map(task => ( + +
{task.title}
+

Due: {task.dueDate}

+
+
+
+ + ))} +
+
+ + {/* In Progress Column */} + +

+ + In Progress +

+
+ {tasks.filter(t => t.status === 'In Progress').map(task => ( + +
{task.title}
+

Due: {task.dueDate}

+
+
+
+ + ))} +
+
+ + {/* Done Column */} + +

+ + Done +

+
+ {tasks.filter(t => t.status === 'Done').map(task => ( + +
{task.title}
+

Due: {task.dueDate}

+
+
+
+ + ))} +
+
+
+ + + +
+

Calendar

+ +
+ +
+ +
+ {/* Today's Schedule */} +
+

Today's Schedule

+
+
+
09:00 AM
+
+ +
Team Standup
+

Daily team sync meeting

+
+
+
+
+
02:00 PM
+
+ +
Design Review
+

Review new landing page design

+
+
+
+
+
04:30 PM
+
+ +
Sprint Planning
+

Plan next sprint tasks

+
+
+
+
+
+ + {/* Upcoming Events */} +
+

Upcoming Events

+
+
+
Tomorrow
+
+ +
Client Meeting
+

10:00 AM - Project update discussion

+
+
+
+
+
Friday
+
+ +
Team Building
+

02:00 PM - Virtual team activity

+
+
+
+
+
+
+
+
+
+ +
+ ) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..35e9719 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/auth' diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..aa27490 --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,64 @@ +import { auth } from '@/auth' +import { db } from '@/lib/db' +import { stripe } from '@/lib/stripe' +import { NextResponse } from 'next/server' + +export async function POST(req: Request) { + try { + const user = await auth() + + if (!user || !user.user.id) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + const userSubscription = await db.userSubscription.findUnique({ + where: { + userId: user.user.id + } + }) + + if (userSubscription && userSubscription.stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: userSubscription.stripeCustomerId, + return_url: process.env.APP_URL + }) + + return new NextResponse(JSON.stringify({ url: stripeSession.url })) + } + + const stripeSession = await stripe.checkout.sessions.create({ + success_url: process.env.APP_URL, + cancel_url: process.env.APP_URL, + payment_method_types: ['card'], + + mode: 'subscription', + billing_address_collection: 'auto', + customer_email: user?.user.email!, + + line_items: [ + { + price_data: { + currency: 'USD', + product_data: { + name: 'Your SaaS Subscription Name', + description: 'Saas Subscription Description' + }, + // cost (change this to the price of your product) + unit_amount: 899, + recurring: { + interval: 'month' + } + }, + quantity: 1 + } + ], + metadata: { + userId: user.user.id + } + }) + return new NextResponse(JSON.stringify({ url: stripeSession.url })) + } catch (error) { + console.log('[STRIPE_GET]', error) + return new NextResponse('Internal Error', { status: 500 }) + } +} diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts new file mode 100644 index 0000000..33acf78 --- /dev/null +++ b/app/api/emails/route.ts @@ -0,0 +1,47 @@ +import { Resend } from "resend" +import RepoEmail from "@/components/email/email"; +import { render } from "@react-email/render" +import { NextResponse } from 'next/server'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function POST(request: Request) { + try { + const { email } = await request.json(); + + const { data, error } = await resend.emails.send({ + from: "Nizzy ", + to: [email], + subject: "Nizzy Starter Kit", + html: render(RepoEmail()), + }); + + if (error) { + return new NextResponse(JSON.stringify(error), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + await resend.contacts.create({ + email: email, + audienceId: process.env.RESEND_AUDIENCE as string + }); + + return new NextResponse(JSON.stringify({ message: "Email sent successfully" }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error:any) { + return new NextResponse(JSON.stringify({ error: error.message }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }); + } +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..1aee918 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,96 @@ +import Stripe from 'stripe' +import { headers } from 'next/headers' +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +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 + + // checkout.session.completed is sent after the initial purchase of the subscription from the checkout page + if (event.type === 'checkout.session.completed') { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + if (!session?.metadata?.userId) { + return new NextResponse('No user id found', { status: 400 }) + } + + await db.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) + } + }) + } + + // invoice.payment_succeeded is sent on subscription renewals + if (event.type === "invoice.payment_succeeded") { + // note: sometimes the subscription we get back doesn't have the up to date current_period_end + // which is why we also need to listen for customer.subscription.updated + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + await db.userSubscription.update({ + where: { + stripeSubscriptionId: subscription.id + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000) + } + }) + } + + // customer.subscription.updated is fired when their subscription end date changes + if (event.type === 'customer.subscription.updated') { + const subscriptionId = event.data.object.id as string; + + await db.userSubscription.update({ + where: { + stripeSubscriptionId: subscriptionId + }, + data: { + stripeCurrentPeriodEnd: new Date(event.data.object.current_period_end * 1000) + } + }) + } + + // invoice.payment_failed if the renewal fails + if (event.type === 'invoice.payment_failed') { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ) + + await db.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/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..d811381 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..f41b71f --- /dev/null +++ b/app/globals.css @@ -0,0 +1,88 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, + body { + @apply bg-background; + } + + :root { + /* Adjust these to change the theme's color */ + --primary: 234 88% 74%; + --primary-foreground: 0 0% 0%; + + /* Adjust these to change the chart's colors */ + --chart-1: 208 87% 53%; + --chart-2: 301 56% 56%; + --chart-3: 99 55% 51%; + --chart-4: 56 70% 54%; + --chart-5: 0 60% 57%; + + --background: 0 0% 100%; + --background-hover: 0 0% 95%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + } + + .dark { + --background: 249 11% 12%; + --background-hover: 0 0% 13%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } + + * { + @apply border-border; + } +} + +html { + scroll-behavior: smooth; +} + +body { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; +} + +main { + flex: 1 0 0; +} + +.disable-transitions * { + transition-property: background-color, color !important; + transition-duration: 0s !important; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..27bef69 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' +import { ThemeProvider } from '@/components/ui/theme-provider' +import { Navbar } from '@/components/navbar' +import { ToastProvider } from '@/components/providers/toaster-provider' +import { SessionProvider } from 'next-auth/react' +import { auth } from '@/auth' +import { AlertDemo } from '@/components/alert' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Nizzy-Starter', + description: 'The best SaaS starter kit on the web 🌎 🚀 HAHA' +} + +export default async function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode +}>) { + const session = await auth() + return ( + + + + + + + + {children} + + + + + ) +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..2a5160f --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,15 @@ +import { Error404 } from '@/components/icons' + +export default function NotFound() { + return ( +
+
+ +
+ Error 404 +
+ Page not found!!! +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..91772c3 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,77 @@ +import { Footer } from '@/components/footer' +import { Header } from '@/components/header' +import { Button } from '@/components/ui/button' + +export default function Home() { + return ( + <> +
+
+
+ +
+ {/* Hero Section */} +
+

+ Think, plan, and track + all in one place +

+

+ Efficiently manage your tasks and boost productivity. +

+
+ +
+
+ + {/* Decorative Elements */} +
+ {/* Yellow Note */} +
+
+

Take notes to keep track of crucial details, and accomplish more tasks with ease.

+
+
+ + {/* Task List */} +
+
+

Today's tasks

+
+
+
+
+
+ 60% +
+
+
+
+
+ 33% +
+
+
+
+ + {/* Integrations */} +
+
+

100+ Integrations

+
+
+
+
+
+
+
+
+
+
+
+
+ + ) +} diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..83faab5 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,42 @@ +import bcrypt from 'bcryptjs' +import type { NextAuthConfig } from 'next-auth' +import Credentials from 'next-auth/providers/credentials' + +import Github from 'next-auth/providers/github' +import Google from 'next-auth/providers/google' +import { LoginSchema } from '@/schemas' +import { getUserByEmail } from '@/data/user' + +export default { + providers: [ + // OAuth authentication providers... the raw data is found at /api/auth/providers + Github({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET + }), + Google({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET + }), + + Credentials({ + async authorize(credentials) { + const validatedFields = await LoginSchema.safeParse(credentials) + + if (validatedFields.success) { + const { email, password } = validatedFields.data + + const user = await getUserByEmail(email) + // by no password I mean that the user is using a social login (Google, Github, etc.) + if (!user || !user.password) return null + + // check if passwords match + const passwordsMatch = await bcrypt.compare(password, user.password) + // if the passwords match, return the user + if (passwordsMatch) return user + } + return null + } + }) + ] +} satisfies NextAuthConfig diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..dfee790 --- /dev/null +++ b/auth.ts @@ -0,0 +1,76 @@ +import NextAuth from 'next-auth' +import { PrismaAdapter } from '@auth/prisma-adapter' +import { UserRole } from '@prisma/client' +import { getUserById } from '@/data/user' +import { db } from '@/lib/db' +import authConfig from '@/auth.config' + +// auth +export const { + handlers: { GET, POST }, + + auth, // This auth thing helps us get user info such as for display certain content for them and specific data + signIn, + signOut +} = NextAuth({ + // if there is an error, redirect to this page + pages: { + signIn: '/login', + error: '/error' + }, + // events to get emailverfiied if the user used Oauth + events: { + async linkAccount({ user }) { + await db.user.update({ + where: { id: user.id }, + data: { emailVerified: new Date() } + }) + } + }, + // Callbacks allow us to customuzie the auth process such as who has access to what, get ID, and block users. + callbacks: { + // sign in + async signIn({ user, account }) { + // Allow OAuth without verification + if (account?.provider !== 'credentials') return true + + // get exisiting user & restrict signin if they have not verified their email + const exisitingUser = await getUserById(user.id ?? '') + + if (!exisitingUser?.emailVerified) return false + + return true + }, + // token & session + async session({ session, token }) { + // if they have an id (sub) and user has been created, return it + if (token.sub && session.user) { + session.user.id = token.sub + } + + // if they have a role and user has been created, return it + if (token.role && session.user) { + session.user.role = token.role as UserRole + } + + return session + }, + + // jwt + async jwt({ token }) { + // fetch user + if (!token.sub) return token + + const exisitingUser = await getUserById(token.sub) + + if (!exisitingUser) return token + + token.role = exisitingUser.role + return token + } + // session userId + }, + adapter: PrismaAdapter(db), + session: { strategy: 'jwt' }, + ...authConfig +}) diff --git a/components.json b/components.json new file mode 100644 index 0000000..fd5076f --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/alert.tsx b/components/alert.tsx new file mode 100644 index 0000000..a36b7c8 --- /dev/null +++ b/components/alert.tsx @@ -0,0 +1,104 @@ +'use client' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Palette, RocketIcon } from 'lucide-react' +import { Button } from './ui/button' +import Link from 'next/link' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'react-hot-toast' +import { useState, useTransition } from 'react' +import { Form } from '@/components/ui/form' + +export function AlertDemo() { + const [email, setEmail] = useState('') + const handleSubmit = async (e: any) => { + e.preventDefault() + const email = e.target.email.value + try { + const response = await fetch('/api/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }) + const data = await response.json() + + if (data.message) { + toast.success(data.message) + setEmail('') + } else { + console.error(data, 'ha') + } + } catch (error) { + console.error('Error:', error) + } + } + + return ( +
+ +
+ + + Heads up! + + +

This is a demo. You can get the github repository

+ + +

+ here +

+
+ + + Starter Kit + + Enter your email to get the starterkit link in your inbox + + + +
+ + setEmail(e.target.value)} + name="email" + id="email" + type="email" + required + placeholder="tylerdurder@gmail.com" + className="mt-3" + value={email} + /> + + + +
+
+
+
+
+ + + +
+
+ ) +} diff --git a/components/auth/back-button.tsx b/components/auth/back-button.tsx new file mode 100644 index 0000000..fae9f1b --- /dev/null +++ b/components/auth/back-button.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Button } from '@/components/ui/button' +import Link from 'next/link' + +interface BackButtonProps { + label: string + href: string +} + +export const BackButton = ({ label, href }: BackButtonProps) => { + return ( + + ) +} diff --git a/components/auth/card-wrapper.tsx b/components/auth/card-wrapper.tsx new file mode 100644 index 0000000..f593cc5 --- /dev/null +++ b/components/auth/card-wrapper.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Header } from '@/components/auth/header' +import { Social } from '@/components/auth/social' +import { BackButton } from '@/components/auth/back-button' +import { Card, CardFooter, CardHeader } from '@/components/ui/card' + +interface CardWrapperProps { + children: React.ReactNode + headerTitle: string + backButtonLabel: string + backButtonHref: string + showSocial?: boolean +} + +export const CardWrapper = ({ + children, + headerTitle, + backButtonLabel, + backButtonHref, + showSocial +}: CardWrapperProps) => { + return ( + + +
+ + +
{children}
+ + {showSocial && ( +
+ +
+ )} + + + + + + ) +} diff --git a/components/auth/header.tsx b/components/auth/header.tsx new file mode 100644 index 0000000..1305b8c --- /dev/null +++ b/components/auth/header.tsx @@ -0,0 +1,11 @@ +interface HeaderProps { + title: string +} + +export const Header = ({ title }: HeaderProps) => { + return ( +
+

{title}

+
+ ) +} diff --git a/components/auth/social.tsx b/components/auth/social.tsx new file mode 100644 index 0000000..786016f --- /dev/null +++ b/components/auth/social.tsx @@ -0,0 +1,29 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { signIn } from 'next-auth/react' +import { FaGithub, FaGoogle } from 'react-icons/fa' + +export const Social = () => { + const onClick = (provider: 'google' | 'github') => { + signIn(provider, { + callbackUrl: '/' + }) + } + return ( +
+ + +
+ ) +} diff --git a/components/color-picker.tsx b/components/color-picker.tsx new file mode 100644 index 0000000..96422ef --- /dev/null +++ b/components/color-picker.tsx @@ -0,0 +1,109 @@ +'use client' +import { useState, useEffect, useRef } from 'react' +import { HslColorPicker, HslColor } from 'react-colorful' +import { CodeBlock } from 'react-code-block' +import { themes } from 'prism-react-renderer' +import { useCopyToClipboard } from 'react-use' + +interface HslColorType { + h: number + s: number + l: number +} + +interface ColorPickerProps { + variable: string +} + +export function ColorPicker({ variable }: ColorPickerProps) { + const [color, setColor] = useState({ h: 0, s: 100, l: 50 }) + const mainDivRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + useEffect(() => { + const rootStyles = getComputedStyle(document.documentElement) + const primaryColor = rootStyles.getPropertyValue(variable).trim() + + const [h, s, l] = primaryColor.split(' ').map((value) => { + if (value.endsWith('%')) { + return parseFloat(value) + } + return parseFloat(value) + }) + + setColor({ h, s, l }) + }, [variable]) + + const handleColorChange = (newColor: HslColor) => { + const fixedColor = `${newColor.h} ${newColor.s}% ${newColor.l}%` + setColor(newColor) + document.documentElement.style.setProperty(variable, fixedColor) + } + + const handleMouseDown = (event: MouseEvent) => { + if ( + mainDivRef.current && + mainDivRef.current.contains(event.target as Node) + ) { + setIsDragging(true) + document.documentElement.classList.add('disable-transitions') + } + } + + const handleMouseUp = () => { + if (isDragging) { + setIsDragging(false) + document.documentElement.classList.remove('disable-transitions') + } + } + + useEffect(() => { + window.addEventListener('mousedown', handleMouseDown) + window.addEventListener('mouseup', handleMouseUp) + + return () => { + window.removeEventListener('mousedown', handleMouseDown) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [isDragging]) + + const [state, copyToClipboard] = useCopyToClipboard() + + const copyCode = () => { + copyToClipboard(`${variable}: ${color.h} ${color.s}% ${color.l}%;`) + setTimeout(() => { + copyToClipboard('') + }, 2500) + } + + return ( +
+ + +
+ + + globals.css + + + + + + +
+
+
+ ) +} diff --git a/components/email/email.tsx b/components/email/email.tsx new file mode 100644 index 0000000..40b28f8 --- /dev/null +++ b/components/email/email.tsx @@ -0,0 +1,106 @@ +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Text, + } from "@react-email/components"; + + + import * as React from "react"; + + + + const baseUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : ""; + + export const RepoEmail = () => ( + + + + Your Free SaaS Starter Kit + + + + + Hey!! + + Welcome to the Nizzy Starter Kit!! The free (and better) SaaS Starter Kit. + + + Below is the link to the github repository where you can find the starter kit. + + + Enjoy building your next project with it! + +
+ +
+ + Best, +
+ Nizzy +
+
+ + 470 Noor Ave STE B #1148, Ottawa, Canada 94080 + +
+ + + ); + + export default RepoEmail; + + const main = { + backgroundColor: "#ffffff", + fontFamily: + 'Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', + }; + + const container = { + margin: "0 auto", + padding: "20px 0 48px", + }; + + const logo = { + margin: "0 auto", + }; + + const paragraph = { + fontSize: "16px", + lineHeight: "26px", + color: 'black' + }; + + const btnContainer = { + textAlign: "center" as const, + }; + + const button = { + backgroundColor: "#1F8EEF", + borderRadius: "6px", + color: "#fff", + fontSize: "16px", + textDecoration: "none", + textAlign: "center" as const, + display: "block", + padding: "12px", + }; + + const hr = { + borderColor: "#cccccc", + margin: "20px 0", + }; + + const footer = { + color: "#8898aa", + fontSize: "12px", + }; \ No newline at end of file diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..7ad7080 --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' +import { Logo } from '@/components/logo' + +export const Footer = () => { + return ( +
+
+
+ + + + + © 2024 YourCompany. All rights reserved. + +
+
+
+ ) +} diff --git a/components/header.tsx b/components/header.tsx new file mode 100644 index 0000000..8e2ddc1 --- /dev/null +++ b/components/header.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Sparkles } from 'lucide-react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' + +const IframeWithSkeleton = () => { + const [iframeLoaded, setIframeLoaded] = useState(false); + + useEffect(() => { + const iframe = document.getElementById('youtube-iframe') as HTMLIFrameElement; + if (iframe) { + const handleIframeLoad = () => { + setIframeLoaded(true); + }; + + iframe.addEventListener('load', handleIframeLoad); + + return () => { + iframe.removeEventListener('load', handleIframeLoad); + }; + } + }, []); + + return ( + <> + {!iframeLoaded && } + + + ); +}; + +export const Header = () => { + return ( +
+
+
+

+ Clone. Build. Ship. +

+

+ Build your SaaS faster with our fully customizable template. +

+
+ + + +
+
+
+ +
+
+
+ ) +} diff --git a/components/icons/index.tsx b/components/icons/index.tsx new file mode 100644 index 0000000..e00aad8 --- /dev/null +++ b/components/icons/index.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import type { SVGProps } from 'react' + +export function StripeIcon(props: SVGProps) { + return ( + + + + + + + ) +} + +export function ResendIcon(props: SVGProps) { + return ( + + + + ) +} + +export function TailwindcssIcon(props: SVGProps) { + return ( + + + + ) +} + +export function NextjsIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + ) +} + +export function Error404(props: SVGProps) { + return ( + + + + ) +} diff --git a/components/languages.tsx b/components/languages.tsx new file mode 100644 index 0000000..e9e2f3d --- /dev/null +++ b/components/languages.tsx @@ -0,0 +1,46 @@ +'use client' +import { NextjsIcon, ResendIcon, StripeIcon, TailwindcssIcon } from './icons' +import Marquee from 'react-fast-marquee' + +const languages = [ + { + label: 'Stripe', + icon: StripeIcon, + className: 'w-36 h-auto aspect-square' + }, + { + label: 'Resend', + icon: ResendIcon, + className: 'w-36 p-5 h-auto aspect-square' + }, + { + label: 'TailwindCSS', + icon: TailwindcssIcon, + className: 'w-48 px-5 h-auto aspect-square' + }, + { + label: 'NextJS', + icon: NextjsIcon, + className: 'w-36 p-5 h-auto aspect-square' + } +] + +export function Language() { + return ( +
+
+
+ + {languages.map((language, i) => ( + + ))} + +
+ ) +} diff --git a/components/logo.tsx b/components/logo.tsx new file mode 100644 index 0000000..0ed0c95 --- /dev/null +++ b/components/logo.tsx @@ -0,0 +1,12 @@ +import { Squircle } from 'lucide-react' + +export const Logo = () => { + return ( +
+ + + YourCompany + +
+ ) +} diff --git a/components/mobile-sidebar.tsx b/components/mobile-sidebar.tsx new file mode 100644 index 0000000..86a9112 --- /dev/null +++ b/components/mobile-sidebar.tsx @@ -0,0 +1,23 @@ +'use client' +import { PanelRightClose } from 'lucide-react' +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' +import { Sidebar } from '@/components/sidebar' +import { useState } from 'react' + +export const MobileSidebar = () => { + const [open, setOpen] = useState(false) + + const openSidebar = () => setOpen(true) + const closeSidebar = () => setOpen(false) + + return ( + + + + + + + + + ) +} diff --git a/components/mode-toggle.tsx b/components/mode-toggle.tsx new file mode 100644 index 0000000..9023ecb --- /dev/null +++ b/components/mode-toggle.tsx @@ -0,0 +1,39 @@ +'use client' + +import * as React from 'react' +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ) +} diff --git a/components/navbar.tsx b/components/navbar.tsx new file mode 100644 index 0000000..555b0c4 --- /dev/null +++ b/components/navbar.tsx @@ -0,0 +1,55 @@ +import Link from 'next/link' +import { ModeToggle } from '@/components/mode-toggle' +import Image from 'next/image' +import { UserButton } from '@/components/user-button' +import { MobileSidebar } from '@/components/mobile-sidebar' +import { Logo } from '@/components/logo' + +export const navPages = [ + { + title: 'Dashboard', + link: '/dashboard' + }, + { + title: 'Pricing', + link: '/#pricing' + }, + { + title: 'Items', + link: '/#items' + } +] + +export const Navbar = () => { + return ( + + ) +} diff --git a/components/pricing-card.tsx b/components/pricing-card.tsx new file mode 100644 index 0000000..2d615e0 --- /dev/null +++ b/components/pricing-card.tsx @@ -0,0 +1,154 @@ +'use client' +import { Check, Sparkle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import axios from 'axios' +import { useCurrentUser } from '@/hooks/use-current-user' +import { useRouter } from 'next/navigation' + +// Update Tiers Here +export const tiers = [ + { + name: 'Your Competitor Name', + price: '18.99', + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + 'Feature 6', + 'Feature 7', + 'Feature 8', + 'Feature 9', + 'Feature 10' + ], + cta: 'Get Started', + yourProduct: false + }, + { + name: 'Your Product Name', + priceBefore: '$19.99', + price: '8.99', + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + 'Feature 6', + 'Feature 7', + 'Feature 8', + 'Feature 9', + 'Feature 10' + ], + cta: 'Get Started', + yourProduct: true + } +] + +export const PricingCard = () => { + const [isLoading, setIsLoading] = useState(false) + const session = useCurrentUser() + + const router = useRouter(); + + const onClick = async () => { + if (!session) { + toast('👇 Sign in to purchase!') + router.push('/login') + return + } + try { + setIsLoading(true) + const response = await axios.post('/api/checkout') + window.location.href = response.data.url + } catch (error) { + toast.error('An error occured! Please try again.') + } finally { + setIsLoading(false) + } + } + return ( +
+ {/* Title */} +
+

+ Pricing +

+

+ Describe your product / service here that will impress the user & want + them to buy the product +

+
+ {/* Pricing Card Display */} +
+ {tiers.map((tier) => ( +
+ {tier.yourProduct && ( +
+ Popular +
+ )} + {/* Pricing */} +
+

+ {tier.name} +

+
+ {tier.priceBefore ? ( + + {tier.priceBefore} + + ) : null} + ${tier.price} /month +
+
    + {tier.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ {/* Button */} +
+ +
+
+ ))} +
+
+ ) +} diff --git a/components/providers/toaster-provider.tsx b/components/providers/toaster-provider.tsx new file mode 100644 index 0000000..b46f7e4 --- /dev/null +++ b/components/providers/toaster-provider.tsx @@ -0,0 +1,6 @@ +'use client' +import { Toaster } from 'react-hot-toast' + +export const ToastProvider = () => { + return +} diff --git a/components/purchase-button.tsx b/components/purchase-button.tsx new file mode 100644 index 0000000..c79231f --- /dev/null +++ b/components/purchase-button.tsx @@ -0,0 +1,25 @@ +'use client' +import axios from 'axios' +import { useState } from 'react' +import toast from 'react-hot-toast' +import { Button } from '@/components/ui/button' + +export const PurchaseButton = () => { + const [isLoading, setIsLoading] = useState(false) + const onClick = async () => { + try { + setIsLoading(true) + const response = await axios.post('/api/checkout') + window.location.href = response.data.url + } catch (error) { + toast.error('An error occurred. Please try again.') + } finally { + setIsLoading(false) + } + } + return ( + + ) +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx new file mode 100644 index 0000000..e868721 --- /dev/null +++ b/components/sidebar.tsx @@ -0,0 +1,130 @@ +import { cn } from '@/lib/utils' +import Link from 'next/link' +import { useCurrentUser } from '@/hooks/use-current-user' +import { signOut } from 'next-auth/react' +import Image from 'next/image' +import { ModeToggle } from '@/components/mode-toggle' +import { Logo } from '@/components/logo' + +const sidebarPages = [ + { + link: '/', + title: 'Home' + }, + { + link: '#profile', + title: 'Profile' + }, + { + link: '#purchases', + title: 'Purchases' + } +] + +const socials = [ + { + link: 'https://github.com/NizarAbiZaher', + title: 'Github' + }, + { + link: 'https://www.youtube.com/@NizzyABI', + title: 'Youtube' + }, + { + link: 'https://twitter.com/NizarAbiZaher', + title: 'Twitter' + }, + { + link: 'https://www.linkedin.com/in/nizarabizaher/', + title: 'Tiktok' + }, + { + link: 'https://www.instagram.com/nizarabizaher/', + title: 'Instagram' + }, + { + link: 'https://discord.gg/nizar', + title: 'Discord' + } +] + +interface SidebarProps { + closeSidebar?: () => void +} + +export const Sidebar = ({ closeSidebar }: SidebarProps) => { + const session = useCurrentUser() + const Logout = () => { + signOut() + } + return ( +
+ + + +
+ +
+
+
+
+

Main

+ {sidebarPages.map((page) => ( + +
+

{page.title}

+
+ + ))} +
+
+

Socials

+ {socials.map((page) => ( + +
+

{page.title}

+
+ + ))} +
+ {session ? ( + { + Logout() + closeSidebar && closeSidebar() + }} + > + Logout + + ) : ( + +
+

Sign Up

+
+ + )} +
+
+
+ ) +} diff --git a/components/testimonials.tsx b/components/testimonials.tsx new file mode 100644 index 0000000..e9a1b1e --- /dev/null +++ b/components/testimonials.tsx @@ -0,0 +1,110 @@ +'use client' + +import { Card, CardContent, CardTitle } from '@/components/ui/card' +import { Avatar, AvatarImage } from '@/components/ui/avatar' +import AvatarCircles from '@/components/ui/user-avatar-card' + +export const Testimonials = () => { + // Add or remove testimonials here following this format + const testimonials = [ + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + }, + { + name: 'John Doe', + avatar: '/testimonials/john-doe.jpg', + message: + 'Write customer / user testimonials here. Please make sure it is a real one & not a fake one. You can add as many as you want. In fact, the more the better since people like to see what others are saying about your product or service.' + } + ] + + return ( +
+ {/* Section Title */} +
+

+ Testimonials +

+

+ Describe your product / service here that will impress the user & want + them to buy the product +

+ +
+ {/* Testimonials Card*/} +
+
+ {testimonials.map((testimonial, i) => ( + + +
+ + + + + + {testimonial.name} + +
+

+ "{testimonial.message}" +

+
+
+ ))} +
+
+
+ ) +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..36808c0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full border-b p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..4ea9b93 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..022ca38 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none disabled:text-muted-foreground disabled:bg-foreground/15 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:none', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/80', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:border-primary/25 hover:bg-primary/10 hover:text-primary', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-primary/10 hover:text-primary', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..ac405c5 --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,67 @@ +'use client' + +import * as React from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { DayPicker } from 'react-day-picker' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => + }} + {...props} + /> + ) +} +Calendar.displayName = 'Calendar' + +export { Calendar } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..4902b39 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..2456876 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +