├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── LICENSE.md ├── README.md ├── actions ├── email.ts └── user.ts ├── app ├── (auth) │ ├── layout.tsx │ ├── login │ │ ├── forgot-password │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── reset-password │ │ │ └── page.tsx │ └── register │ │ └── page.tsx ├── (mainPage) │ ├── layout.tsx │ └── page.tsx ├── api │ ├── auth │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ └── register │ │ │ └── route.ts │ └── payment │ │ ├── cancel-subscription │ │ └── route.ts │ │ ├── change-subscription │ │ └── route.ts │ │ ├── resume-subscription │ │ └── route.ts │ │ ├── subscribe │ │ └── route.ts │ │ └── webhook │ │ └── route.ts ├── dashboard │ ├── billing │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── subscription-form │ │ │ └── index.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── settings │ │ └── page.tsx │ └── subscription │ │ ├── page.tsx │ │ └── plan │ │ └── index.tsx ├── favicon.ico ├── fonts │ ├── CalSans-SemiBold.ttf │ ├── CalSans-SemiBold.woff │ ├── CalSans-SemiBold.woff2 │ ├── Inter-Bold.ttf │ ├── Inter-Regular.ttf │ ├── SF-Pro-Display-Medium.otf │ └── index.ts ├── globals.css ├── layout.tsx └── sitemap.ts ├── components ├── emails │ ├── reset-password-email.tsx │ └── verification-email.tsx ├── forms │ ├── forgot-password-form.tsx │ ├── login-form.tsx │ ├── register-form.tsx │ └── reset-password-form.tsx ├── icons │ └── index.tsx ├── layout │ ├── footer.tsx │ ├── nav.tsx │ ├── navbar.tsx │ ├── sidebar.tsx │ └── user-dropdown.tsx ├── logo.tsx ├── pages │ └── dashboard │ │ ├── infoCards.tsx │ │ └── main.tsx ├── shared │ ├── icons │ │ ├── buymeacoffee.tsx │ │ ├── expanding-arrow.tsx │ │ ├── github.tsx │ │ ├── google.tsx │ │ ├── index.tsx │ │ ├── loading-circle.tsx │ │ ├── loading-dots.module.css │ │ ├── loading-dots.tsx │ │ ├── loading-spinner.module.css │ │ ├── loading-spinner.tsx │ │ └── twitter.tsx │ ├── loading-dots.module.css │ ├── loading-dots.tsx │ ├── modal.tsx │ ├── popover.tsx │ ├── tech-list.tsx │ ├── tooltip.tsx │ └── user-avatar.tsx └── ui │ ├── avatar │ └── index.tsx │ ├── button.tsx │ ├── card │ ├── card-skeleton.tsx │ ├── index.tsx │ └── info-card │ │ └── index.tsx │ ├── chart │ ├── area.tsx │ └── sparkarea.tsx │ ├── form │ ├── index.tsx │ └── input.tsx │ ├── header.tsx │ ├── input.tsx │ ├── label.tsx │ ├── shell.tsx │ ├── skeleton.tsx │ └── toast │ ├── index.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── config ├── dashboard.ts ├── email.ts └── site.ts ├── lib ├── axios.ts ├── constants.ts ├── hooks │ ├── use-intersection-observer.ts │ ├── use-local-storage.ts │ ├── use-media-query.ts │ └── use-scroll.ts ├── lemon.ts ├── prisma.ts ├── subscription.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── prisma ├── migrations │ ├── 20240102080708_init │ │ └── migration.sql │ ├── 20240223130605_google_login │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── authjs.webp ├── clogo.png ├── logo.png ├── mockup.png ├── mockup3.png ├── next.svg ├── prisma.svg ├── thirteen.svg ├── vercel-logotype.svg └── vercel.svg ├── tailwind.config.js ├── tsconfig.json ├── types ├── index.d.ts └── next-auth.d.ts └── validations ├── auth.ts └── email.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Create a free PostgreSQL database: https://vercel.com/postgres 2 | POSTGRES_PRISMA_URL="" 3 | POSTGRES_URL_NON_POOLING="" 4 | 5 | # Follow the instructions here to create a Google OAuth app: https://refine.dev/blog/nextauth-google-github-authentication-nextjs/#for-googleprovider-make-sure-you-have-a-google-account 6 | GOOGLE_CLIENT_ID="" 7 | GOOGLE_CLIENT_SECRET="" 8 | 9 | # Only for production – generate one here: https://generate-secret.vercel.app/32 10 | NEXTAUTH_SECRET="" 11 | NEXTAUTH_URL="" 12 | 13 | #check here https://docs.lemonsqueezy.com/guides/developer-guide/getting-started 14 | LEMONSQUEEZY_API_KEY="" 15 | LEMONSQUEEZY_STORE_ID="" 16 | 17 | #these can be different for your product 18 | NEXT_PUBLIC_LEMONSQUEEZY_MONTH_PRODUCT_ID="" 19 | NEXT_PUBLIC_LEMONSQUEEZY_YEAR_PRODUCT_ID="" 20 | LEMONSQUEEZY_WEBHOOK_SECRET="" 21 | 22 | # check here https://resend.com/docs/api-reference/api-keys/create-api-key 23 | RESEND_APIKEY="" 24 | RESEND_EMAIL_FROM="" 25 | 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.development 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # misc 40 | .vscode 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | node_modules 3 | .next 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Steven Tey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ### A Next.js SaaS Lemonsqueezy Boilerplate 3 | 4 | This repository serves as a starting point (or boilerplate) for any Next.js SaaS project that requires user authentication, management, and subscription payments. It currently uses Prisma with a Postgres database and is built using shadcn/ui components as well as custom sass components as needed. 5 | 6 | ## Live: [https://getsaasboilerplate.com/](https://getsaasboilerplate.com) 7 | 8 | ## The stack 9 | > Changes or additions to teh stack will be updated here 10 | 11 | - Next.js 12 | - Tailwind 13 | - Radix UI 14 | - NextAuth 15 | - Typescript 16 | - Prisma 17 | - Postgresql 18 | - LemonSqueezy (Subscriptions) 19 | - Sass 20 | - shadcn/ui 21 | - Resend 22 | - Lucide icons 23 | 24 | ## Features 25 | > Features are developed in no specific order 26 | 27 | - [x] App directory 28 | - [x] Route Groups 29 | - [x] Intercepting & Parallel Routes 30 | - [x] CSR/SSR 31 | - [x] Subscriptions with Lemon Squeezy 32 | - [x] Basic SEO 33 | - [x] User Profiles 34 | - [x] Account Pages 35 | - [x] Custom Components 36 | - [x] Toast Message 37 | - [ ] Light/Dark Modes 38 | - [x] Responsive Design 39 | - [ ] Dasboard layouts 40 | - [ ] Cookies 41 | - [ ] Intl 42 | - [ ] Custom errors 43 | 44 | ## Setup 45 | Create a `.env` file and generate NextAuth secret using: 46 | 47 | ```bash 48 | openssl rand -base64 32 49 | ``` 50 | 51 | 52 | ## Getting Started 53 | ```bash 54 | npx prisma migrate dev --name init 55 | npm install 56 | npm run dev 57 | ``` 58 | 59 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 60 | -------------------------------------------------------------------------------- /actions/email.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import crypto from "crypto"; 4 | 5 | import { getUserByEmail, getUserByResetPasswordToken } from "./user"; 6 | 7 | import prisma from "@/lib/prisma"; 8 | import { resend } from "../config/email"; 9 | import { ResetPasswordEmail } from "@/components/emails/reset-password-email"; 10 | import { 11 | PasswordResetFormInput, 12 | PasswordUpdateFormInputExtended, 13 | passwordResetSchema, 14 | passwordUpdateSchemaExtended, 15 | } from "validations/auth"; 16 | import { hash } from "bcrypt"; 17 | 18 | export async function resetPassword( 19 | rawInput: PasswordResetFormInput, 20 | ): Promise<"invalid-input" | "not-found" | "error" | "success"> { 21 | try { 22 | const validatedInput = passwordResetSchema.safeParse(rawInput); 23 | if (!validatedInput.success) return "invalid-input"; 24 | 25 | const user = await getUserByEmail(validatedInput.data.email); 26 | if (!user) return "not-found"; 27 | 28 | const today = new Date(); 29 | const resetPasswordToken = crypto.randomBytes(32).toString("base64url"); 30 | const resetPasswordTokenExpiry = new Date( 31 | today.setDate(today.getDate() + 1), 32 | ); // 24 hours from now 33 | 34 | const userUpdated = await prisma.user.update({ 35 | where: { 36 | id: user.id, 37 | }, 38 | data: { 39 | resetPasswordToken, 40 | resetPasswordTokenExpiry, 41 | }, 42 | }); 43 | 44 | const emailSent = await resend.emails.send({ 45 | from: 'Hello ', 46 | to: [validatedInput.data.email], 47 | subject: "Reset your password", 48 | react: ResetPasswordEmail({ 49 | email: validatedInput.data.email, 50 | resetPasswordToken, 51 | }), 52 | }); 53 | 54 | return userUpdated && emailSent ? "success" : "error"; 55 | } catch (error) { 56 | console.error(error); 57 | return "error"; 58 | } 59 | } 60 | 61 | export async function updatePassword( 62 | rawInput: PasswordUpdateFormInputExtended, 63 | ): Promise<"invalid-input" | "not-found" | "expired" | "error" | "success"> { 64 | try { 65 | const validatedInput = passwordUpdateSchemaExtended.safeParse(rawInput); 66 | if ( 67 | !validatedInput.success || 68 | validatedInput.data.password !== validatedInput.data.confirmPassword 69 | ) 70 | return "invalid-input"; 71 | 72 | const user = await getUserByResetPasswordToken( 73 | validatedInput.data.resetPasswordToken, 74 | ); 75 | if (!user) return "not-found"; 76 | 77 | const resetPasswordExpiry = user.resetPasswordTokenExpiry; 78 | if (!resetPasswordExpiry || resetPasswordExpiry < new Date()) 79 | return "expired"; 80 | 81 | const passwordHash = await hash(validatedInput.data.password, 10); 82 | 83 | const userUpdated = await prisma.user.update({ 84 | where: { 85 | id: user.id, 86 | }, 87 | data: { 88 | password: passwordHash, 89 | resetPasswordToken: null, 90 | resetPasswordTokenExpiry: null, 91 | }, 92 | }); 93 | 94 | return userUpdated ? "success" : "error"; 95 | } catch (error) { 96 | console.error(error); 97 | throw new Error("Error updating password"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /actions/user.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import prisma from "@/lib/prisma"; 4 | import { type User } from "@prisma/client" 5 | 6 | 7 | export async function getUserByResetPasswordToken( 8 | resetPasswordToken: string 9 | ): Promise { 10 | try { 11 | return await prisma.user.findUnique({ 12 | where: { 13 | resetPasswordToken, 14 | }, 15 | }) 16 | } catch (error) { 17 | console.error(error) 18 | throw new Error("Error getting user by reset password token") 19 | } 20 | } 21 | 22 | export async function getUserByEmail( 23 | email: string 24 | ): Promise { 25 | try { 26 | return await prisma.user.findUnique({ 27 | where: { 28 | email, 29 | }, 30 | }) 31 | } catch (error) { 32 | console.error(error) 33 | throw new Error("Error getting user by reset password token") 34 | } 35 | } -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface AuthLayoutProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export default function AuthLayout({ children }: AuthLayoutProps): JSX.Element { 8 | return ( 9 | <> 10 |
11 |
12 |
13 |
14 | 37 |
38 | 45 |
46 |
47 |
48 | {children} 49 |
50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/(auth)/login/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import ForgotPasswordForm from "@/components/forms/forgot-password-form"; 4 | import { Logo } from "@/components/logo"; 5 | 6 | export default function ForgotPassword() { 7 | return ( 8 |
9 |
10 | 11 |

Password Reset

12 |

13 | Enter your email to receive a reset link 14 |

15 |
16 | 17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import LoginForm from "@/components/forms/login-form" 3 | import Link from "next/link"; 4 | import { getServerSession } from "next-auth"; 5 | import { authOptions } from "@/app/api/auth/[...nextauth]/route"; 6 | import { Logo } from "@/components/logo"; 7 | 8 | export default async function Login() { 9 | const session = await getServerSession(authOptions); 10 | return ( 11 |
12 |
13 | 14 |

Sign In

15 |

16 | Use your email and password to sign in 17 |

18 |
19 | 20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /app/(auth)/login/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import ResetPasswordForm from "@/components/forms/reset-password-form"; 2 | import { Logo } from "@/components/logo"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | interface PasswordUpdatePageProps { 7 | searchParams: { [key: string]: string | string[] | undefined } 8 | } 9 | 10 | export default function ResetPassword({ 11 | searchParams, 12 | }: PasswordUpdatePageProps) { 13 | return ( 14 |
15 |
16 | 17 |

Password Reset

18 |

19 | Enter your email to receive a reset link 20 |

21 |
22 | 23 |
24 | ); 25 | } -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import RegisterForm from "@/components/forms/register-form"; 4 | import { Logo } from "@/components/logo"; 5 | 6 | export default function Register() { 7 | return ( 8 |
9 |
10 | 11 |

Sign Up

12 |

13 | Create an account with your email and password 14 |

15 |
16 | 17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /app/(mainPage)/layout.tsx: -------------------------------------------------------------------------------- 1 | import "../globals.css"; 2 | import { Analytics } from "@vercel/analytics/react"; 3 | import Nav from "@/components/layout/nav"; 4 | import Footer from "@/components/layout/footer"; 5 | 6 | export default async function MainPageLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 | <> 13 |
14 |
15 |
16 |
17 | 40 |
41 | 48 |
49 |
50 |
51 |
52 |
53 |
55 |
56 |
{children}
57 |
58 | 59 |
60 |
61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /app/(mainPage)/page.tsx: -------------------------------------------------------------------------------- 1 | import TechList from "@/components/shared/tech-list"; 2 | import { Button, buttonVariants } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | import { siteConfig } from "config/site"; 5 | import Image from "next/image"; 6 | 7 | export default async function Home() { 8 | return ( 9 |
10 |
11 |
12 |
13 |

14 | Lorem ipsum 15 |
16 | Lorem ipsum Header 17 |

18 |

19 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 20 |

21 |
22 | 30 | 38 |
39 |
40 |
41 | hero 48 |
49 |
50 |
51 |
52 |
53 |
54 |

55 | Lorem ipsum header 56 |

57 |

58 | Lorem Ipsum 59 |

60 |
61 |
62 |
63 |
64 |
65 |
66 | 75 | 76 | 77 |
78 |

79 | Lorem Ipsum 80 |

81 |
82 |
83 |

84 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 85 |

86 | 87 | Learn More 88 | 97 | 98 | 99 | 100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | 116 | 117 | 118 | 119 |
120 |

121 | Lorem Ipsum 122 |

123 |
124 |
125 |

126 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 127 |

128 | 129 | Learn More 130 | 139 | 140 | 141 | 142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 | 158 | 159 | 160 | 161 | 162 |
163 |

164 | Lorem ipsum 165 |

166 |
167 |
168 |

169 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 170 |

171 | 172 | Learn More 173 | 182 | 183 | 184 | 185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | 201 | 202 | 203 | 204 | 205 |
206 |

207 | Lorem ipsum 208 |

209 |
210 |
211 |

212 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 213 |

214 | 215 | Learn More 216 | 225 | 226 | 227 | 228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 | 244 | 245 | 246 | 247 | 248 |
249 |

250 | Lorem ipsum 251 |

252 |
253 |
254 |

255 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 256 |

257 | 258 | Learn More 259 | 268 | 269 | 270 | 271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 | 287 | 288 | 289 | 290 | 291 |
292 |

293 | Lorem ipsum 294 |

295 |
296 |
297 |

298 | Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups. 299 |

300 | 301 | Learn More 302 | 311 | 312 | 313 | 314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |

327 | What Are You Looking For? 328 | 329 | {" "} 330 | 331 |

332 |

333 | There are many variations of passages of Lorem Ipsum but the 334 | majority have suffered in some form. 335 |

336 | 344 |
345 |
346 |
347 |
348 |
349 |
350 | 351 | 358 | 366 | 373 | 379 | 380 | 381 | 382 | 389 | 397 | 405 | 411 | 412 | 413 |
414 |
415 |
416 |
417 |
418 |

419 | Testimonials 420 |

421 |

422 | What Our Client Say 423 |

424 |
425 |
426 |
427 |
428 | 434 | 435 | 436 |

437 | Synth chartreuse iPhone lomo cray raw denim brunch everyday 438 | carry neutra before they sold out fixie 9s microdosing. Tacos 439 | pinterest fanny pack venmo, post-ironic heirloom try-hard 440 | pabst authentic iceland. 441 |

442 | 443 | testimonial 450 | 451 | 452 | John Doe 453 | 454 | UI DEVELOPER 455 | 456 | 457 |
458 |
459 |
460 |
461 | 467 | 468 | 469 |

470 | Synth chartreuse iPhone lomo cray raw denim brunch everyday 471 | carry neutra before they sold out fixie 90s microdosing. Tacos 472 | pinterest fanny pack venmo, post-ironic heirloom try-hard 473 | pabst authentic iceland. 474 |

475 | 476 | testimonial 483 | 484 | 485 | John Doe 486 | 487 | DESIGNER 488 | 489 | 490 |
491 |
492 |
493 |
494 |
495 |
499 |
500 |
501 |
502 |
506 | 507 | Pricing Table 508 | 509 |

510 | Our Pricing Plan 511 |

512 |

513 | There are many variations of passages of Lorem Ipsum available 514 | but the majority have suffered alteration in some form. 515 |

516 |
517 |
518 |
519 |
520 |
521 |
525 | 526 | Basic 527 | 528 |

529 | $ 530 | 39 531 | 532 | {" "} 533 | Per Month 534 | 535 |

536 |
537 |

538 | Features 539 |

540 |
541 |

542 | 1 User 543 |

544 |

545 | All UI components 546 |

547 |

548 | Lifetime access 549 |

550 |

551 | Free updates 552 |

553 |

554 | Use on 1 (one) project 555 |

556 |

557 | 3 Months support 558 |

559 |
560 |
561 |
562 | 565 |
566 |
567 |
568 |
569 |
573 | 574 | Business 575 | 576 |

577 | $ 578 | 1,299 579 | 580 | {" "} 581 | Per Month 582 | 583 |

584 |
585 |

586 | Features 587 |

588 |
589 |

590 | Unlimited Users 591 |

592 |

593 | All UI components 594 |

595 |

596 | Lifetime access 597 |

598 |

599 | Free updates 600 |

601 |

602 | Use on unlimited project 603 |

604 |

605 | Lifetime support 606 |

607 |
608 |
609 |
610 | 613 |
614 |
615 |
616 |
617 |
621 |

622 | Recommended 623 |

624 | 625 | Premium 626 | 627 |

628 | $ 629 | 259 630 | 631 | {" "} 632 | Per Month 633 | 634 |

635 |
636 |

637 | Features 638 |

639 |
640 |

641 | 10 Users 642 |

643 |

644 | All UI components 645 |

646 |

647 | Lifetime access 648 |

649 |

650 | Free updates 651 |

652 |

653 | Use on 20 projects 654 |

655 |

656 | 3 Years support 657 |

658 |
659 |
660 |
661 | 664 |
665 |
666 |
667 |
668 |
669 |
670 |
671 | ); 672 | } 673 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from "next-auth"; 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | import { getServerSession } from 'next-auth/next' 6 | import CredentialsProvider from 'next-auth/providers/credentials' 7 | import GoogleProvider from 'next-auth/providers/google' 8 | import { compare } from "bcrypt"; 9 | 10 | export const authOptions:NextAuthOptions = { 11 | adapter: PrismaAdapter(prisma), 12 | providers: [ 13 | GoogleProvider({ 14 | clientId: process.env.GOOGLE_CLIENT_ID as string, 15 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 16 | }), 17 | CredentialsProvider({ 18 | credentials: { 19 | email: { label: "Email", type: "email" }, 20 | password: { label: "Password", type: "password" } 21 | }, 22 | async authorize(credentials) { 23 | const { email, password } = credentials ?? {} 24 | if (!email || !password) { 25 | throw new Error("Missing username or password"); 26 | } 27 | const user = await prisma.user.findUnique({ 28 | where: { 29 | email, 30 | }, 31 | }); 32 | const userPass = user?.password as string 33 | // if user doesn't exist or password doesn't match 34 | if (!user || !(await compare(password, userPass))) { 35 | throw new Error("Invalid username or password"); 36 | } 37 | return user; 38 | }, 39 | }), 40 | ], 41 | pages: { 42 | signIn: '/login', 43 | }, 44 | session: { 45 | strategy: 'jwt' 46 | }, 47 | callbacks: { 48 | // Add user ID to session from token 49 | session: async ({ session, token }) => { 50 | if (session?.user) { 51 | session.user.id = token.id; 52 | session.user.email = token.email; 53 | session.user.subscriptionId = token.subscriptionId; 54 | } 55 | return session 56 | }, 57 | async jwt({ token, user }) { 58 | const dbUser = await prisma.user.findFirst({ 59 | where: { 60 | email: token.email as string, 61 | }, 62 | }) 63 | 64 | if (!dbUser) { 65 | if (user) { 66 | token.id = user?.id 67 | } 68 | return token 69 | } 70 | 71 | return { 72 | id: dbUser.id, 73 | name: dbUser.name, 74 | email: dbUser.email, 75 | subscriptionId: dbUser.subscriptionId as string 76 | } 77 | }, 78 | } 79 | } 80 | 81 | export function getSession() { 82 | return getServerSession(authOptions) 83 | } 84 | 85 | const handler = NextAuth(authOptions); 86 | 87 | export { handler as GET, handler as POST }; 88 | -------------------------------------------------------------------------------- /app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/lib/prisma"; 2 | import { hash } from "bcrypt"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(req: Request) { 6 | const { email, password } = await req.json(); 7 | const exists = await prisma.user.findUnique({ 8 | where: { 9 | email, 10 | }, 11 | }); 12 | if (exists) { 13 | return NextResponse.json({ error: "User already exists" }, { status: 400 }); 14 | } else { 15 | const user = await prisma.user.create({ 16 | data: { 17 | email, 18 | password: await hash(password, 10), 19 | }, 20 | }); 21 | return NextResponse.json(user); 22 | } 23 | } -------------------------------------------------------------------------------- /app/api/payment/cancel-subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 4 | import { axios } from "@/lib/axios"; 5 | 6 | export async function POST(request: Request) { 7 | try { 8 | const { email } = await request.json(); 9 | 10 | const user = await prisma.user.findUnique({ 11 | where: { email }, 12 | select: { 13 | id: true, 14 | email: true, 15 | subscriptionId: true, 16 | variantId: true, 17 | currentPeriodEnd: true, 18 | }, 19 | }); 20 | 21 | if (!user) return NextResponse.json({ message: "User not found" }, { status: 404 }); 22 | 23 | const { isPro } = await getUserSubscriptionPlan(user.email); 24 | 25 | if (!isPro) return NextResponse.json({ message: "You are not subscribed" }, { status: 402 }); 26 | 27 | await axios.patch( 28 | `https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`, 29 | { 30 | data: { 31 | type: "subscriptions", 32 | id: user.subscriptionId, 33 | attributes: { 34 | cancelled: true, 35 | }, 36 | }, 37 | }, 38 | { 39 | headers: { 40 | Accept: "application/vnd.api+json", 41 | "Content-Type": "application/vnd.api+json", 42 | Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, 43 | }, 44 | } 45 | ); 46 | 47 | const endsAt = user.currentPeriodEnd?.toLocaleString(); 48 | 49 | return NextResponse.json({ 50 | message: `Your subscription has been cancelled. You will still have access to our product until '${endsAt}'`, 51 | }); 52 | } catch (err) { 53 | console.log({ err }); 54 | if (err instanceof Error) { 55 | return NextResponse.json({ message: err.message }, { status: 500 }); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/api/payment/change-subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 4 | import { axios } from "@/lib/axios"; 5 | 6 | export async function POST(request: Request) { 7 | try { 8 | const { email,variantId } = await request.json(); 9 | 10 | 11 | const user = await prisma.user.findUnique({ 12 | where: { email }, 13 | select: { 14 | id: true, 15 | email: true, 16 | subscriptionId: true, 17 | variantId: true, 18 | currentPeriodEnd: true, 19 | }, 20 | }); 21 | 22 | if (!user) return NextResponse.json({ message: "User not found" }, { status: 404 }); 23 | 24 | const { isPro } = await getUserSubscriptionPlan(user.email); 25 | 26 | if (!isPro) return NextResponse.json({ message: "You are not subscribed" }, { status: 402 }); 27 | 28 | await axios.patch( 29 | `https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`, 30 | { 31 | data: { 32 | type: "subscriptions", 33 | id: user.subscriptionId, 34 | attributes: { 35 | product_id: 158693, 36 | variant_id: Number(variantId), 37 | invoice_immediately: true 38 | }, 39 | }, 40 | }, 41 | { 42 | headers: { 43 | Accept: "application/vnd.api+json", 44 | "Content-Type": "application/vnd.api+json", 45 | Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, 46 | }, 47 | } 48 | ); 49 | 50 | return NextResponse.json({ 51 | message: `You are currently on the yearly plan.`, 52 | }); 53 | } catch (err) { 54 | console.log({ err }); 55 | if (err instanceof Error) { 56 | return NextResponse.json({ message: err.message }, { status: 500 }); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /app/api/payment/resume-subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/lib/prisma"; 3 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 4 | import { axios } from "@/lib/axios"; 5 | 6 | export async function POST(request: Request) { 7 | try { 8 | const { email } = await request.json(); 9 | 10 | const user = await prisma.user.findUnique({ 11 | where: { email }, 12 | select: { 13 | id: true, 14 | email: true, 15 | subscriptionId: true, 16 | variantId: true, 17 | currentPeriodEnd: true, 18 | }, 19 | }); 20 | 21 | if (!user) 22 | return NextResponse.json({ message: "User not found" }, { status: 404 }); 23 | 24 | const { isPro } = await getUserSubscriptionPlan(user.email); 25 | 26 | if (!isPro) 27 | return NextResponse.json( 28 | { message: "You are not subscribed" }, 29 | { status: 402 }, 30 | ); 31 | 32 | await axios.patch( 33 | `https://api.lemonsqueezy.com/v1/subscriptions/${user.subscriptionId}`, 34 | { 35 | data: { 36 | type: "subscriptions", 37 | id: user.subscriptionId, 38 | attributes: { 39 | cancelled: false, 40 | }, 41 | }, 42 | }, 43 | { 44 | headers: { 45 | Accept: "application/vnd.api+json", 46 | "Content-Type": "application/vnd.api+json", 47 | Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`, 48 | }, 49 | }, 50 | ); 51 | 52 | return NextResponse.json({ 53 | message: `Your subscription has been resumed.`, 54 | }); 55 | } catch (err) { 56 | console.log({ err }); 57 | if (err instanceof Error) { 58 | return NextResponse.json({ message: err.message }, { status: 500 }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/api/payment/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import type { CreateCheckoutResult } from "lemonsqueezy.ts/dist/types"; 2 | import { NextResponse } from "next/server"; 3 | import { axios } from "@/lib/axios"; 4 | import { client } from "@/lib/lemon"; 5 | import prisma from "@/lib/prisma"; 6 | 7 | export type CreateCheckoutResponse = { 8 | checkoutURL: string; 9 | }; 10 | 11 | export async function POST(request: Request) { 12 | try { 13 | const { email, variantId } = await request.json(); 14 | 15 | const user = await prisma.user.findUnique({ 16 | where: { email }, 17 | select: { id: true, email: true }, 18 | }); 19 | 20 | if (!user) 21 | return NextResponse.json( 22 | { message: "Your account was not found" }, 23 | { status: 404 }, 24 | ); 25 | if (!variantId) { 26 | return NextResponse.json( 27 | { error: true, message: "No variant ID was provided." }, 28 | { status: 400 }, 29 | ); 30 | } 31 | const attributes = { 32 | checkout_options: { 33 | 'embed': true, 34 | 'media': false, 35 | 'button_color': '#fde68a' 36 | }, 37 | checkout_data: { 38 | 'email': email, // Displays in the checkout form 39 | 'custom': { 40 | 'user_id': user.id // Sent in the background; visible in webhooks and API calls 41 | } 42 | }, 43 | product_options: { 44 | 'enabled_variants': [variantId], // Only show the selected variant in the checkout 45 | 'redirect_url': `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`, 46 | 'receipt_link_url': `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`, 47 | 'receipt_button_text': 'Go to your account', 48 | 'receipt_thank_you_note': 'Thank you for signing up to Lemonstand!' 49 | } 50 | } 51 | 52 | try { 53 | const checkout = await client.createCheckout({ 54 | store: (process.env.LEMONSQUEEZY_STORE_ID as string), 55 | variant: variantId, 56 | checkout_data: { 57 | email: user.email, 58 | custom: [user.id] 59 | }, 60 | }) 61 | console.log(checkout) 62 | 63 | return NextResponse.json({ checkoutURL: checkout.data.attributes.url }, { status: 201 }); 64 | } catch (e:any) { 65 | console.log(e) 66 | return NextResponse.json({'error': true, 'message': e.message}, {status: 400}) 67 | } 68 | 69 | 70 | } 71 | catch(err:any) { 72 | return NextResponse.json({ message: err.message || err }, { status: 500 }); 73 | } 74 | } -------------------------------------------------------------------------------- /app/api/payment/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import crypto from "crypto"; 3 | import { headers } from "next/headers"; 4 | import { NextResponse } from "next/server"; 5 | import rawBody from "raw-body"; 6 | import { Readable } from "stream"; 7 | import { client } from "@/lib/lemon"; 8 | import prisma from "@/lib/prisma"; 9 | 10 | export async function POST(request: Request) { 11 | const body = await rawBody(Readable.from(Buffer.from(await request.text()))); 12 | const headersList = headers(); 13 | const payload = JSON.parse(body.toString()); 14 | const sigString = headersList.get("x-signature"); 15 | const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET as string; 16 | const hmac = crypto.createHmac("sha256", secret); 17 | const digest = Buffer.from(hmac.update(body).digest("hex"), "utf8"); 18 | const signature = Buffer.from( 19 | Array.isArray(sigString) ? sigString.join("") : sigString || "", 20 | "utf8" 21 | ); 22 | console.log(payload) 23 | 24 | // validate signature 25 | if (!crypto.timingSafeEqual(digest, signature)) { 26 | return NextResponse.json({ message: "Invalid signature" }, { status: 403 }); 27 | } 28 | 29 | const userId = payload.meta.custom_data[0]; 30 | const email = payload.data.attributes.user_email; 31 | 32 | // Check if custom defined data i.e. the `userId` is there or not 33 | if (!userId) { 34 | return NextResponse.json({ message: "No userId provided" }, { status: 403 }); 35 | } 36 | 37 | switch (payload.meta.event_name) { 38 | case "subscription_created": { 39 | const subscription = await client.retrieveSubscription({ id: payload.data.id }); 40 | 41 | await prisma.user.update({ 42 | where: { email }, 43 | data: { 44 | subscriptionId: `${subscription.data.id}`, 45 | customerId: `${payload.data.attributes.customer_id}`, 46 | variantId: subscription.data.attributes.variant_id, 47 | currentPeriodEnd: subscription.data.attributes.renews_at 48 | }, 49 | }); 50 | } 51 | 52 | case "subscription_updated": { 53 | const subscription = await client.retrieveSubscription({ id: payload.data.id }); 54 | 55 | const user = await prisma.user.findUnique({ 56 | where: { email }, 57 | select: { subscriptionId: true }, 58 | }); 59 | 60 | if (!user || !user.subscriptionId) return; 61 | 62 | await prisma.user.update({ 63 | where: { email }, 64 | data: { 65 | variantId: subscription.data.attributes.variant_id, 66 | currentPeriodEnd: subscription.data.attributes.renews_at, 67 | }, 68 | }); 69 | } 70 | 71 | default: { 72 | return new Response('Done'); 73 | } 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /app/dashboard/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { CardSkeleton } from "@/components/ui/card/card-skeleton"; 2 | import { DashboardHeader } from "@/components/ui/header"; 3 | import { DashboardShell } from "@/components/ui/shell"; 4 | 5 | export default function DashboardBillingLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/app/api/auth/[...nextauth]/route"; 2 | 3 | import { Session, getServerSession } from "next-auth"; 4 | import { redirect } from "next/navigation"; 5 | import React from "react"; 6 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 7 | import SubscriptionForm from "./subscription-form"; 8 | import { DashboardShell } from "@/components/ui/shell"; 9 | import { DashboardHeader } from "@/components/ui/header"; 10 | 11 | const Billing = async () => { 12 | const session = (await getServerSession(authOptions)) as Session; 13 | if (!session) { 14 | redirect("/login"); 15 | } 16 | 17 | const subscription = await getUserSubscriptionPlan( 18 | session.user?.email as string, 19 | ); 20 | 21 | return ( 22 | <> 23 | 24 | 28 |
29 | 33 |
34 |
35 | 36 | ); 37 | }; 38 | 39 | export default Billing; 40 | -------------------------------------------------------------------------------- /app/dashboard/billing/subscription-form/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { axios } from "@/lib/axios"; 4 | import { formatDate } from "@/lib/utils"; 5 | import { useRouter } from "next/navigation"; 6 | import { 7 | Card, 8 | CardDescription, 9 | CardFooter, 10 | CardHeader, 11 | CardTitle, 12 | } from "@/components/ui/card"; 13 | import { toast } from "@/components/ui/toast/use-toast"; 14 | import { Icons } from "@/components/icons"; 15 | import { Button } from "@/components/ui/button"; 16 | 17 | type SubscriptionsProps = { 18 | email: string; 19 | subscriptionPlan: any; 20 | }; 21 | 22 | const SubscriptionForm = ({ email, subscriptionPlan }: SubscriptionsProps) => { 23 | const router = useRouter(); 24 | const [isLoading, setLoading] = useState(false); 25 | const cancelSubscription = async () => { 26 | try { 27 | setLoading(true); 28 | const res = await axios.post( 29 | "/api/payment/cancel-subscription", 30 | { 31 | email, 32 | }, 33 | ); 34 | 35 | router.refresh(); 36 | return toast({ 37 | title: "Your subscription has been cancelled", 38 | description: res.message, 39 | }); 40 | } catch (err) { 41 | return toast({ 42 | title: "Something went wrong.", 43 | description: "Please refresh the page and try again.", 44 | variant: "destructive", 45 | }); 46 | } finally { 47 | setLoading(false); 48 | } 49 | }; 50 | 51 | const resumeSubscription = async () => { 52 | try { 53 | setLoading(true); 54 | const res = await axios.post( 55 | "/api/payment/resume-subscription", 56 | { 57 | email, 58 | }, 59 | ); 60 | 61 | router.refresh(); 62 | return toast({ 63 | title: res.message, 64 | }); 65 | } catch (err) { 66 | return toast({ 67 | title: "Something went wrong.", 68 | description: "Please refresh the page and try again.", 69 | variant: "destructive", 70 | }); 71 | } finally { 72 | setLoading(false); 73 | } 74 | }; 75 | 76 | const changeYearlySubscription = async (variantId: string) => { 77 | try { 78 | setLoading(true); 79 | const res = await axios.post( 80 | "/api/payment/change-subscription", 81 | { 82 | email, 83 | variantId, 84 | }, 85 | ); 86 | 87 | router.refresh(); 88 | return toast({ 89 | title: "You are currently on the yearly plan.", 90 | description: res.message, 91 | }); 92 | } catch (err) { 93 | return toast({ 94 | title: "Something went wrong.", 95 | description: "Please refresh the page and try again.", 96 | variant: "destructive", 97 | }); 98 | } finally { 99 | setLoading(false); 100 | } 101 | }; 102 | 103 | const changePlan = () => { 104 | router.push('/dashboard/subscription') 105 | } 106 | 107 | return ( 108 | <> 109 | 110 | 111 | Subscription Plan 112 | 113 | You are currently on the{" "} 114 | 115 | {!subscriptionPlan ? "FREE" : subscriptionPlan.name.toUpperCase()} 116 | {" "} 117 | plan. 118 | 119 | {!subscriptionPlan && } 122 | 123 | {subscriptionPlan && ( 124 | 125 |
126 | {!subscriptionPlan?.isCanceled && ( 127 | 137 | )} 138 | {subscriptionPlan?.isCanceled && ( 139 | 149 | )} 150 | {!subscriptionPlan?.isCanceled && 151 | subscriptionPlan?.variantId !== 152 | Number( 153 | process.env.NEXT_PUBLIC_LEMONSQUEEZY_YEAR_PRODUCT_ID, 154 | ) && ( 155 | 170 | )} 171 |
172 | 173 |

174 | {subscriptionPlan?.isCanceled 175 | ? "Your plan will be canceled on " 176 | : "Your plan renews on "} 177 | {formatDate(subscriptionPlan?.currentPeriodEnd as number)}. 178 |

179 |
180 | )} 181 |
182 | 183 | ); 184 | }; 185 | 186 | export default SubscriptionForm; 187 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/layout/footer" 2 | import Nav from "@/components/layout/nav" 3 | import { DashboardNav } from "@/components/layout/sidebar" 4 | import { dashboardConfig } from "config/dashboard" 5 | 6 | interface DashboardLayoutProps { 7 | children?: React.ReactNode 8 | } 9 | 10 | export default async function DashboardLayout({ 11 | children, 12 | }: DashboardLayoutProps) { 13 | 14 | return ( 15 |
16 |
17 |
18 |
20 |
21 |
22 | 25 |
26 | {children} 27 |
28 |
29 |
30 |
31 | ) 32 | } -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { authOptions } from "../api/auth/[...nextauth]/route"; 3 | import { Session, getServerSession } from "next-auth"; 4 | import { redirect } from "next/navigation"; 5 | import { DashboardShell } from "@/components/ui/shell"; 6 | import { DashboardHeader } from "@/components/ui/header"; 7 | import InfoCards from "@/components/pages/dashboard/infoCards"; 8 | import Main from "@/components/pages/dashboard/main"; 9 | 10 | 11 | const Dashboard = async () => { 12 | const session = (await getServerSession(authOptions)) as Session; 13 | if (!session) { 14 | redirect("/login"); 15 | } 16 | 17 | return ( 18 | <> 19 | 20 | 24 |
25 | 26 |
27 |
28 |
29 | 30 | ); 31 | }; 32 | 33 | export default Dashboard; 34 | -------------------------------------------------------------------------------- /app/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/app/api/auth/[...nextauth]/route'; 2 | import { DashboardHeader } from '@/components/ui/header'; 3 | import { DashboardShell } from '@/components/ui/shell'; 4 | import { Session, getServerSession } from 'next-auth'; 5 | import { redirect } from 'next/navigation'; 6 | import React from 'react' 7 | 8 | type Props = {} 9 | 10 | const Settings = async (props: Props) => { 11 | const session = await getServerSession(authOptions) as Session; 12 | if (!session) { 13 | redirect("/login"); 14 | } 15 | 16 | return ( 17 | 18 | 22 |
23 | Settings. 24 |
25 |
26 | ) 27 | } 28 | 29 | export default Settings -------------------------------------------------------------------------------- /app/dashboard/subscription/page.tsx: -------------------------------------------------------------------------------- 1 | import { Plan } from "./plan"; 2 | import { Session, getServerSession } from "next-auth"; 3 | import { authOptions } from "../../api/auth/[...nextauth]/route"; 4 | import { redirect } from "next/navigation"; 5 | import { getUserByEmail } from "actions/user"; 6 | import { client } from "@/lib/lemon"; 7 | import { DashboardShell } from "@/components/ui/shell"; 8 | import { DashboardHeader } from "@/components/ui/header"; 9 | 10 | export default async function SubscriptionPage() { 11 | const session = (await getServerSession(authOptions)) as Session; 12 | if (!session) { 13 | redirect("/login"); 14 | } 15 | 16 | const user = await getUserByEmail(session.user?.email as string); 17 | if (user?.subscriptionId) { 18 | redirect("/dashboard"); 19 | } 20 | 21 | const variants = await client.listAllVariants(); 22 | const packages = variants.data.filter( 23 | (v) => v.attributes.status === "published", 24 | ); 25 | 26 | return ( 27 | 28 | 32 |
33 | {packages && 34 | packages.map((item) => ( 35 | 43 | ))} 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/dashboard/subscription/plan/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CreateCheckoutResponse } from "@/app/api/payment/subscribe/route"; 4 | import { Icons } from "@/components/icons"; 5 | import LoadingDots from "@/components/shared/loading-dots"; 6 | import { Button } from "@/components/ui/button"; 7 | import { axios } from "@/lib/axios"; 8 | import React, { useState } from "react"; 9 | 10 | type PlanProps = { 11 | name: string; 12 | email: string; 13 | variantId: string; 14 | price: number; 15 | hasTrial?: boolean; 16 | trialInterval?: number; 17 | }; 18 | 19 | export const Plan = ({ 20 | name, 21 | email, 22 | variantId, 23 | price, 24 | hasTrial, 25 | trialInterval, 26 | }: PlanProps) => { 27 | const [loading, setLoading] = useState(false); 28 | const Icon = Icons["check"]; 29 | const onPlanClick = async () => { 30 | try { 31 | setLoading(true); 32 | const { checkoutURL } = await axios.post( 33 | "/api/payment/subscribe", 34 | { email, variantId }, 35 | ); 36 | 37 | window.location.href = checkoutURL; 38 | } catch (err) { 39 | // 40 | } finally { 41 | setLoading(false); 42 | } 43 | }; 44 | return ( 45 |
46 |
47 |

48 | What's included in the {name} plan 49 |

50 |
    51 |
  • 52 | Free Trial {trialInterval} Days 53 |
  • 54 |
  • 55 | Unlimited Users 56 |
  • 57 | 58 |
  • 59 | Custom domain 60 |
  • 61 |
  • 62 | Dashboard Analytics 63 |
  • 64 |
  • 65 | Access to Discord 66 |
  • 67 |
  • 68 | Premium Support 69 |
  • 70 |
71 |
72 |
73 |
74 |

${price}

75 |

76 | Billed {name} 77 |

78 |
79 | 91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /app/fonts/CalSans-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/CalSans-SemiBold.woff -------------------------------------------------------------------------------- /app/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /app/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /app/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /app/fonts/SF-Pro-Display-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eckdev/nextjs-lemonsqueezy-boilerplate/6a5b116ac5d6d9ffdfcfe540eb9721254be36519/app/fonts/SF-Pro-Display-Medium.otf -------------------------------------------------------------------------------- /app/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | import { Inter as FontSans } from "next/font/google"; 3 | 4 | export const sfPro = localFont({ 5 | src: "./CalSans-SemiBold.woff2", 6 | variable: "--font-heading", 7 | }); 8 | 9 | export const inter = FontSans({ 10 | subsets: ["latin"], 11 | variable: "--font-sans", 12 | }) 13 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .container { 6 | width: 100%; 7 | margin-right: auto; 8 | margin-left: auto; 9 | padding-right: 2rem; 10 | padding-left: 2rem; 11 | } 12 | 13 | .animate-right { 14 | animation: right 30s linear infinite alternate; 15 | } 16 | 17 | .animate-left { 18 | animation: left 30s linear infinite alternate; 19 | } 20 | 21 | @keyframes right { 22 | 0% { 23 | transform: translateZ(0); 24 | } 25 | 100% { 26 | transform: translate3d(-100%, 0, 0); 27 | } 28 | } 29 | 30 | @keyframes left { 31 | 0% { 32 | transform: translate3d(-100%, 0, 0); 33 | } 34 | 100% { 35 | transform: translateZ(0); 36 | } 37 | } 38 | 39 | @layer base { 40 | :root { 41 | --background: 210 40% 98%; 42 | --foreground: 222.2 47.4% 11.2%; 43 | 44 | --muted: 210 40% 96.1%; 45 | --muted-foreground: 215.4 16.3% 46.9%; 46 | 47 | --popover: 0 0% 100%; 48 | --popover-foreground: 222.2 47.4% 11.2%; 49 | 50 | --border: 214.3 31.8% 91.4%; 51 | --input: 214.3 31.8% 91.4%; 52 | 53 | --card: 0 0% 100%; 54 | --card-foreground: 222.2 47.4% 11.2%; 55 | 56 | --primary: 208 100% 59%; 57 | --primary-foreground: 210 40% 98%; 58 | 59 | --secondary: 210 40% 96.1%; 60 | --secondary-foreground: 222.2 47.4% 11.2%; 61 | 62 | --accent: 210 40% 96.1%; 63 | --accent-foreground: 222.2 47.4% 11.2%; 64 | 65 | --destructive: 0 100% 50%; 66 | --destructive-foreground: 210 40% 98%; 67 | 68 | --ring: 215 20.2% 65.1%; 69 | 70 | --radius: 0.5rem; 71 | } 72 | 73 | .dark { 74 | --background: 224 71% 4%; 75 | --foreground: 213 31% 91%; 76 | 77 | --muted: 223 47% 11%; 78 | --muted-foreground: 215.4 16.3% 56.9%; 79 | 80 | --accent: 216 34% 17%; 81 | --accent-foreground: 210 40% 98%; 82 | 83 | --popover: 224 71% 4%; 84 | --popover-foreground: 215 20.2% 65.1%; 85 | 86 | --border: 216 34% 17%; 87 | --input: 216 34% 17%; 88 | 89 | --card: 224 71% 4%; 90 | --card-foreground: 213 31% 91%; 91 | 92 | --primary: 210 40% 98%; 93 | --primary-foreground: 222.2 47.4% 1.2%; 94 | 95 | --secondary: 222.2 47.4% 11.2%; 96 | --secondary-foreground: 210 40% 98%; 97 | 98 | --destructive: 0 63% 31%; 99 | --destructive-foreground: 210 40% 98%; 100 | 101 | --ring: 216 34% 17%; 102 | 103 | --radius: 0.5rem; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-sync-scripts */ 2 | import "./globals.css"; 3 | import { sfPro, inter } from "./fonts"; 4 | import { Toaster } from "@/components/ui/toast/toaster"; 5 | import { cn } from "@/lib/utils"; 6 | import { siteConfig } from "config/site"; 7 | 8 | export const metadata = { 9 | title: siteConfig.name, 10 | description:siteConfig.description, 11 | themeColor: "#FFF", 12 | }; 13 | 14 | export default async function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | 3 | export default async function sitemap(): Promise { 4 | return [ 5 | { 6 | url: "https://getsaasboilerplate.com", 7 | lastModified: new Date(), 8 | } 9 | ]; 10 | } 11 | -------------------------------------------------------------------------------- /components/emails/reset-password-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Button, 4 | Container, 5 | Head, 6 | Html, 7 | Preview, 8 | Section, 9 | Tailwind, 10 | Text, 11 | } from "@react-email/components" 12 | 13 | import { siteConfig } from "../../config/site" 14 | 15 | interface ResetPasswordEmailProps { 16 | email: string 17 | resetPasswordToken: string 18 | } 19 | 20 | export function ResetPasswordEmail({ 21 | email, 22 | resetPasswordToken, 23 | }: Readonly): JSX.Element { 24 | const previewText = `${siteConfig.name} password reset.` 25 | 26 | return ( 27 | 28 | 29 | {previewText} 30 | 31 | {previewText} 32 | 33 | 34 | 35 |
36 | Hi, 37 | 38 | Someone just requested a password change for your{" "} 39 | {siteConfig.name} 40 | account associated with {email}. 41 | 42 | 43 | If this was you, you can set a new password here: 44 | 45 | 50 |
51 |
52 | 53 | If you don't want to change your password or didn't 54 | request this, just ignore and delete this message. 55 | 56 | 57 | To keep your account secure, please don't forward this 58 | email to anyone. 59 | 60 |
61 |
62 | 63 | Enjoy{" "} 64 | 65 | {siteConfig.name} 66 | {" "} 67 | and have a nice day! 68 | 69 |
70 |
71 | 72 |
73 | 74 | ) 75 | } -------------------------------------------------------------------------------- /components/emails/verification-email.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Button, 4 | Container, 5 | Head, 6 | Html, 7 | Preview, 8 | Section, 9 | Tailwind, 10 | Text, 11 | } from "@react-email/components" 12 | import { siteConfig } from "../../config/site" 13 | 14 | 15 | interface EmailVerificationEmailProps { 16 | email: string 17 | emailVerificationToken: string 18 | } 19 | 20 | export function VerificationEmail({ 21 | email, 22 | emailVerificationToken, 23 | }: Readonly): JSX.Element { 24 | const previewText = `${siteConfig.name} email verification.` 25 | return ( 26 | 27 | 28 | {previewText} 29 | 30 | {previewText} 31 | 32 | 33 | 34 |
35 | Hi, 36 | 37 | Your email address, {email}, was recently used to sign up at{" "} 38 | 39 | {siteConfig.name} 40 | 41 | . 42 | 43 | 44 | Please verify this address by clicking the button below 45 | 46 | 51 |
52 | 53 |
54 | 55 | If you didn't sign up at {siteConfig.name}, just ignore and 56 | delete this message. 57 | 58 | 59 | Enjoy{" "} 60 | 61 | {siteConfig.name} 62 | {" "} 63 | and have a nice day! 64 | 65 |
66 |
67 | 68 |
69 | 70 | ) 71 | } -------------------------------------------------------------------------------- /components/forms/forgot-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import type { z } from "zod"; 4 | import { Form } from "../ui/form"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useRouter } from "next/navigation"; 8 | import LoadingDots from "../shared/loading-dots"; 9 | import FormInput from "../ui/form/input"; 10 | import { passwordResetSchema } from "validations/auth"; 11 | import { toast } from "../ui/toast/use-toast"; 12 | import { resetPassword } from "actions/email"; 13 | import { Button } from "../ui/button"; 14 | 15 | type PasswordResetFormInputs = z.infer 16 | 17 | const ForgotPasswordForm = () => { 18 | const [loading, setLoading] = useState(false); 19 | const router = useRouter(); 20 | const form = useForm({ 21 | resolver: zodResolver(passwordResetSchema), 22 | defaultValues: { 23 | email: "" 24 | }, 25 | }); 26 | 27 | const onSubmit = async (formData: PasswordResetFormInputs) => { 28 | try { 29 | setLoading(true) 30 | const message = await resetPassword({ 31 | email: formData.email, 32 | }) 33 | alert(message) 34 | 35 | switch (message) { 36 | case "success": 37 | toast({ 38 | title: "Success!", 39 | description: "Check your inbox and verify your email address", 40 | }) 41 | router.push("/login") 42 | break 43 | default: 44 | toast({ 45 | title: "Error sending verification link", 46 | description: "Please try again", 47 | variant: "destructive", 48 | }) 49 | router.push("/register") 50 | } 51 | } catch (error) { 52 | toast({ 53 | title: "Something went wrong", 54 | description: "Please try again", 55 | variant: "destructive", 56 | }) 57 | console.error(error) 58 | } 59 | finally { 60 | setLoading(false) 61 | } 62 | } 63 | 64 | 65 | return ( 66 |
67 | void form.handleSubmit(onSubmit)(...args)} 69 | className="flex flex-col space-y-4 bg-white px-4 py-8 sm:px-16" 70 | > 71 | 72 | 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default ForgotPasswordForm; 90 | -------------------------------------------------------------------------------- /components/forms/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import type { z } from "zod"; 4 | import { Form } from "../ui/form"; 5 | import { useForm } from "react-hook-form"; 6 | import { signInWithPasswordSchema } from "validations/auth"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import Link from "next/link"; 9 | import LoadingDots from "../shared/loading-dots"; 10 | import { signIn } from "next-auth/react"; 11 | import FormInput from "../ui/form/input"; 12 | import { Session } from "next-auth"; 13 | import { Google } from "@/components/shared/icons"; 14 | import { Button, buttonVariants } from "../ui/button"; 15 | import { cn } from "@/lib/utils"; 16 | import { toast } from "@/components/ui/toast/use-toast"; 17 | import { useRouter } from "next/navigation"; 18 | 19 | 20 | type SignInWithEmailFormInputs = z.infer; 21 | 22 | const LoginForm = ({ session }: { session: Session | null }) => { 23 | const [loading, setLoading] = useState(false); 24 | const [isGoogleLoading, setIsGoogleLoading] = useState(false); 25 | 26 | const router = useRouter(); 27 | const form = useForm({ 28 | resolver: zodResolver(signInWithPasswordSchema), 29 | defaultValues: { 30 | email: "", 31 | password: "", 32 | }, 33 | }); 34 | 35 | const onSubmit = async (formData: SignInWithEmailFormInputs) => { 36 | const { email, password } = formData; 37 | setLoading(true); 38 | const signInResult = await signIn("credentials", { 39 | redirect: false, 40 | email, 41 | password, 42 | callbackUrl: "/dashboard", 43 | }); 44 | 45 | setLoading(false); 46 | if (signInResult?.error) { 47 | return toast({ 48 | title: "Something went wrong.", 49 | description: "Your sign in request failed. Please try again.", 50 | variant: "destructive", 51 | }) 52 | } 53 | else{ 54 | router.push(signInResult?.url as string) 55 | } 56 | }; 57 | 58 | return ( 59 | <> 60 |
61 | void form.handleSubmit(onSubmit)(...args)} 63 | className="flex flex-col space-y-4 bg-white px-4 py-8 sm:px-16" 64 | > 65 | 71 | 77 | 89 |
90 |
91 | 92 | Don't have an account?{" "} 93 | 97 | Sign up 98 | {" "} 99 | for free. 100 | 101 |
102 |
103 | Forgot your password? 104 | 109 | Reset now 110 | Reset Password 111 | 112 | . 113 |
114 |
115 | 116 |
117 | 135 |
136 | 137 | 138 | ); 139 | }; 140 | 141 | export default LoginForm; 142 | -------------------------------------------------------------------------------- /components/forms/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import type { z } from "zod"; 4 | import { Form } from "../ui/form"; 5 | import { useForm } from "react-hook-form"; 6 | import { signUpWithPasswordSchema } from "validations/auth"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import { useRouter } from "next/navigation"; 9 | import Link from "next/link"; 10 | import LoadingDots from "../shared/loading-dots"; 11 | import FormInput from "../ui/form/input"; 12 | import { toast } from "../ui/toast/use-toast"; 13 | import { Button } from "../ui/button"; 14 | 15 | type SignUpWithEmailFormInputs = z.infer; 16 | 17 | const RegisterForm = () => { 18 | const [loading, setLoading] = useState(false); 19 | const router = useRouter(); 20 | const form = useForm({ 21 | resolver: zodResolver(signUpWithPasswordSchema), 22 | defaultValues: { 23 | email: "", 24 | password: "", 25 | confirmPassword: "", 26 | }, 27 | }); 28 | 29 | const onSubmit = (formData: SignUpWithEmailFormInputs):void => { 30 | const { email, password } = formData 31 | setLoading(true); 32 | fetch("/api/auth/register", { 33 | method: "POST", 34 | headers: { 35 | "Content-Type": "application/json", 36 | }, 37 | body: JSON.stringify({ 38 | email, 39 | password, 40 | }), 41 | }).then(async (res) => { 42 | setLoading(false); 43 | if (res.status === 200) { 44 | setTimeout(() => { 45 | router.push("/login"); 46 | }, 1000); 47 | } else { 48 | form.reset() 49 | const { error } = await res.json(); 50 | return toast({ 51 | title: "Something went wrong.", 52 | description: error, 53 | variant: "destructive", 54 | }) 55 | } 56 | }); 57 | }; 58 | 59 | return ( 60 |
61 | void form.handleSubmit(onSubmit)(...args)} 63 | className="flex flex-col space-y-4 bg-white px-4 py-8 sm:px-16" 64 | > 65 | 71 | 77 | 83 | 95 |

96 | Do you have an account?{" "} 97 | 98 | Sign in 99 | 100 |

101 | 102 | 103 | ); 104 | }; 105 | 106 | export default RegisterForm; 107 | -------------------------------------------------------------------------------- /components/forms/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import prisma from "@/lib/prisma"; 4 | import type { z } from "zod"; 5 | import { hash } from "bcrypt"; 6 | import { Form } from "../ui/form"; 7 | import { useForm } from "react-hook-form"; 8 | import { zodResolver } from "@hookform/resolvers/zod"; 9 | import { useRouter } from "next/navigation"; 10 | import LoadingDots from "../shared/loading-dots"; 11 | import FormInput from "../ui/form/input"; 12 | import { passwordUpdateSchema } from "validations/auth"; 13 | import { getUserByResetPasswordToken } from "actions/user"; 14 | import { resetPassword, updatePassword } from "actions/email"; 15 | import { toast } from "../ui/toast/use-toast"; 16 | import { Button } from "../ui/button"; 17 | 18 | type PasswordUpdateFormInputs = z.infer; 19 | interface PasswordUpdateFormProps { 20 | resetPasswordToken: string; 21 | } 22 | const ResetPasswordForm = ({ resetPasswordToken }: PasswordUpdateFormProps) => { 23 | const [loading, setLoading] = useState(false); 24 | const router = useRouter(); 25 | const form = useForm({ 26 | resolver: zodResolver(passwordUpdateSchema), 27 | defaultValues: { 28 | password: "", 29 | confirmPassword: "", 30 | }, 31 | }); 32 | 33 | const onSubmit = async (formData: PasswordUpdateFormInputs) => { 34 | const { password, confirmPassword } = formData; 35 | setLoading(true); 36 | try { 37 | const message = await updatePassword({ 38 | password: password, 39 | confirmPassword: confirmPassword, 40 | resetPasswordToken, 41 | }); 42 | 43 | switch (message) { 44 | case "expired": 45 | toast({ 46 | title: "Token is missing or expired", 47 | description: "Please try again", 48 | variant: "destructive", 49 | }); 50 | router.push("/signin"); 51 | break; 52 | case "success": 53 | toast({ 54 | title: "Success!", 55 | description: "You can now sign in with new password", 56 | }); 57 | router.push("/signin"); 58 | break; 59 | default: 60 | toast({ 61 | title: "Error updating password", 62 | description: "Please try again", 63 | variant: "destructive", 64 | }); 65 | } 66 | } catch (error) { 67 | toast({ 68 | title: "Something went wrong", 69 | description: "Please try again", 70 | variant: "destructive", 71 | }); 72 | console.error(error); 73 | } 74 | }; 75 | 76 | return ( 77 |
78 | void form.handleSubmit(onSubmit)(...args)} 80 | className="flex flex-col space-y-4 bg-white px-4 py-8 sm:px-16" 81 | > 82 | 88 | 94 | 106 | 107 | 108 | ); 109 | }; 110 | 111 | export default ResetPasswordForm; 112 | -------------------------------------------------------------------------------- /components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | ArrowRight, 4 | Check, 5 | ChevronLeft, 6 | ChevronRight, 7 | Command, 8 | CreditCard, 9 | File, 10 | FileText, 11 | HelpCircle, 12 | Image, 13 | Laptop, 14 | Loader2, 15 | LucideProps, 16 | Moon, 17 | MoreVertical, 18 | Pizza, 19 | Plus, 20 | Settings, 21 | SunMedium, 22 | Trash, 23 | Twitter, 24 | User, 25 | X, 26 | type Icon as LucideIcon, 27 | } from "lucide-react" 28 | 29 | export type Icon = LucideIcon 30 | 31 | export const Icons = { 32 | logo: Command, 33 | close: X, 34 | spinner: Loader2, 35 | chevronLeft: ChevronLeft, 36 | chevronRight: ChevronRight, 37 | trash: Trash, 38 | post: FileText, 39 | page: File, 40 | media: Image, 41 | settings: Settings, 42 | billing: CreditCard, 43 | ellipsis: MoreVertical, 44 | add: Plus, 45 | warning: AlertTriangle, 46 | user: User, 47 | arrowRight: ArrowRight, 48 | help: HelpCircle, 49 | pizza: Pizza, 50 | sun: SunMedium, 51 | moon: Moon, 52 | laptop: Laptop, 53 | gitHub: ({ ...props }: LucideProps) => ( 54 | 69 | ), 70 | twitter: Twitter, 71 | check: Check, 72 | } -------------------------------------------------------------------------------- /components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "config/site"; 2 | import { Logo } from "../logo"; 3 | 4 | export default function Footer() { 5 | return ( 6 |
7 | 8 |

9 | ©{siteConfig.name} - All rights reserved 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/layout/nav.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "./navbar"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { authOptions } from "@/app/api/auth/[...nextauth]/route"; 4 | 5 | export default async function Nav() { 6 | const session = await getServerSession(authOptions); 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /components/layout/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import useScroll from "@/lib/hooks/use-scroll"; 5 | import UserDropdown from "./user-dropdown"; 6 | import { Session } from "next-auth"; 7 | import { Logo } from "../logo"; 8 | import { Button } from "../ui/button"; 9 | 10 | export default function NavBar({ session }: { session: Session | null }) { 11 | const scrolled = useScroll(50); 12 | 13 | return ( 14 | <> 15 | 16 | 26 | 27 | 28 | Logo 29 | 30 | {!session ? ( 31 | <> 32 | 46 | 47 | 55 | 56 | 57 | ) : ( 58 | 59 | )} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /components/layout/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { usePathname } from "next/navigation" 5 | 6 | import { SidebarNavItem } from "types" 7 | import { cn } from "@/lib/utils" 8 | import { Icons } from "../icons" 9 | 10 | interface DashboardNavProps { 11 | items: SidebarNavItem[] 12 | } 13 | 14 | export function DashboardNav({ items }: DashboardNavProps) { 15 | const path = usePathname() 16 | 17 | if (!items?.length) { 18 | return null 19 | } 20 | 21 | return ( 22 | 43 | ) 44 | } -------------------------------------------------------------------------------- /components/layout/user-dropdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { signOut } from "next-auth/react"; 5 | import { CreditCard, LayoutDashboard, LogOut } from "lucide-react"; 6 | import Popover from "@/components/shared/popover"; 7 | import { Session } from "next-auth"; 8 | import Link from "next/link"; 9 | import { UserAvatar } from "../shared/user-avatar"; 10 | 11 | export default function UserDropdown({ session }: { session: Session }) { 12 | const { email, subscriptionId } = session?.user || {}; 13 | const [openPopover, setOpenPopover] = useState(false); 14 | 15 | if (!email) return null; 16 | 17 | return ( 18 |
19 | 22 |
23 | {session?.user?.name && ( 24 |

25 | {session?.user?.name} 26 |

27 | )} 28 |

29 | {session?.user?.email} 30 |

31 |
32 | 41 | 50 | 51 | 58 |
59 | } 60 | align="end" 61 | openPopover={openPopover} 62 | setOpenPopover={setOpenPopover} 63 | > 64 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | function Logo() { 4 | return ( 5 | 6 | 16 | 17 | 18 | Logo 19 | 20 | ); 21 | } 22 | 23 | export { Logo }; 24 | -------------------------------------------------------------------------------- /components/pages/dashboard/infoCards.tsx: -------------------------------------------------------------------------------- 1 | import InfoCard from "@/components/ui/card/info-card"; 2 | import { Activity, CreditCard, DollarSign, Users } from "lucide-react"; 3 | import React from "react"; 4 | 5 | const InfoCards = () => { 6 | return ( 7 |
8 | } 13 | /> 14 | } 19 | /> 20 | } 25 | /> 26 |
27 | ); 28 | }; 29 | 30 | export default InfoCards; 31 | -------------------------------------------------------------------------------- /components/pages/dashboard/main.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 2 | import { AreaChartHero } from '@/components/ui/chart/area' 3 | import React from 'react' 4 | 5 | type Props = {} 6 | 7 | const Main = (props: Props) => { 8 | return ( 9 | 10 | 11 | Overview 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | } 19 | 20 | export default Main -------------------------------------------------------------------------------- /components/shared/icons/buymeacoffee.tsx: -------------------------------------------------------------------------------- 1 | export default function BuyMeACoffee({ className }: { className?: string }) { 2 | return ( 3 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 59 | 63 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/shared/icons/expanding-arrow.tsx: -------------------------------------------------------------------------------- 1 | export default function ExpandingArrow({ className }: { className?: string }) { 2 | return ( 3 |
4 | 14 | 18 | 19 | 29 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/shared/icons/github.tsx: -------------------------------------------------------------------------------- 1 | export default function Github({ className }: { className?: string }) { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/shared/icons/google.tsx: -------------------------------------------------------------------------------- 1 | export default function Google({ className }: { className: string }) { 2 | return ( 3 | 4 | 12 | 13 | 14 | 15 | 23 | 24 | 25 | 26 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {" "} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/shared/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as LoadingDots } from "./loading-dots"; 2 | export { default as LoadingCircle } from "./loading-circle"; 3 | export { default as LoadingSpinner } from "./loading-spinner"; 4 | export { default as ExpandingArrow } from "./expanding-arrow"; 5 | export { default as Github } from "./github"; 6 | export { default as Twitter } from "./twitter"; 7 | export { default as Google } from "./google"; 8 | export { default as BuyMeACoffee } from "./buymeacoffee"; -------------------------------------------------------------------------------- /components/shared/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle() { 2 | return ( 3 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/shared/icons/loading-dots.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-flex; 3 | align-items: center; 4 | } 5 | 6 | .loading .spacer { 7 | margin-right: 2px; 8 | } 9 | 10 | .loading span { 11 | animation-name: blink; 12 | animation-duration: 1.4s; 13 | animation-iteration-count: infinite; 14 | animation-fill-mode: both; 15 | width: 5px; 16 | height: 5px; 17 | border-radius: 50%; 18 | display: inline-block; 19 | margin: 0 1px; 20 | } 21 | 22 | .loading span:nth-of-type(2) { 23 | animation-delay: 0.2s; 24 | } 25 | 26 | .loading span:nth-of-type(3) { 27 | animation-delay: 0.4s; 28 | } 29 | 30 | @keyframes blink { 31 | 0% { 32 | opacity: 0.2; 33 | } 34 | 20% { 35 | opacity: 1; 36 | } 37 | 100% { 38 | opacity: 0.2; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/shared/icons/loading-dots.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-dots.module.css"; 2 | 3 | const LoadingDots = ({ color = "#000" }: { color?: string }) => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default LoadingDots; 14 | -------------------------------------------------------------------------------- /components/shared/icons/loading-spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | color: gray; 3 | display: inline-block; 4 | position: relative; 5 | width: 80px; 6 | height: 80px; 7 | transform: scale(0.3) translateX(-95px); 8 | } 9 | .spinner div { 10 | transform-origin: 40px 40px; 11 | animation: spinner 1.2s linear infinite; 12 | } 13 | .spinner div:after { 14 | content: " "; 15 | display: block; 16 | position: absolute; 17 | top: 3px; 18 | left: 37px; 19 | width: 6px; 20 | height: 20px; 21 | border-radius: 20%; 22 | background: black; 23 | } 24 | .spinner div:nth-child(1) { 25 | transform: rotate(0deg); 26 | animation-delay: -1.1s; 27 | } 28 | .spinner div:nth-child(2) { 29 | transform: rotate(30deg); 30 | animation-delay: -1s; 31 | } 32 | .spinner div:nth-child(3) { 33 | transform: rotate(60deg); 34 | animation-delay: -0.9s; 35 | } 36 | .spinner div:nth-child(4) { 37 | transform: rotate(90deg); 38 | animation-delay: -0.8s; 39 | } 40 | .spinner div:nth-child(5) { 41 | transform: rotate(120deg); 42 | animation-delay: -0.7s; 43 | } 44 | .spinner div:nth-child(6) { 45 | transform: rotate(150deg); 46 | animation-delay: -0.6s; 47 | } 48 | .spinner div:nth-child(7) { 49 | transform: rotate(180deg); 50 | animation-delay: -0.5s; 51 | } 52 | .spinner div:nth-child(8) { 53 | transform: rotate(210deg); 54 | animation-delay: -0.4s; 55 | } 56 | .spinner div:nth-child(9) { 57 | transform: rotate(240deg); 58 | animation-delay: -0.3s; 59 | } 60 | .spinner div:nth-child(10) { 61 | transform: rotate(270deg); 62 | animation-delay: -0.2s; 63 | } 64 | .spinner div:nth-child(11) { 65 | transform: rotate(300deg); 66 | animation-delay: -0.1s; 67 | } 68 | .spinner div:nth-child(12) { 69 | transform: rotate(330deg); 70 | animation-delay: 0s; 71 | } 72 | @keyframes spinner { 73 | 0% { 74 | opacity: 1; 75 | } 76 | 100% { 77 | opacity: 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/shared/icons/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-spinner.module.css"; 2 | 3 | export default function LoadingSpinner() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/shared/icons/twitter.tsx: -------------------------------------------------------------------------------- 1 | export default function Twitter({ className }: { className?: string }) { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/shared/loading-dots.module.css: -------------------------------------------------------------------------------- 1 | .loading { 2 | display: inline-flex; 3 | align-items: center; 4 | } 5 | 6 | .loading .spacer { 7 | margin-right: 2px; 8 | } 9 | 10 | .loading span { 11 | animation-name: blink; 12 | animation-duration: 1.4s; 13 | animation-iteration-count: infinite; 14 | animation-fill-mode: both; 15 | width: 5px; 16 | height: 5px; 17 | border-radius: 50%; 18 | display: inline-block; 19 | margin: 0 1px; 20 | } 21 | 22 | .loading span:nth-of-type(2) { 23 | animation-delay: 0.2s; 24 | } 25 | 26 | .loading span:nth-of-type(3) { 27 | animation-delay: 0.4s; 28 | } 29 | 30 | @keyframes blink { 31 | 0% { 32 | opacity: 0.2; 33 | } 34 | 20% { 35 | opacity: 1; 36 | } 37 | 100% { 38 | opacity: 0.2; 39 | } 40 | } -------------------------------------------------------------------------------- /components/shared/loading-dots.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./loading-dots.module.css"; 2 | 3 | const LoadingDots = ({ color = "#000" }: { color?: string }) => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default LoadingDots; -------------------------------------------------------------------------------- /components/shared/modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Dispatch, SetStateAction } from "react"; 4 | import { cn } from "@/lib/utils"; 5 | import { Drawer } from "vaul"; 6 | import * as Dialog from "@radix-ui/react-dialog"; 7 | import useMediaQuery from "@/lib/hooks/use-media-query"; 8 | 9 | export default function Modal({ 10 | children, 11 | className, 12 | showModal, 13 | setShowModal, 14 | }: { 15 | children: React.ReactNode; 16 | className?: string; 17 | showModal: boolean; 18 | setShowModal: Dispatch>; 19 | }) { 20 | const { isMobile } = useMediaQuery(); 21 | 22 | if (isMobile) { 23 | return ( 24 | 25 | 26 | 27 | 33 |
34 |
35 |
36 | {children} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | return ( 44 | 45 | 46 | 51 | e.preventDefault()} 53 | onCloseAutoFocus={(e) => e.preventDefault()} 54 | className={cn( 55 | "animate-scale-in fixed inset-0 z-40 m-auto max-h-fit w-full max-w-md overflow-hidden border border-gray-200 bg-background p-0 shadow-xl md:rounded-2xl", 56 | className, 57 | )} 58 | > 59 | {children} 60 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /components/shared/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Dispatch, ReactNode, SetStateAction } from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | import { Drawer } from "vaul"; 6 | import useMediaQuery from "@/lib/hooks/use-media-query"; 7 | 8 | export default function Popover({ 9 | children, 10 | content, 11 | align = "center", 12 | openPopover, 13 | setOpenPopover, 14 | }: { 15 | children: ReactNode; 16 | content: ReactNode | string; 17 | align?: "center" | "start" | "end"; 18 | openPopover: boolean; 19 | setOpenPopover: Dispatch>; 20 | mobileOnly?: boolean; 21 | }) { 22 | const { isMobile } = useMediaQuery(); 23 | 24 | if (isMobile) { 25 | return ( 26 | 27 |
{children}
28 | 29 | 30 | 31 |
32 |
33 |
34 |
35 | {content} 36 |
37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 47 | {children} 48 | 49 | 50 | 55 | {content} 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /components/shared/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 5 | import { Drawer } from "vaul"; 6 | import useMediaQuery from "@/lib/hooks/use-media-query"; 7 | 8 | export default function Tooltip({ 9 | children, 10 | content, 11 | fullWidth, 12 | }: { 13 | children: ReactNode; 14 | content: ReactNode | string; 15 | fullWidth?: boolean; 16 | }) { 17 | const { isMobile } = useMediaQuery(); 18 | 19 | if (isMobile) { 20 | return ( 21 | 22 | { 25 | e.stopPropagation(); 26 | }} 27 | > 28 | {children} 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 | {typeof content === "string" ? ( 38 | 39 | {content} 40 | 41 | ) : ( 42 | content 43 | )} 44 |
45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | return ( 52 | 53 | 54 | 55 | {children} 56 | 57 | {/* 58 | We don't use TooltipPrimitive.Portal here because for some reason it 59 | prevents you from selecting the contents of a tooltip when used inside a modal 60 | */} 61 | 66 | {typeof content === "string" ? ( 67 |
68 | {content} 69 |
70 | ) : ( 71 | content 72 | )} 73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /components/shared/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client" 2 | import { AvatarProps } from "@radix-ui/react-avatar" 3 | 4 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 5 | import { Icons } from "@/components/icons" 6 | 7 | export function UserAvatar({ ...props }: AvatarProps) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /components/ui/avatar/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 |