mirror of
https://github.com/harivansh-afk/Saas-Teamspace.git
synced 2026-04-15 03:00:47 +00:00
initial commit
This commit is contained in:
commit
ef9ccf22d3
133 changed files with 20802 additions and 0 deletions
41
.env.example
Normal file
41
.env.example
Normal file
|
|
@ -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=""
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
309
README.md
Normal file
309
README.md
Normal file
|
|
@ -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 `<AlertDemo />` 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)
|
||||
62
actions/login.ts
Normal file
62
actions/login.ts
Normal file
|
|
@ -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<typeof LoginSchema>) => {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
7
actions/logout.ts
Normal file
7
actions/logout.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
'use server'
|
||||
|
||||
import { signOut } from '@/auth'
|
||||
|
||||
export const logout = async () => {
|
||||
await signOut()
|
||||
}
|
||||
70
actions/new-password.ts
Normal file
70
actions/new-password.ts
Normal file
|
|
@ -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<typeof NewPasswordSchema>,
|
||||
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' }
|
||||
}
|
||||
41
actions/new-verification.ts
Normal file
41
actions/new-verification.ts
Normal file
|
|
@ -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' }
|
||||
}
|
||||
39
actions/register.ts
Normal file
39
actions/register.ts
Normal file
|
|
@ -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<typeof RegisterSchema>) => {
|
||||
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!' }
|
||||
}
|
||||
32
actions/reset.ts
Normal file
32
actions/reset.ts
Normal file
|
|
@ -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<typeof ResetSchema>) => {
|
||||
// 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 📫' }
|
||||
}
|
||||
7
app/(root)/(routes)/(auth)/layout.tsx
Normal file
7
app/(root)/(routes)/(auth)/layout.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const AuthLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<main className="w-full flex items-center justify-center">{children}</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthLayout
|
||||
124
app/(root)/(routes)/(auth)/login/page.tsx
Normal file
124
app/(root)/(routes)/(auth)/login/page.tsx
Normal file
|
|
@ -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<z.infer<typeof LoginSchema>>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = (values: z.infer<typeof LoginSchema>) => {
|
||||
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 (
|
||||
<CardWrapper
|
||||
headerTitle="Login"
|
||||
backButtonLabel="Don't have an account?"
|
||||
backButtonHref="/register"
|
||||
showSocial
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="tylerdurden@gmail.com"
|
||||
disabled={isPending}
|
||||
type="email"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="••••••••"
|
||||
disabled={isPending}
|
||||
type="password"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="px-0 text-blue-500"
|
||||
>
|
||||
<Link href="/reset">Forgot Password?</Link>
|
||||
</Button>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" disabled={isPending} type="submit">
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
67
app/(root)/(routes)/(auth)/new-password/page.tsx
Normal file
67
app/(root)/(routes)/(auth)/new-password/page.tsx
Normal file
|
|
@ -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<string | undefined>()
|
||||
const [success, setSuccess] = useState<string | undefined>()
|
||||
const [hasErrorToastShown, setHasErrorToastShown] = useState<boolean>(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 (
|
||||
<CardWrapper
|
||||
headerTitle="Verify your email"
|
||||
backButtonLabel="Back to Login"
|
||||
backButtonHref="/login"
|
||||
>
|
||||
<div className="flex items-center w-full justify-center">
|
||||
{!success && !error && <p>Verifying...</p>}
|
||||
</div>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
67
app/(root)/(routes)/(auth)/new-verification/page.tsx
Normal file
67
app/(root)/(routes)/(auth)/new-verification/page.tsx
Normal file
|
|
@ -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<string | undefined>()
|
||||
const [success, setSuccess] = useState<string | undefined>()
|
||||
const [hasErrorToastShown, setHasErrorToastShown] = useState<boolean>(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 (
|
||||
<CardWrapper
|
||||
headerTitle="Verify your email"
|
||||
backButtonLabel="Back to Login"
|
||||
backButtonHref="/login"
|
||||
>
|
||||
<div className="flex items-center w-full justify-center">
|
||||
{!success && !error && <p>Verifying...</p>}
|
||||
</div>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
126
app/(root)/(routes)/(auth)/register/page.tsx
Normal file
126
app/(root)/(routes)/(auth)/register/page.tsx
Normal file
|
|
@ -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<z.infer<typeof RegisterSchema>>({
|
||||
resolver: zodResolver(RegisterSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
name: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = (values: z.infer<typeof RegisterSchema>) => {
|
||||
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 (
|
||||
<CardWrapper
|
||||
headerTitle="Register"
|
||||
backButtonLabel="Already have an account?"
|
||||
backButtonHref="/login"
|
||||
showSocial
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-1 w-full"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Tyler Durden"
|
||||
disabled={isPending}
|
||||
type="name"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="tylerdurden@gmail.com"
|
||||
disabled={isPending}
|
||||
type="email"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="••••••••"
|
||||
disabled={isPending}
|
||||
type="password"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500 " />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div></div>
|
||||
</div>
|
||||
<Button className="w-full" disabled={isPending} type="submit">
|
||||
Register
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
86
app/(root)/(routes)/(auth)/reset/page.tsx
Normal file
86
app/(root)/(routes)/(auth)/reset/page.tsx
Normal file
|
|
@ -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<z.infer<typeof ResetSchema>>({
|
||||
resolver: zodResolver(ResetSchema),
|
||||
defaultValues: {
|
||||
email: ''
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = (values: z.infer<typeof ResetSchema>) => {
|
||||
startTransition(() => {
|
||||
reset(values).then((data) => {
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
}
|
||||
if (data?.success) {
|
||||
toast.success(data.success)
|
||||
form.reset({ email: '' })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
headerTitle="Password Reset"
|
||||
backButtonLabel="Back to login"
|
||||
backButtonHref="/login"
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4 w-full"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="tylerdurden@gmail.com"
|
||||
disabled={isPending}
|
||||
type="email"
|
||||
className="bg-background/50 dark:bg-background/30 ring-foreground/5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-red-500" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full mt-4" disabled={isPending} type="submit">
|
||||
Send Reset Email
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChartContainer config={chartConfig} className="w-full h-[200px]">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
<YAxis
|
||||
dataKey="browser"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
chartConfig[value as keyof typeof chartConfig]?.label
|
||||
}
|
||||
/>
|
||||
<XAxis dataKey="visitors" type="number" hide />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Bar dataKey="visitors" layout="vertical" radius={5} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChartContainer config={chartConfig} className="w-full h-[200px]">
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dashed" />}
|
||||
/>
|
||||
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
|
||||
<Bar dataKey="mobile" fill="var(--color-mobile)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
6
app/(root)/(routes)/customize/_components/index.ts
Normal file
6
app/(root)/(routes)/customize/_components/index.ts
Normal file
|
|
@ -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'
|
||||
|
|
@ -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 (
|
||||
<ChartContainer config={chartConfig} className="w-full h-[200px]">
|
||||
<LineChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Line
|
||||
dataKey="desktop"
|
||||
type="monotone"
|
||||
stroke="var(--color-desktop)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="mobile"
|
||||
type="monotone"
|
||||
stroke="var(--color-mobile)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
110
app/(root)/(routes)/customize/_components/pie-chart-donut.tsx
Normal file
110
app/(root)/(routes)/customize/_components/pie-chart-donut.tsx
Normal file
|
|
@ -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 (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-full h-[200px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="visitors"
|
||||
nameKey="browser"
|
||||
innerRadius={60}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
{totalVisitors.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
Visitors
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="w-full aspect-square h-[200px]"
|
||||
>
|
||||
<RadarChart data={chartData}>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<PolarGrid
|
||||
className="fill-[--color-desktop] opacity-20"
|
||||
gridType="circle"
|
||||
/>
|
||||
<PolarAngleAxis dataKey="month" />
|
||||
<Radar
|
||||
dataKey="desktop"
|
||||
fill="var(--color-desktop)"
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-full h-[200px]"
|
||||
>
|
||||
<RadialBarChart data={chartData} innerRadius={30} outerRadius={100}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel nameKey="browser" />}
|
||||
/>
|
||||
<PolarGrid gridType="circle" />
|
||||
<RadialBar dataKey="visitors" />
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-full h-[200px]"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={chartData}
|
||||
endAngle={100}
|
||||
innerRadius={80}
|
||||
outerRadius={140}
|
||||
>
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-muted last:fill-background"
|
||||
polarRadius={[86, 74]}
|
||||
/>
|
||||
<RadialBar dataKey="visitors" background />
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-4xl font-bold"
|
||||
>
|
||||
{chartData[0].visitors.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
Visitors
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
188
app/(root)/(routes)/customize/page.tsx
Normal file
188
app/(root)/(routes)/customize/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="w-full max-w-6xl flex flex-col gap-16 p-6">
|
||||
<div className="flex flex-col sm:flex-row w-fit mx-auto md:max-w-md gap-2 rounded-lg bg-yellow-300/50 border-2 border-yellow-500/75 p-4 text-foreground">
|
||||
<TriangleAlert className="w-8 h-8 sm:w-6 sm:h-6 mx-auto sm:mx-0" />
|
||||
<span className="w-fit">
|
||||
If you refresh the page, it will reset the colors. Update{' '}
|
||||
<code className="rounded-lg p-1 bg-foreground/10">globals.css</code>{' '}
|
||||
with your new colors.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col w-fit mx-auto max-w-xs xs:max-w-xl lg:max-w-full lg:mx-0 lg:flex-row lg:w-full justify-between gap-8">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 w-fit xs:w-full mx-auto sm:mx-0 gap-8">
|
||||
<div className="w-min sm:w-max flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Primary color</span>
|
||||
<ColorPicker variable="--primary" />
|
||||
</div>
|
||||
<div className="w-min sm:w-max flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Primary text color</span>
|
||||
<ColorPicker variable="--primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-fit flex flex-col gap-6 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Components</span>
|
||||
<div className="flex flex-wrap justify-center items-center sm:justify-start w-full h-fit gap-6">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Checkbox defaultChecked={true} className="w-5 h-5" />
|
||||
<Slider
|
||||
defaultValue={[50]}
|
||||
max={100}
|
||||
step={1}
|
||||
className="max-w-36"
|
||||
/>
|
||||
<Switch defaultChecked />
|
||||
<Progress value={progress} />
|
||||
<Card className="w-full bg-transparent relative">
|
||||
<CardHeader>
|
||||
<CardTitle>Get the Starter Kit</CardTitle>
|
||||
<CardDescription>
|
||||
Receive the Starter Kit for free in your mailbox.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-center gap-4">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<Label htmlFor="name">Email</Label>
|
||||
<Input
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEmail('')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Continue</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-fit mx-auto max-w-xs xs:max-w-xl lg:max-w-full lg:mx-0 lg:flex-row lg:w-full justify-between gap-8">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 w-fit xs:w-full mx-auto sm:mx-0 gap-8">
|
||||
<div className="w-min flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Chart Color 1</span>
|
||||
<ColorPicker variable="--chart-1" />
|
||||
</div>
|
||||
<div className="w-min flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Chart Color 2</span>
|
||||
<ColorPicker variable="--chart-2" />
|
||||
</div>
|
||||
<div className="w-min flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Chart Color 3</span>
|
||||
<ColorPicker variable="--chart-3" />
|
||||
</div>
|
||||
<div className="w-min flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Chart Color 4</span>
|
||||
<ColorPicker variable="--chart-4" />
|
||||
</div>
|
||||
<div className="w-min col-span-2 flex flex-col items-center mx-auto gap-2 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Chart Color 5</span>
|
||||
<ColorPicker variable="--chart-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-fit flex flex-col gap-6 text-lg md:text-xl sm:whitespace-nowrap">
|
||||
<span>Charts</span>
|
||||
<div className="flex flex-wrap max-w-[250px] xs:max-w-full mx-auto justify-center items-center sm:justify-start w-full h-fit gap-6">
|
||||
<BarChartMultiple />
|
||||
<BarChartMixed />
|
||||
<LineChartMultiple />
|
||||
<div className="flex flex-col sm:flex-row w-full">
|
||||
<PieChartDonut />
|
||||
<RadialChartGrid />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row w-full">
|
||||
<RadarChartLines />
|
||||
<RadialChartShape />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
81
app/(root)/(routes)/dashboard/_components/barchart.tsx
Normal file
81
app/(root)/(routes)/dashboard/_components/barchart.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-secondary dark:bg-secondary/50 shadow flex w-full flex-col gap-3 rounded-lg p-5">
|
||||
<section className="flex justify-between gap-2 pb-2">
|
||||
<p>Sales Data</p>
|
||||
<CandlestickChart className="h-4 w-4" />
|
||||
</section>
|
||||
<ChartContainer config={chartConfig} >
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarGraph
|
||||
data={data}
|
||||
margin={{ top: 20, left: -10, right: 10, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey={'month'}
|
||||
tickLine={false}
|
||||
axisLine={true}
|
||||
stroke={`${theme === light_theme ? '#000' : '#fff'}`}
|
||||
fontSize={13}
|
||||
padding={{ left: 0, right: 0 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={true}
|
||||
stroke={`${theme === light_theme ? '#000' : '#fff'}`}
|
||||
fontSize={13}
|
||||
padding={{ top: 0, bottom: 0 }}
|
||||
allowDecimals={false}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey={'total'}
|
||||
radius={[5, 5, 0, 0]}
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
</BarGraph>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
app/(root)/(routes)/dashboard/_components/dashboard-card.tsx
Normal file
44
app/(root)/(routes)/dashboard/_components/dashboard-card.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-secondary dark:bg-secondary/50 shadow flex w-full flex-col gap-3 rounded-lg p-5">
|
||||
{/* Label & Icon */}
|
||||
<section className="flex justify-between gap-2 text-black dark:text-white">
|
||||
<p className="text-sm">{label}</p>
|
||||
<Icon className="h-4 w-4" />
|
||||
</section>
|
||||
{/* Amount & Description */}
|
||||
<section className="flex flex-col gap-1">
|
||||
<h2 className="text-2lg font-semibold">{amount}</h2>
|
||||
<p className="text-xs">{description}</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardCardContent(
|
||||
props: React.HTMLAttributes<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-3 rounded-lg p-5 shadow bg-secondary dark:bg-secondary/50',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
32
app/(root)/(routes)/dashboard/_components/goal.tsx
Normal file
32
app/(root)/(routes)/dashboard/_components/goal.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg p-5 bg-secondary dark:bg-secondary/50">
|
||||
<section className="flex justify-between gap-2 text-black dark:text-white pb-2">
|
||||
<p>Goal Progress</p>
|
||||
<Target className="h-4 w-4" />
|
||||
</section>
|
||||
<div className="gap-3 pt-2">
|
||||
<section className="flex justify-between gap-3 ">
|
||||
<div className=" w-full rounded-full">
|
||||
<Progress
|
||||
value={props.value}
|
||||
className="border border-primary/20 bg-primary/20 h-2"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex justify-between text-sm opacity-75 pt-3">
|
||||
<p>Goal: ${props.goal}</p>
|
||||
<p className="">${Math.round(props.value)} made</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
app/(root)/(routes)/dashboard/_components/line-graph.tsx
Normal file
93
app/(root)/(routes)/dashboard/_components/line-graph.tsx
Normal file
|
|
@ -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 (
|
||||
<Card className="border-none bg-secondary dark:bg-secondary/50 shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-md font-normal">Number Of Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 20, left: 12, right: 12, bottom: 0 }}
|
||||
accessibilityLayer
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey={'month'}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
stroke={`${theme === light_theme ? '#000' : '#fff'}`}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
/>
|
||||
<Line
|
||||
dataKey="users"
|
||||
type="natural"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: 'hsl(var(--primary))' }}
|
||||
activeDot={{ r: 6 }}
|
||||
>
|
||||
<LabelList
|
||||
position="top"
|
||||
offset={12}
|
||||
className="fill-foreground"
|
||||
fontSize={12}
|
||||
/>
|
||||
</Line>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
30
app/(root)/(routes)/dashboard/_components/user-data-card.tsx
Normal file
30
app/(root)/(routes)/dashboard/_components/user-data-card.tsx
Normal file
|
|
@ -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 (
|
||||
<section className="flex justify-between gap-2 text-foreground">
|
||||
<div className="flex gap-3 h-12 w-12 rounded-full bg-secondary/30">
|
||||
<img
|
||||
width={300}
|
||||
height={300}
|
||||
src={props.image || defaultImage}
|
||||
alt="avatar"
|
||||
className="rounded-full h-12 w-12"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<p>{props.name}</p>
|
||||
<div className="text-ellipsis overflow-hidden whitespace-nowrap max-w-1/2 sm:w-auto opacity-50">
|
||||
{props.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">{props.time}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className="flex justify-between gap-2 text-foreground">
|
||||
<div className="flex gap-3 h-12 w-12 rounded-full bg-secondary/30">
|
||||
<img
|
||||
width={300}
|
||||
height={300}
|
||||
src={props.image || defaultImage}
|
||||
alt="avatar"
|
||||
className="rounded-full h-12 w-12"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<p>{props.name}</p>
|
||||
<div className="text-ellipsis overflow-hidden whitespace-nowrap max-w-1/2 sm:w-auto opacity-50">
|
||||
{props.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">{props.saleAmount}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
9
app/(root)/(routes)/dashboard/layout.tsx
Normal file
9
app/(root)/(routes)/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<main className="max-w-6xl w-full flex items-center justify-center px-6">
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardLayout
|
||||
300
app/(root)/(routes)/dashboard/page.tsx
Normal file
300
app/(root)/(routes)/dashboard/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tasks">Tasks</TabsTrigger>
|
||||
<TabsTrigger value="calendar">Calendar</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Total Tasks</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">12</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">In Progress</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">4</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Completed</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">8</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserRoundCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Team Members</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">6</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium">Recent Tasks</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">{task.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">Due: {task.dueDate}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm">{task.status}</span>
|
||||
<div className="h-2 w-24 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium">Upcoming Deadlines</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Design Review</p>
|
||||
<p className="text-sm text-muted-foreground">Tomorrow at 2:00 PM</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Team Meeting</p>
|
||||
<p className="text-sm text-muted-foreground">Friday at 10:00 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">All Tasks</h3>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Todo Column */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4 flex items-center">
|
||||
<ListTodo className="h-4 w-4 mr-2" />
|
||||
Todo
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{tasks.filter(t => t.status === 'Todo').map(task => (
|
||||
<Card key={task.id} className="p-3">
|
||||
<h5 className="font-medium">{task.title}</h5>
|
||||
<p className="text-sm text-muted-foreground mt-1">Due: {task.dueDate}</p>
|
||||
<div className="mt-3 h-1.5 w-full bg-gray-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* In Progress Column */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4 flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
In Progress
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{tasks.filter(t => t.status === 'In Progress').map(task => (
|
||||
<Card key={task.id} className="p-3">
|
||||
<h5 className="font-medium">{task.title}</h5>
|
||||
<p className="text-sm text-muted-foreground mt-1">Due: {task.dueDate}</p>
|
||||
<div className="mt-3 h-1.5 w-full bg-gray-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Done Column */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4 flex items-center">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Done
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{tasks.filter(t => t.status === 'Done').map(task => (
|
||||
<Card key={task.id} className="p-3">
|
||||
<h5 className="font-medium">{task.title}</h5>
|
||||
<p className="text-sm text-muted-foreground mt-1">Due: {task.dueDate}</p>
|
||||
<div className="mt-3 h-1.5 w-full bg-gray-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calendar" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">Calendar</h3>
|
||||
<Button variant="outline">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Select Date
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Today's Schedule */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-4">Today's Schedule</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-14 text-sm text-muted-foreground">09:00 AM</div>
|
||||
<div className="flex-1">
|
||||
<Card className="p-3">
|
||||
<h5 className="font-medium">Team Standup</h5>
|
||||
<p className="text-sm text-muted-foreground">Daily team sync meeting</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-14 text-sm text-muted-foreground">02:00 PM</div>
|
||||
<div className="flex-1">
|
||||
<Card className="p-3">
|
||||
<h5 className="font-medium">Design Review</h5>
|
||||
<p className="text-sm text-muted-foreground">Review new landing page design</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-14 text-sm text-muted-foreground">04:30 PM</div>
|
||||
<div className="flex-1">
|
||||
<Card className="p-3">
|
||||
<h5 className="font-medium">Sprint Planning</h5>
|
||||
<p className="text-sm text-muted-foreground">Plan next sprint tasks</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-4">Upcoming Events</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-20 text-sm text-muted-foreground">Tomorrow</div>
|
||||
<div className="flex-1">
|
||||
<Card className="p-3">
|
||||
<h5 className="font-medium">Client Meeting</h5>
|
||||
<p className="text-sm text-muted-foreground">10:00 AM - Project update discussion</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-20 text-sm text-muted-foreground">Friday</div>
|
||||
<div className="flex-1">
|
||||
<Card className="p-3">
|
||||
<h5 className="font-medium">Team Building</h5>
|
||||
<p className="text-sm text-muted-foreground">02:00 PM - Virtual team activity</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
app/api/auth/[...nextauth]/route.ts
Normal file
1
app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GET, POST } from '@/auth'
|
||||
64
app/api/checkout/route.ts
Normal file
64
app/api/checkout/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
47
app/api/emails/route.ts
Normal file
47
app/api/emails/route.ts
Normal file
|
|
@ -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 <noreply@nizzystarter.com>",
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
96
app/api/webhook/route.ts
Normal file
96
app/api/webhook/route.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
88
app/globals.css
Normal file
88
app/globals.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
43
app/layout.tsx
Normal file
43
app/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<SessionProvider session={session}>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ToastProvider />
|
||||
<AlertDemo />
|
||||
<Navbar />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
15
app/not-found.tsx
Normal file
15
app/not-found.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Error404 } from '@/components/icons'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 relative">
|
||||
<Error404 className="w-[500px] h-auto absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 text-primary/5 -z-10" />
|
||||
<div className="text-6xl">
|
||||
<span>Error</span> <span className="text-primary font-bold">404</span>
|
||||
</div>
|
||||
<span className="text-4xl">Page not found!!!</span>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
77
app/page.tsx
Normal file
77
app/page.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<main className="w-full min-h-screen bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<Header />
|
||||
|
||||
<div className="relative mt-20 mb-40">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center space-y-8 relative z-10">
|
||||
<h1 className="text-6xl font-bold tracking-tight">
|
||||
Think, plan, and track
|
||||
<span className="block text-gray-400 mt-2">all in one place</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Efficiently manage your tasks and boost productivity.
|
||||
</p>
|
||||
<div>
|
||||
<Button size="lg" className="bg-blue-500 hover:bg-blue-600">
|
||||
Get free demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden">
|
||||
{/* Yellow Note */}
|
||||
<div className="absolute left-20 top-20 transform -rotate-6">
|
||||
<div className="bg-yellow-100 p-6 rounded shadow-lg w-48">
|
||||
<p className="text-sm text-gray-700">Take notes to keep track of crucial details, and accomplish more tasks with ease.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="absolute right-20 bottom-20">
|
||||
<div className="bg-white p-4 rounded-lg shadow-lg w-64">
|
||||
<h3 className="font-semibold mb-3">Today's tasks</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-full bg-blue-100 rounded">
|
||||
<div className="h-full w-3/5 bg-blue-500 rounded"></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">60%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-full bg-blue-100 rounded">
|
||||
<div className="h-full w-1/3 bg-blue-500 rounded"></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">33%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integrations */}
|
||||
<div className="absolute right-40 top-20">
|
||||
<div className="bg-white p-4 rounded-lg shadow-lg">
|
||||
<p className="text-sm font-medium mb-2">100+ Integrations</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 bg-red-100 rounded"></div>
|
||||
<div className="w-8 h-8 bg-green-100 rounded"></div>
|
||||
<div className="w-8 h-8 bg-blue-100 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
42
auth.config.ts
Normal file
42
auth.config.ts
Normal file
|
|
@ -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
|
||||
76
auth.ts
Normal file
76
auth.ts
Normal file
|
|
@ -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
|
||||
})
|
||||
16
components.json
Normal file
16
components.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
104
components/alert.tsx
Normal file
104
components/alert.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="w-full max-w-6xl px-6 py-4">
|
||||
<Alert className="flex flex-col sm:flex-row gap-4 justify-between px-6 rounded-xl border-0 ring ring-primary/20 ring-inset text-secondary bg-primary/15 text-black dark:text-white cursor-default">
|
||||
<div>
|
||||
<AlertTitle className="flex gap-1">
|
||||
<RocketIcon className="h-4 w-4" />
|
||||
Heads up!
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex">
|
||||
<p>This is a demo. You can get the github repository</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<p className="text-primary ml-1.5 underline cursor-pointer">
|
||||
here
|
||||
</p>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="rounded-lg sm:max-w-[425px] ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Starter Kit</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your email to get the starterkit link in your inbox
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Label htmlFor="name" className="text-right mb-2">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
name="email"
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="tylerdurder@gmail.com"
|
||||
className="mt-3"
|
||||
value={email}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="mt-4">
|
||||
Get The Starter Kit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Link href="/customize" className="mx-auto sm:mx-0">
|
||||
<Button className="gap-2 text-foreground" variant="link">
|
||||
<Palette className="w-5 h-5" />
|
||||
Customize
|
||||
</Button>
|
||||
</Link>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
components/auth/back-button.tsx
Normal file
22
components/auth/back-button.tsx
Normal file
|
|
@ -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 (
|
||||
<Button
|
||||
variant="link"
|
||||
className="w-full mt-2 text-blue-500"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href={href}>{label}</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
42
components/auth/card-wrapper.tsx
Normal file
42
components/auth/card-wrapper.tsx
Normal file
|
|
@ -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 (
|
||||
<Card className="mx-auto w-full max-w-sm bg-secondary/90 border border-foreground/5 rounded-lg px-7">
|
||||
<CardHeader>
|
||||
<Header title={headerTitle} />
|
||||
</CardHeader>
|
||||
|
||||
<div>{children}</div>
|
||||
|
||||
{showSocial && (
|
||||
<div>
|
||||
<Social />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardFooter>
|
||||
<BackButton label={backButtonLabel} href={backButtonHref} />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
11
components/auth/header.tsx
Normal file
11
components/auth/header.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface HeaderProps {
|
||||
title: string
|
||||
}
|
||||
|
||||
export const Header = ({ title }: HeaderProps) => {
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl font-bold ">{title}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
components/auth/social.tsx
Normal file
29
components/auth/social.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
className="rounded-[5px] w-full border border-primary/20 bg-secondary text-primary hover:bg-primary/10 text-md"
|
||||
onClick={() => onClick('google')}
|
||||
>
|
||||
<FaGoogle className="mr-2" /> Google
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-[5px] w-full border border-primary/20 bg-secondary text-primary hover:bg-primary/10 text-md"
|
||||
onClick={() => onClick('github')}
|
||||
>
|
||||
<FaGithub className="mr-2" /> Github
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
components/color-picker.tsx
Normal file
109
components/color-picker.tsx
Normal file
|
|
@ -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<HslColorType>({ h: 0, s: 100, l: 50 })
|
||||
const mainDivRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={mainDivRef}
|
||||
className="flex flex-col items-center sm:items-start w-min space-y-4"
|
||||
>
|
||||
<HslColorPicker color={color} onChange={handleColorChange} />
|
||||
<CodeBlock
|
||||
code={`${variable}: ${color.h} ${color.s}% ${color.l}%;`}
|
||||
language="css"
|
||||
theme={themes.oneDark}
|
||||
>
|
||||
<div className="relative">
|
||||
<CodeBlock.Code className="bg-foreground/[0.025] border p-4 rounded-lg shadow-lg break-words whitespace-pre-wrap">
|
||||
<span className="flex items-center text-sm text-foreground/70 h-6 mb-1">
|
||||
globals.css
|
||||
</span>
|
||||
<CodeBlock.LineContent className="text-base">
|
||||
<CodeBlock.Token />
|
||||
</CodeBlock.LineContent>
|
||||
</CodeBlock.Code>
|
||||
<button
|
||||
className="bg-foreground/5 border rounded px-3.5 py-1.5 absolute top-2 right-2 text-sm font-semibold"
|
||||
onClick={copyCode}
|
||||
>
|
||||
{state.value ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
components/email/email.tsx
Normal file
106
components/email/email.tsx
Normal file
|
|
@ -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 = () => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
Your Free SaaS Starter Kit
|
||||
</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
|
||||
<Text style={paragraph}>Hey!!</Text>
|
||||
<Text style={paragraph}>
|
||||
Welcome to the Nizzy Starter Kit!! The free (and better) SaaS Starter Kit.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Below is the link to the github repository where you can find the starter kit.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Enjoy building your next project with it!
|
||||
</Text>
|
||||
<Section style={btnContainer}>
|
||||
<Button style={button} href="https://github.com/NizarAbiZaher/nizzy-starter">
|
||||
Go To The Starter Kit
|
||||
</Button>
|
||||
</Section>
|
||||
<Text style={paragraph}>
|
||||
Best,
|
||||
<br />
|
||||
Nizzy
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
470 Noor Ave STE B #1148, Ottawa, Canada 94080
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
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",
|
||||
};
|
||||
19
components/footer.tsx
Normal file
19
components/footer.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Link from 'next/link'
|
||||
import { Logo } from '@/components/logo'
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className="max-w-6xl w-full p-6">
|
||||
<div className="flex justify-center bg-secondary dark:bg-secondary/50 py-6 w-full rounded-xl">
|
||||
<div className="w-full max-w-6xl flex flex-col md:flex-row items-center justify-between gap-6 px-6">
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
<span className="text-sm">
|
||||
© 2024 YourCompany. All rights reserved.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
67
components/header.tsx
Normal file
67
components/header.tsx
Normal file
|
|
@ -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 && <Skeleton className="w-full max-w-2xl h-auto aspect-video" />}
|
||||
<iframe
|
||||
id="youtube-iframe"
|
||||
src="https://www.youtube.com/embed/Q6jDdtbkMIU?si=YtgU89RhYiwt5-U5"
|
||||
title="YouTube Video Player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
className={`w-full max-w-2xl h-auto aspect-video rounded-[6px] ${iframeLoaded ? '' : 'hidden'}`}
|
||||
></iframe>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
<div className="space-y-20 mt-32">
|
||||
<div className="mx-auto grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="flex flex-col justify-center text-center lg:text-left ">
|
||||
<h2 className="text-4xl font-extrabold sm:text-5xl">
|
||||
Clone. Build. Ship.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-foreground">
|
||||
Build your SaaS faster with our fully customizable template.
|
||||
</p>
|
||||
<div className="flex justify-center lg:justify-start items-center mt-4">
|
||||
<Link href="/overview">
|
||||
<Button className="gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span>Get Started</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-lg overflow-hidden">
|
||||
<IframeWithSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
components/icons/index.tsx
Normal file
104
components/icons/index.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react'
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function StripeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.729 8.36c0-.771.63-1.068 1.675-1.068c1.112 0 2.442.251 3.666.712c.567.214 1.222-.182 1.222-.789V4.588a.952.952 0 0 0-.636-.912C15.236 3.195 13.828 3 12.404 3C8.403 3 5.742 5.097 5.742 8.598c0 5.46 7.49 4.589 7.49 6.943c0 .91-.788 1.206-1.892 1.206c-1.255 0-2.777-.396-4.165-.99c-.56-.24-1.216.152-1.216.76v2.698c0 .4.236.763.61.904c1.632.615 3.263.881 4.77.881c4.1 0 6.919-2.037 6.919-5.578c-.02-5.895-7.53-4.846-7.53-7.062Z"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResendIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2.023 0v24h5.553v-8.434h2.998L15.326 24h6.65l-5.372-9.258a7.65 7.65 0 0 0 3.316-3.016q1.063-1.815 1.062-4.08q0-2.194-1.062-3.91q-1.063-1.747-2.95-2.742Q15.12 0 12.823 0Zm5.553 4.87h4.219q1.097 0 1.851.376q.788.378 1.2 1.098q.412.685.412 1.611c0 .926-.126 1.165-.378 1.645q-.343.72-1.03 1.13q-.651.379-1.542.38H7.576Z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TailwindcssIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.001 4.8q-4.8 0-6 4.8q1.8-2.4 4.2-1.8c.913.228 1.565.89 2.288 1.624C13.666 10.618 15.027 12 18.001 12q4.8 0 6-4.8q-1.8 2.4-4.2 1.8c-.913-.228-1.565-.89-2.288-1.624C16.337 6.182 14.976 4.8 12.001 4.8m-6 7.2q-4.8 0-6 4.8q1.8-2.4 4.2-1.8c.913.228 1.565.89 2.288 1.624c1.177 1.194 2.538 2.576 5.512 2.576q4.8 0 6-4.8q-1.8 2.4-4.2 1.8c-.913-.228-1.565-.89-2.288-1.624C10.337 13.382 8.976 12 6.001 12"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NextjsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<g fill="none">
|
||||
<g clipPath="url(#akarIconsNextjsFill0)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.214.006c-.052.005-.216.022-.364.033c-3.408.308-6.6 2.147-8.624 4.974a11.88 11.88 0 0 0-2.118 5.243c-.096.66-.108.854-.108 1.748s.012 1.089.108 1.748c.652 4.507 3.86 8.293 8.209 9.696c.779.251 1.6.422 2.533.526c.364.04 1.936.04 2.3 0c1.611-.179 2.977-.578 4.323-1.265c.207-.105.247-.134.219-.157a211.64 211.64 0 0 1-1.955-2.62l-1.919-2.593l-2.404-3.559a342.499 342.499 0 0 0-2.422-3.556c-.009-.003-.018 1.578-.023 3.51c-.007 3.38-.01 3.516-.052 3.596a.426.426 0 0 1-.206.213c-.075.038-.14.045-.495.045H7.81l-.108-.068a.44.44 0 0 1-.157-.172l-.05-.105l.005-4.704l.007-4.706l.073-.092a.644.644 0 0 1 .174-.143c.096-.047.133-.051.54-.051c.478 0 .558.018.682.154c.035.038 1.337 2 2.895 4.362l4.734 7.172l1.9 2.878l.097-.063a12.318 12.318 0 0 0 2.465-2.163a11.947 11.947 0 0 0 2.825-6.135c.096-.66.108-.854.108-1.748s-.012-1.088-.108-1.748C23.24 5.75 20.032 1.963 15.683.56a12.6 12.6 0 0 0-2.498-.523c-.226-.024-1.776-.05-1.97-.03m4.913 7.26a.473.473 0 0 1 .237.276c.018.06.023 1.365.018 4.305l-.007 4.218l-.743-1.14l-.746-1.14V10.72c0-1.983.009-3.097.023-3.151a.478.478 0 0 1 .232-.296c.097-.05.132-.054.5-.054c.347 0 .408.005.486.047"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="akarIconsNextjsFill0">
|
||||
<path d="M0 0h24v24H0z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Error404(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="m2 2.207l20 19.794M6.562 2.515c2.006-.602 7.754-.723 11.162-.118c.825.147 1.62.679 2.054 1.385c.726 1.183.687 2.56.687 3.942l-.12 8.455M4 4.172c-.632 2.267-.547 6.428-.506 12.57c.006.79.043 1.59.281 2.345c.369 1.17.983 1.88 2.332 2.45c.575.244 1.206.324 1.833.324h4.043c3.796-.092 5.328-.488 7.006-2.678m-8.502 2.678c2.381-1.207 3.608-1.375 3.296-4.411c-.06-.786.39-1.725 1.194-1.977m5.428-3.428c-.243 1.436-.406 1.97-1.375 2.801"
|
||||
color="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
46
components/languages.tsx
Normal file
46
components/languages.tsx
Normal file
|
|
@ -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 (
|
||||
<section className="relative">
|
||||
<div className="absolute top-0 left-0 w-1/4 h-full bg-gradient-to-r from-background pointer-events-none z-10"></div>
|
||||
<div className="absolute top-0 right-0 w-1/4 h-full bg-gradient-to-r from-transparent to-background pointer-events-none z-10"></div>
|
||||
<Marquee speed={30} autoFill>
|
||||
{languages.map((language, i) => (
|
||||
<language.icon
|
||||
key={i}
|
||||
className={
|
||||
language.className +
|
||||
'text-foreground opacity-50 hover:opacity-100 transition duration-300 ml-32 cursor-pointer'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Marquee>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
12
components/logo.tsx
Normal file
12
components/logo.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Squircle } from 'lucide-react'
|
||||
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 group">
|
||||
<Squircle className="w-12 h-12 text-primary group-hover:-rotate-12 transition-all duration-300" />
|
||||
<span className="text-xl group-hover:translate-x-0.5 transition-all duration-300">
|
||||
YourCompany
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
components/mobile-sidebar.tsx
Normal file
23
components/mobile-sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger className="sm:hidden pr-4 text-primary" onClick={openSidebar}>
|
||||
<PanelRightClose className="h-6 w-6 hover:text-primary/50 duration-300" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-6 border-none w-80 bg-secondary">
|
||||
<Sidebar closeSidebar={closeSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
39
components/mode-toggle.tsx
Normal file
39
components/mode-toggle.tsx
Normal file
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-md" variant="outline" size="icon">
|
||||
<Sun className="block dark:hidden h-5 w-5" />
|
||||
<Moon className="hidden dark:block h-5 w-5" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
55
components/navbar.tsx
Normal file
55
components/navbar.tsx
Normal file
|
|
@ -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 (
|
||||
<nav className="top-0 w-full z-50 transition">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<MobileSidebar />
|
||||
{/* Logo */}
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
{/* Links, Theme, & User */}
|
||||
<div className="hidden sm:flex h-[40px] items-center text-lg md:text-lg font-medium gap-4 transition-all">
|
||||
<div className="flex items-center h-full text-base font-medium">
|
||||
{navPages.map((page, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={page.link}
|
||||
className="flex items-center hover:text-primary hover:bg-primary/10 h-full transition duration-300 px-4 rounded-md"
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex h-full gap-4">
|
||||
<ModeToggle />
|
||||
<UserButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
154
components/pricing-card.tsx
Normal file
154
components/pricing-card.tsx
Normal file
|
|
@ -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 (
|
||||
<section id="pricing" className="scroll-mt-4">
|
||||
{/* Title */}
|
||||
<div className="mx-auto flex flex-col items-center pb-8">
|
||||
<h2 className="pb-4 text-4xl font-extrabold text-foreground">
|
||||
Pricing
|
||||
</h2>
|
||||
<p className="text-md opacity-50 max-w-lg text-center">
|
||||
Describe your product / service here that will impress the user & want
|
||||
them to buy the product
|
||||
</p>
|
||||
</div>
|
||||
{/* Pricing Card Display */}
|
||||
<div className="flex flex-col sm:place-items-center md:flex-row items-center justify-center gap-6">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className={`relative flex flex-col p-6 shadow-lg rounded-lg justify-between ring-2 ring-inset w-full max-w-[550px] ${
|
||||
tier.yourProduct
|
||||
? 'bg-primary/10 ring-primary/50'
|
||||
: 'bg-secondary ring-foreground/10'
|
||||
}`}
|
||||
>
|
||||
{tier.yourProduct && (
|
||||
<div className="px-3 py-1 text-primary-foreground text-sm bg-primary rounded-full inline-block absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
Popular
|
||||
</div>
|
||||
)}
|
||||
{/* Pricing */}
|
||||
<div>
|
||||
<h3
|
||||
className={`text-lg font-semibold ${
|
||||
tier.yourProduct ? 'text-primary' : 'text-foreground/70'
|
||||
}`}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
<div
|
||||
className={`mt-4 ${
|
||||
tier.yourProduct ? 'text-foreground/90' : 'text-foreground/70'
|
||||
}`}
|
||||
>
|
||||
{tier.priceBefore ? (
|
||||
<span className="font-semibold mr-2 line-through text-lg opacity-75">
|
||||
{tier.priceBefore}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-4xl font-bold">${tier.price}</span> /month
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{tier.features.map((feature, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center text-foreground/90 gap-2"
|
||||
>
|
||||
<Check
|
||||
className={`h-6 w-6 rounded-full ${
|
||||
tier.yourProduct ? 'text-primary' : 'text-foreground/70'
|
||||
}`}
|
||||
/>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Button */}
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
disabled={!tier.yourProduct}
|
||||
onClick={onClick}
|
||||
className={`w-full ${tier.yourProduct && 'hover:-translate-y-1'}`}
|
||||
>
|
||||
{tier.cta}
|
||||
<Sparkle className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
6
components/providers/toaster-provider.tsx
Normal file
6
components/providers/toaster-provider.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
'use client'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
export const ToastProvider = () => {
|
||||
return <Toaster />
|
||||
}
|
||||
25
components/purchase-button.tsx
Normal file
25
components/purchase-button.tsx
Normal file
|
|
@ -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 (
|
||||
<Button onClick={onClick} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Purchase'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
130
components/sidebar.tsx
Normal file
130
components/sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col justify-between pl-2">
|
||||
<Link href="/" onClick={closeSidebar}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex pt-4">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div className=" pt-3">
|
||||
<div className="space-y-4">
|
||||
<div className="ml-2">
|
||||
<h1 className="text-xl font-semibold">Main</h1>
|
||||
{sidebarPages.map((page) => (
|
||||
<Link
|
||||
key={page.link}
|
||||
href={page.link}
|
||||
className={cn(
|
||||
'group flex py-1.5 w-full justify-start font-light cursor-pointer'
|
||||
)}
|
||||
onClick={closeSidebar}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
<p className="font-normal text-foreground/75">{page.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<h1 className="text-xl font-semibold">Socials</h1>
|
||||
{socials.map((page) => (
|
||||
<Link
|
||||
key={page.link}
|
||||
href={page.link}
|
||||
className={cn(
|
||||
'group flex w-full justify-start font-light cursor-pointer py-1.5'
|
||||
)}
|
||||
onClick={closeSidebar}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
<p className="font-normal text-foreground/75">{page.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{session ? (
|
||||
<Link
|
||||
href="/login"
|
||||
className="group flex py-2 w-full justify-start cursor-pointer rounded ml-2 font-bold text-xl"
|
||||
onClick={() => {
|
||||
Logout()
|
||||
closeSidebar && closeSidebar()
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="group flex pt-2 w-full justify-start font-light cursor-pointer"
|
||||
onClick={closeSidebar}
|
||||
>
|
||||
<div className="flex w-full ml-2 pb-3">
|
||||
<p className="font-semibold ">Sign Up</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
components/testimonials.tsx
Normal file
110
components/testimonials.tsx
Normal file
|
|
@ -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 (
|
||||
<div>
|
||||
{/* Section Title */}
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center">
|
||||
<h2 className="pb-4 text-4xl font-extrabold text-foreground">
|
||||
Testimonials
|
||||
</h2>
|
||||
<p className="text-md opacity-50 max-w-lg text-center">
|
||||
Describe your product / service here that will impress the user & want
|
||||
them to buy the product
|
||||
</p>
|
||||
<AvatarCircles />
|
||||
</div>
|
||||
{/* Testimonials Card*/}
|
||||
<div className="flex items-center justify-center my-6">
|
||||
<div className="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl">
|
||||
{testimonials.map((testimonial, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="py-4 px-0 bg-secondary border-0 ring-[2px] ring-foreground/10 ring-inset rounded-lg hover:bg-primary/10 hover:ring-primary/25 transition duration-300 cursor-default"
|
||||
>
|
||||
<CardContent className="py-0">
|
||||
<div className="flex">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={testimonial.avatar}
|
||||
alt={testimonial.name}
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<CardTitle className="text-lg pl-2 text-foreground pt-1">
|
||||
{testimonial.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<p className="pt-3 text-foreground/70">
|
||||
"{testimonial.message}"
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
|
|
@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
|
|
@ -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<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
56
components/ui/button.tsx
Normal file
56
components/ui/button.tsx
Normal file
|
|
@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
67
components/ui/calendar.tsx
Normal file
67
components/ui/calendar.tsx
Normal file
|
|
@ -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<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-start)]:rounded-l-md [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_start: 'day-range-start',
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = 'Calendar'
|
||||
|
||||
export { Calendar }
|
||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
365
components/ui/chart.tsx
Normal file
365
components/ui/chart.tsx
Normal file
|
|
@ -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<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
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 (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
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 (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup
|
||||
}
|
||||
176
components/ui/form.tsx
Normal file
176
components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = 'FormItem'
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = 'FormLabel'
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = 'FormControl'
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = 'FormDescription'
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField
|
||||
}
|
||||
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md ring-1 ring-border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 transition duration-300',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
140
components/ui/sheet.tsx
Normal file
140
components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = 'SheetHeader'
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = 'SheetFooter'
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
28
components/ui/slider.tsx
Normal file
28
components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
29
components/ui/switch.tsx
Normal file
29
components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
52
components/ui/tabs.tsx
Normal file
52
components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
9
components/ui/theme-provider.tsx
Normal file
9
components/ui/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
127
components/ui/toast.tsx
Normal file
127
components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from 'react'
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction
|
||||
}
|
||||
35
components/ui/toaster.tsx
Normal file
35
components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
30
components/ui/tooltip.tsx
Normal file
30
components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-foreground/10 bg-background px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
189
components/ui/use-toast.ts
Normal file
189
components/ui/use-toast.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react'
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST'
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
)
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false
|
||||
}
|
||||
: t
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id }
|
||||
})
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
42
components/ui/user-avatar-card.tsx
Normal file
42
components/ui/user-avatar-card.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use client'
|
||||
import { cn } from '@/lib/utils'
|
||||
interface AvatarCirclesProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AvatarCircles({ className }: AvatarCirclesProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'z-10 flex items-center justify-center -space-x-4 rtl:space-x-reverse pt-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
className="h-9 w-9 rounded-full border-2 border-secondary"
|
||||
src="/testimonials/john-doe.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="h-9 w-9 rounded-full border-2 border-secondary"
|
||||
src="/testimonials/john-doe.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="h-9 w-9 rounded-full border-2 border-secondary"
|
||||
src="/testimonials/john-doe.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="h-9 w-9 rounded-full border-2 border-secondary"
|
||||
src="/testimonials/john-doe.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="h-9 w-9 rounded-full border-2 border-secondary"
|
||||
src="/testimonials/john-doe.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
components/user-button.tsx
Normal file
123
components/user-button.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
'use client'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import {
|
||||
Book,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const UserButton = () => {
|
||||
const userButtonItems = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: Book
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
href: '/payments',
|
||||
icon: CreditCard
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings
|
||||
}
|
||||
]
|
||||
// Random gradient colors for Avatar
|
||||
|
||||
const router = useRouter()
|
||||
const session = useCurrentUser()
|
||||
|
||||
const onClick = () => {
|
||||
router.push('/register')
|
||||
}
|
||||
const Logout = () => {
|
||||
signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!session ? (
|
||||
<div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex md:hidden items-center justify-center rounded-lg cursor-pointer transition duration-300 hover:bg-primary/10 px-2 py-2"
|
||||
>
|
||||
<LogOut className="h-5.5 w-5" />
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
className="px-5 rounded-md hidden md:flex"
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* User Avatar / Logo */}
|
||||
<Avatar className="cursor-pointer">
|
||||
<AvatarImage src={session.image ? session.image : ''} alt="pfp" />
|
||||
<AvatarFallback className="bg-gradient-to-r from-blue-400 via-blue-500 to-blue-700"></AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuLabel className="py-0 pt-1">
|
||||
{session?.name}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="opacity-70 text-sm font-normal">
|
||||
{session?.email}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Main Icons */}
|
||||
<DropdownMenuGroup>
|
||||
{userButtonItems.map((item, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<Link href={item.href} className="flex">
|
||||
<item.icon className="mr-2 mt-0.5 h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{/* Logout Button */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={Logout} className="cursor-pointer">
|
||||
<LogOut className="mr-2 mt-0.5 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserButton
|
||||
25
data/password-reset-token.ts
Normal file
25
data/password-reset-token.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { db } from '@/lib/db'
|
||||
|
||||
// token functionality
|
||||
export const getPasswordResetTokenByToken = async (token: string) => {
|
||||
try {
|
||||
const passwordResetToken = await db.passwordResetToken.findUnique({
|
||||
where: { token }
|
||||
})
|
||||
return passwordResetToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Token Email functionality (match emails)
|
||||
export const getPasswordResetTokenByEmail = async (email: string) => {
|
||||
try {
|
||||
const passwordResetToken = await db.passwordResetToken.findFirst({
|
||||
where: { email }
|
||||
})
|
||||
return passwordResetToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
19
data/user.ts
Normal file
19
data/user.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { db } from '@/lib/db'
|
||||
|
||||
export const getUserByEmail = async (email: string) => {
|
||||
try {
|
||||
const user = await db.user.findUnique({ where: { email } })
|
||||
return user
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserById = async (id: string) => {
|
||||
try {
|
||||
const user = await db.user.findUnique({ where: { id } })
|
||||
return user
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
27
data/verification-token.ts
Normal file
27
data/verification-token.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { db } from '@/lib/db'
|
||||
|
||||
export const getVerificationTokenByToken = async (token: string) => {
|
||||
// Get Verification Token
|
||||
try {
|
||||
const verificationToken = await db.verificationToken.findUnique({
|
||||
where: { token }
|
||||
})
|
||||
|
||||
return verificationToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getVerificationTokenByEmail = async (email: string) => {
|
||||
// Get Email Verification
|
||||
try {
|
||||
const verificationToken = await db.verificationToken.findFirst({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
return verificationToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
143
emails/reset-email.tsx
Normal file
143
emails/reset-email.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Text
|
||||
} from '@react-email/components'
|
||||
import * as React from 'react'
|
||||
|
||||
interface ResetPasswordProps {
|
||||
token?: string
|
||||
}
|
||||
|
||||
const gradientStyle = {
|
||||
background: 'linear-gradient(to right, #6366F1, #A855F7)', // from-indigo-500 to-purple-500
|
||||
WebkitBackgroundClip: 'text', // Clips the background to the text (for Safari)
|
||||
WebkitTextFillColor: 'transparent', // Makes the text color transparent (for Safari)
|
||||
backgroundClip: 'text', // Clips the background to the text (standard)
|
||||
color: 'transparent' // Makes the text color transparent
|
||||
}
|
||||
export const ResetPassword = ({ token }: ResetPasswordProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Reset password with this link</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Reset Password</Heading>
|
||||
<Link
|
||||
href={`${process.env.APP_URL}/new-password?token=${token}`}
|
||||
target="_blank"
|
||||
style={{
|
||||
...link,
|
||||
display: 'block',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
Click here to reset password
|
||||
</Link>
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
color: '#ababab',
|
||||
marginTop: '14px',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
If you didn't try to reset your password, you can safely ignore
|
||||
this email.
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
marginTop: '14px',
|
||||
marginBottom: '16px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
<span>nizzy</span>
|
||||
<span style={gradientStyle}>abi</span>
|
||||
</Text>
|
||||
|
||||
<Text style={footer}>
|
||||
<Link
|
||||
href="https://nizzyabi.com"
|
||||
target="_blank"
|
||||
style={{ ...link, color: '#898989' }}
|
||||
>
|
||||
nizzyabi.com
|
||||
</Link>
|
||||
, learn to code
|
||||
<br />
|
||||
&& have fun doing it.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
ResetPassword.PreviewProps = {
|
||||
loginCode: 'sparo-ndigo-amurt-secan'
|
||||
} as ResetPasswordProps
|
||||
|
||||
export default ResetPassword
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#ffffff'
|
||||
}
|
||||
|
||||
const container = {
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
margin: '0 auto'
|
||||
}
|
||||
|
||||
const h1 = {
|
||||
color: '#333',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
padding: '0'
|
||||
}
|
||||
|
||||
const link = {
|
||||
color: '#2754C5',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '14px',
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '14px',
|
||||
margin: '24px 0'
|
||||
}
|
||||
|
||||
const footer = {
|
||||
color: '#898989',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '12px',
|
||||
lineHeight: '22px',
|
||||
marginTop: '12px',
|
||||
marginBottom: '24px'
|
||||
}
|
||||
|
||||
const code = {
|
||||
display: 'inline-block',
|
||||
padding: '16px 4.5%',
|
||||
width: '90.5%',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #eee',
|
||||
color: '#333'
|
||||
}
|
||||
134
emails/verify-email.tsx
Normal file
134
emails/verify-email.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Text
|
||||
} from '@react-email/components'
|
||||
import * as React from 'react'
|
||||
|
||||
interface LinkEmailProps {
|
||||
token?: string
|
||||
}
|
||||
|
||||
export const LinkEmail = ({ token }: LinkEmailProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Log in with this link</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Login</Heading>
|
||||
<Link
|
||||
href={`${process.env.APP_URL}/verify-email?token=${token}`}
|
||||
target="_blank"
|
||||
style={{
|
||||
...link,
|
||||
display: 'block',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
Click here to verify your email
|
||||
</Link>
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
color: '#ababab',
|
||||
marginTop: '14px',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
If you didn't try to login, you can safely ignore this email.
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
marginTop: '14px',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
your logo here
|
||||
</Text>
|
||||
|
||||
<Text style={footer}>
|
||||
<Link
|
||||
href="https://nizzyabi.com"
|
||||
target="_blank"
|
||||
style={{ ...link, color: '#898989' }}
|
||||
>
|
||||
yoururl.com
|
||||
</Link>
|
||||
, learn to code
|
||||
<br />
|
||||
&& have fun doing it.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
LinkEmail.PreviewProps = {
|
||||
loginCode: 'sparo-ndigo-amurt-secan'
|
||||
} as LinkEmailProps
|
||||
|
||||
export default LinkEmail
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#ffffff'
|
||||
}
|
||||
|
||||
const container = {
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
margin: '0 auto'
|
||||
}
|
||||
|
||||
const h1 = {
|
||||
color: '#333',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '40px 0',
|
||||
padding: '0'
|
||||
}
|
||||
|
||||
const link = {
|
||||
color: '#2754C5',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '14px',
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '14px',
|
||||
margin: '24px 0'
|
||||
}
|
||||
|
||||
const footer = {
|
||||
color: '#898989',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: '12px',
|
||||
lineHeight: '22px',
|
||||
marginTop: '12px',
|
||||
marginBottom: '24px'
|
||||
}
|
||||
|
||||
const code = {
|
||||
display: 'inline-block',
|
||||
padding: '16px 4.5%',
|
||||
width: '90.5%',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #eee',
|
||||
color: '#333'
|
||||
}
|
||||
7
hooks/use-current-role.ts
Normal file
7
hooks/use-current-role.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export const useCurrentRole = () => {
|
||||
const session = useSession()
|
||||
|
||||
return session.data?.user?.role
|
||||
}
|
||||
7
hooks/use-current-user.ts
Normal file
7
hooks/use-current-user.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Hook to get user data in a more convenient way
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const session = useSession()
|
||||
return session.data?.user
|
||||
}
|
||||
14
hooks/use-pro-modal.ts
Normal file
14
hooks/use-pro-modal.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { create } from 'zustand'
|
||||
// create interface for proModal Display
|
||||
interface userProModalStore {
|
||||
isOpen: boolean
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// if the button is clicked, isOpen will become true and the modal will be displayed
|
||||
export const useProModal = create<userProModalStore>((set) => ({
|
||||
isOpen: false,
|
||||
onOpen: () => set({ isOpen: true }),
|
||||
onClose: () => set({ isOpen: false })
|
||||
}))
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue