├── .commitlintrc.json ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── actions ├── generate-user-stripe.ts ├── open-customer-portal.ts ├── update-user-name.ts └── update-user-role.ts ├── app ├── (auth) │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ └── register │ │ └── page.tsx ├── (docs) │ ├── docs │ │ ├── [[...slug]] │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── guides │ │ ├── [slug] │ │ │ └── page.tsx │ │ └── page.tsx │ └── layout.tsx ├── (marketing) │ ├── (blog-post) │ │ └── blog │ │ │ └── [slug] │ │ │ └── page.tsx │ ├── [slug] │ │ └── page.tsx │ ├── blog │ │ ├── category │ │ │ └── [slug] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── error.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ └── pricing │ │ ├── loading.tsx │ │ └── page.tsx ├── (protected) │ ├── admin │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── orders │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── dashboard │ │ ├── billing │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── charts │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── settings │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ └── layout.tsx ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── og │ │ └── route.tsx │ ├── user │ │ └── route.ts │ └── webhooks │ │ └── stripe │ │ └── route.ts ├── layout.tsx ├── opengraph-image.jpg └── robots.ts ├── assets └── fonts │ ├── CalSans-SemiBold.ttf │ ├── CalSans-SemiBold.woff2 │ ├── GeistVF.woff2 │ ├── Inter-Bold.ttf │ ├── Inter-Regular.ttf │ └── index.ts ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── analytics.tsx ├── charts │ ├── area-chart-stacked.tsx │ ├── bar-chart-mixed.tsx │ ├── interactive-bar-chart.tsx │ ├── line-chart-multiple.tsx │ ├── radar-chart-simple.tsx │ ├── radial-chart-grid.tsx │ ├── radial-shape-chart.tsx │ ├── radial-stacked-chart.tsx │ └── radial-text-chart.tsx ├── content │ ├── author.tsx │ ├── blog-card.tsx │ ├── blog-header-layout.tsx │ ├── blog-posts.tsx │ ├── mdx-card.tsx │ └── mdx-components.tsx ├── dashboard │ ├── delete-account.tsx │ ├── header.tsx │ ├── info-card.tsx │ ├── project-switcher.tsx │ ├── search-command.tsx │ ├── section-columns.tsx │ ├── transactions-list.tsx │ └── upgrade-card.tsx ├── docs │ ├── page-header.tsx │ ├── pager.tsx │ ├── search.tsx │ └── sidebar-nav.tsx ├── forms │ ├── billing-form-button.tsx │ ├── customer-portal-button.tsx │ ├── newsletter-form.tsx │ ├── user-auth-form.tsx │ ├── user-name-form.tsx │ └── user-role-form.tsx ├── layout │ ├── dashboard-sidebar.tsx │ ├── mobile-nav.tsx │ ├── mode-toggle.tsx │ ├── navbar.tsx │ ├── site-footer.tsx │ └── user-account-nav.tsx ├── modals │ ├── delete-account-modal.tsx │ ├── providers.tsx │ └── sign-in-modal.tsx ├── pricing │ ├── billing-info.tsx │ ├── compare-plans.tsx │ ├── pricing-cards.tsx │ └── pricing-faq.tsx ├── sections │ ├── bentogrid.tsx │ ├── features.tsx │ ├── hero-landing.tsx │ ├── info-landing.tsx │ ├── powered.tsx │ ├── preview-landing.tsx │ └── testimonials.tsx ├── shared │ ├── blur-image.tsx │ ├── callout.tsx │ ├── card-skeleton.tsx │ ├── copy-button.tsx │ ├── empty-placeholder.tsx │ ├── header-section.tsx │ ├── icons.tsx │ ├── max-width-wrapper.tsx │ ├── section-skeleton.tsx │ ├── toc.tsx │ └── user-avatar.tsx ├── tailwind-indicator.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── modal.tsx │ ├── navigation-menu.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── config ├── blog.ts ├── dashboard.ts ├── docs.ts ├── landing.ts ├── marketing.ts ├── site.ts └── subscriptions.ts ├── content ├── blog │ ├── deploying-next-apps.mdx │ ├── dynamic-routing-static-regeneration.mdx │ ├── preview-mode-headless-cms.mdx │ └── server-client-components.mdx ├── docs │ ├── configuration │ │ ├── authentification.mdx │ │ ├── blog.mdx │ │ ├── components.mdx │ │ ├── config-files.mdx │ │ ├── database.mdx │ │ ├── email.mdx │ │ ├── layouts.mdx │ │ ├── markdown-files.mdx │ │ └── subscriptions.mdx │ ├── in-progress.mdx │ ├── index.mdx │ └── installation.mdx ├── guides │ ├── build-blog-using-contentlayer-mdx.mdx │ └── using-next-auth-next-13.mdx └── pages │ ├── privacy.mdx │ └── terms.mdx ├── contentlayer.config.ts ├── emails └── magic-link-email.tsx ├── env.mjs ├── hooks ├── use-intersection-observer.ts ├── use-local-storage.ts ├── use-lock-body.ts ├── use-media-query.ts ├── use-mounted.ts └── use-scroll.ts ├── lib ├── db.ts ├── email.ts ├── exceptions.ts ├── session.ts ├── stripe.ts ├── subscription.ts ├── toc.ts ├── user.ts ├── utils.ts └── validations │ ├── auth.ts │ ├── og.ts │ └── user.ts ├── middleware.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── prisma ├── migrations │ └── 0_init │ │ └── migration.sql └── schema.prisma ├── public ├── _static │ ├── avatars │ │ ├── mickasmt.png │ │ └── shadcn.jpeg │ ├── blog │ │ ├── blog-post-1.jpg │ │ ├── blog-post-2.jpg │ │ ├── blog-post-3.jpg │ │ └── blog-post-4.jpg │ ├── docs │ │ └── gg-auth-config.jpg │ ├── favicons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ └── favicon-32x32.png │ ├── illustrations │ │ ├── call-waiting.svg │ │ ├── rocket-crashed.svg │ │ └── work-from-home.jpg │ └── og.jpg ├── favicon.ico └── site.webmanifest ├── styles ├── globals.css └── mdx.css ├── tailwind.config.ts ├── tsconfig.json └── types ├── index.d.ts └── next-auth.d.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # App - Don't add "/" in the end of the url (same in production) 3 | # ----------------------------------------------------------------------------- 4 | NEXT_PUBLIC_APP_URL=http://localhost:3000 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Authentication (NextAuth.js) 8 | # ----------------------------------------------------------------------------- 9 | AUTH_SECRET= 10 | 11 | GOOGLE_CLIENT_ID= 12 | GOOGLE_CLIENT_SECRET= 13 | 14 | GITHUB_OAUTH_TOKEN= 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Database (MySQL - Neon DB) 18 | # ----------------------------------------------------------------------------- 19 | DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require' 20 | 21 | # ----------------------------------------------------------------------------- 22 | # Email (Resend) 23 | # ----------------------------------------------------------------------------- 24 | RESEND_API_KEY= 25 | EMAIL_FROM="SaaS Starter App " 26 | 27 | # ----------------------------------------------------------------------------- 28 | # Subscriptions (Stripe) 29 | # ----------------------------------------------------------------------------- 30 | STRIPE_API_KEY= 31 | STRIPE_WEBHOOK_SECRET= 32 | 33 | NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PLAN_ID= 34 | NEXT_PUBLIC_STRIPE_PRO_YEARLY_PLAN_ID= 35 | 36 | NEXT_PUBLIC_STRIPE_BUSINESS_MONTHLY_PLAN_ID= 37 | NEXT_PUBLIC_STRIPE_BUSINESS_YEARLY_PLAN_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off", 14 | "tailwindcss/classnames-order": "error" 15 | }, 16 | "settings": { 17 | "tailwindcss": { 18 | "callees": ["cn"], 19 | "config": "tailwind.config.ts" 20 | }, 21 | "next": { 22 | "rootDir": true 23 | } 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["*.ts", "*.tsx"], 28 | "parser": "@typescript-eslint/parser" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.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*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # email 40 | /.react-email/ 41 | 42 | .vscode 43 | .contentlayer -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build 5 | .contentlayer -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mickasmt 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 | -------------------------------------------------------------------------------- /actions/generate-user-stripe.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/auth"; 4 | import { stripe } from "@/lib/stripe"; 5 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 6 | import { absoluteUrl } from "@/lib/utils"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export type responseAction = { 10 | status: "success" | "error"; 11 | stripeUrl?: string; 12 | } 13 | 14 | // const billingUrl = absoluteUrl("/dashboard/billing") 15 | const billingUrl = absoluteUrl("/pricing") 16 | 17 | export async function generateUserStripe(priceId: string): Promise { 18 | let redirectUrl: string = ""; 19 | 20 | try { 21 | const session = await auth() 22 | const user = session?.user; 23 | 24 | if (!user || !user.email || !user.id) { 25 | throw new Error("Unauthorized"); 26 | } 27 | 28 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 29 | 30 | if (subscriptionPlan.isPaid && subscriptionPlan.stripeCustomerId) { 31 | // User on Paid Plan - Create a portal session to manage subscription. 32 | const stripeSession = await stripe.billingPortal.sessions.create({ 33 | customer: subscriptionPlan.stripeCustomerId, 34 | return_url: billingUrl, 35 | }) 36 | 37 | redirectUrl = stripeSession.url as string 38 | } else { 39 | // User on Free Plan - Create a checkout session to upgrade. 40 | const stripeSession = await stripe.checkout.sessions.create({ 41 | success_url: billingUrl, 42 | cancel_url: billingUrl, 43 | payment_method_types: ["card"], 44 | mode: "subscription", 45 | billing_address_collection: "auto", 46 | customer_email: user.email, 47 | line_items: [ 48 | { 49 | price: priceId, 50 | quantity: 1, 51 | }, 52 | ], 53 | metadata: { 54 | userId: user.id, 55 | }, 56 | }) 57 | 58 | redirectUrl = stripeSession.url as string 59 | } 60 | } catch (error) { 61 | throw new Error("Failed to generate user stripe session"); 62 | } 63 | 64 | // no revalidatePath because redirect 65 | redirect(redirectUrl) 66 | } -------------------------------------------------------------------------------- /actions/open-customer-portal.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import { auth } from "@/auth"; 5 | 6 | import { stripe } from "@/lib/stripe"; 7 | import { absoluteUrl } from "@/lib/utils"; 8 | 9 | export type responseAction = { 10 | status: "success" | "error"; 11 | stripeUrl?: string; 12 | }; 13 | 14 | const billingUrl = absoluteUrl("/dashboard/billing"); 15 | 16 | export async function openCustomerPortal( 17 | userStripeId: string, 18 | ): Promise { 19 | let redirectUrl: string = ""; 20 | 21 | try { 22 | const session = await auth(); 23 | 24 | if (!session?.user || !session?.user.email) { 25 | throw new Error("Unauthorized"); 26 | } 27 | 28 | if (userStripeId) { 29 | const stripeSession = await stripe.billingPortal.sessions.create({ 30 | customer: userStripeId, 31 | return_url: billingUrl, 32 | }); 33 | 34 | redirectUrl = stripeSession.url as string; 35 | } 36 | } catch (error) { 37 | throw new Error("Failed to generate user stripe session"); 38 | } 39 | 40 | redirect(redirectUrl); 41 | } 42 | -------------------------------------------------------------------------------- /actions/update-user-name.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/auth"; 4 | import { prisma } from "@/lib/db"; 5 | import { userNameSchema } from "@/lib/validations/user"; 6 | import { revalidatePath } from "next/cache"; 7 | 8 | export type FormData = { 9 | name: string; 10 | }; 11 | 12 | export async function updateUserName(userId: string, data: FormData) { 13 | try { 14 | const session = await auth() 15 | 16 | if (!session?.user || session?.user.id !== userId) { 17 | throw new Error("Unauthorized"); 18 | } 19 | 20 | const { name } = userNameSchema.parse(data); 21 | 22 | // Update the user name. 23 | await prisma.user.update({ 24 | where: { 25 | id: userId, 26 | }, 27 | data: { 28 | name: name, 29 | }, 30 | }) 31 | 32 | revalidatePath('/dashboard/settings'); 33 | return { status: "success" }; 34 | } catch (error) { 35 | // console.log(error) 36 | return { status: "error" } 37 | } 38 | } -------------------------------------------------------------------------------- /actions/update-user-role.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { auth } from "@/auth"; 5 | import { UserRole } from "@prisma/client"; 6 | 7 | import { prisma } from "@/lib/db"; 8 | import { userRoleSchema } from "@/lib/validations/user"; 9 | 10 | export type FormData = { 11 | role: UserRole; 12 | }; 13 | 14 | export async function updateUserRole(userId: string, data: FormData) { 15 | try { 16 | const session = await auth(); 17 | 18 | if (!session?.user || session?.user.id !== userId) { 19 | throw new Error("Unauthorized"); 20 | } 21 | 22 | const { role } = userRoleSchema.parse(data); 23 | 24 | // Update the user role. 25 | await prisma.user.update({ 26 | where: { 27 | id: userId, 28 | }, 29 | data: { 30 | role: role, 31 | }, 32 | }); 33 | 34 | revalidatePath("/dashboard/settings"); 35 | return { status: "success" }; 36 | } catch (error) { 37 | // console.log(error) 38 | return { status: "error" }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | 5 | interface AuthLayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default async function AuthLayout({ children }: AuthLayoutProps) { 10 | const user = await getCurrentUser(); 11 | 12 | if (user) { 13 | if (user.role === "ADMIN") redirect("/admin"); 14 | redirect("/dashboard"); 15 | } 16 | 17 | return
{children}
; 18 | } 19 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Metadata } from "next"; 3 | import Link from "next/link"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { buttonVariants } from "@/components/ui/button"; 7 | import { UserAuthForm } from "@/components/forms/user-auth-form"; 8 | import { Icons } from "@/components/shared/icons"; 9 | 10 | export const metadata: Metadata = { 11 | title: "Login", 12 | description: "Login to your account", 13 | }; 14 | 15 | export default function LoginPage() { 16 | return ( 17 |
18 | 25 | <> 26 | 27 | Back 28 | 29 | 30 |
31 |
32 | 33 |

34 | Welcome back 35 |

36 |

37 | Enter your email to sign in to your account 38 |

39 |
40 | 41 | 42 | 43 |

44 | 48 | Don't have an account? Sign Up 49 | 50 |

51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/shared/icons" 6 | import { UserAuthForm } from "@/components/forms/user-auth-form" 7 | import { Suspense } from "react" 8 | 9 | export const metadata = { 10 | title: "Create an account", 11 | description: "Create an account to get started.", 12 | } 13 | 14 | export default function RegisterPage() { 15 | return ( 16 |
17 | 24 | Login 25 | 26 |
27 |
28 |
29 |
30 | 31 |

32 | Create an account 33 |

34 |

35 | Enter your email below to create your account 36 |

37 |
38 | 39 | 40 | 41 |

42 | By clicking continue, you agree to our{" "} 43 | 47 | Terms of Service 48 | {" "} 49 | and{" "} 50 | 54 | Privacy Policy 55 | 56 | . 57 |

58 |
59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /app/(docs)/docs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { allDocs } from "contentlayer/generated"; 3 | 4 | import { getTableOfContents } from "@/lib/toc"; 5 | import { Mdx } from "@/components/content/mdx-components"; 6 | import { DocsPageHeader } from "@/components/docs/page-header"; 7 | import { DocsPager } from "@/components/docs/pager"; 8 | import { DashboardTableOfContents } from "@/components/shared/toc"; 9 | 10 | import "@/styles/mdx.css"; 11 | 12 | import { Metadata } from "next"; 13 | 14 | import { constructMetadata, getBlurDataURL } from "@/lib/utils"; 15 | 16 | interface DocPageProps { 17 | params: { 18 | slug: string[]; 19 | }; 20 | } 21 | 22 | async function getDocFromParams(params) { 23 | const slug = params.slug?.join("/") || ""; 24 | const doc = allDocs.find((doc) => doc.slugAsParams === slug); 25 | 26 | if (!doc) return null; 27 | 28 | return doc; 29 | } 30 | 31 | export async function generateMetadata({ 32 | params, 33 | }: DocPageProps): Promise { 34 | const doc = await getDocFromParams(params); 35 | 36 | if (!doc) return {}; 37 | 38 | const { title, description } = doc; 39 | 40 | return constructMetadata({ 41 | title: `${title} – SaaS Starter`, 42 | description: description, 43 | }); 44 | } 45 | 46 | export async function generateStaticParams(): Promise< 47 | DocPageProps["params"][] 48 | > { 49 | return allDocs.map((doc) => ({ 50 | slug: doc.slugAsParams.split("/"), 51 | })); 52 | } 53 | 54 | export default async function DocPage({ params }: DocPageProps) { 55 | const doc = await getDocFromParams(params); 56 | 57 | if (!doc) { 58 | notFound(); 59 | } 60 | 61 | const toc = await getTableOfContents(doc.body.raw); 62 | 63 | const images = await Promise.all( 64 | doc.images.map(async (src: string) => ({ 65 | src, 66 | blurDataURL: await getBlurDataURL(src), 67 | })), 68 | ); 69 | 70 | return ( 71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/(docs)/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollArea } from "@/components/ui/scroll-area"; 2 | import { DocsSidebarNav } from "@/components/docs/sidebar-nav"; 3 | 4 | interface DocsLayoutProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export default function DocsLayout({ children }: DocsLayoutProps) { 9 | return ( 10 |
11 | 16 | {children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/(docs)/guides/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { allGuides } from "contentlayer/generated"; 2 | import Link from "next/link"; 3 | import { notFound } from "next/navigation"; 4 | 5 | import { Mdx } from "@/components/content/mdx-components"; 6 | import { DocsPageHeader } from "@/components/docs/page-header"; 7 | import { Icons } from "@/components/shared/icons"; 8 | import { DashboardTableOfContents } from "@/components/shared/toc"; 9 | import { getTableOfContents } from "@/lib/toc"; 10 | 11 | import "@/styles/mdx.css"; 12 | 13 | import { Metadata } from "next"; 14 | 15 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 16 | import { buttonVariants } from "@/components/ui/button"; 17 | import { cn, constructMetadata } from "@/lib/utils"; 18 | 19 | export async function generateStaticParams() { 20 | return allGuides.map((guide) => ({ 21 | slug: guide.slugAsParams, 22 | })); 23 | } 24 | 25 | export async function generateMetadata({ 26 | params, 27 | }: { 28 | params: { slug: string }; 29 | }): Promise { 30 | const guide = allGuides.find((guide) => guide.slugAsParams === params.slug); 31 | if (!guide) { 32 | return; 33 | } 34 | 35 | const { title, description } = guide; 36 | 37 | return constructMetadata({ 38 | title: `${title} – SaaS Starter`, 39 | description: description, 40 | }); 41 | } 42 | 43 | export default async function GuidePage({ 44 | params, 45 | }: { 46 | params: { 47 | slug: string; 48 | }; 49 | }) { 50 | const guide = allGuides.find((guide) => guide.slugAsParams === params.slug); 51 | 52 | if (!guide) { 53 | notFound(); 54 | } 55 | 56 | const toc = await getTableOfContents(guide.body.raw); 57 | 58 | return ( 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 70 | 71 | See all guides 72 | 73 |
74 |
75 |
76 |
77 | 78 |
79 |
80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/(docs)/guides/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { allGuides } from "contentlayer/generated"; 3 | import { compareDesc } from "date-fns"; 4 | 5 | import { formatDate } from "@/lib/utils"; 6 | import { DocsPageHeader } from "@/components/docs/page-header"; 7 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 8 | 9 | export const metadata = { 10 | title: "Guides", 11 | description: 12 | "This section includes end-to-end guides for developing Next.js 13 apps.", 13 | }; 14 | 15 | export default function GuidesPage() { 16 | const guides = allGuides 17 | .filter((guide) => guide.published) 18 | .sort((a, b) => { 19 | return compareDesc(new Date(a.date), new Date(b.date)); 20 | }); 21 | 22 | return ( 23 | 24 | 28 | {guides?.length ? ( 29 |
30 | {guides.map((guide) => ( 31 |
35 | {guide.featured && ( 36 | 37 | Featured 38 | 39 | )} 40 |
41 |
42 |

43 | {guide.title} 44 |

45 | {guide.description && ( 46 |

{guide.description}

47 | )} 48 |
49 | {guide.date && ( 50 |

51 | {formatDate(guide.date)} 52 |

53 | )} 54 |
55 | 56 | View 57 | 58 |
59 | ))} 60 |
61 | ) : ( 62 |

No guides published.

63 | )} 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { NavMobile } from "@/components/layout/mobile-nav"; 2 | import { NavBar } from "@/components/layout/navbar"; 3 | import { SiteFooter } from "@/components/layout/site-footer"; 4 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 5 | 6 | interface DocsLayoutProps { 7 | children: React.ReactNode; 8 | } 9 | 10 | export default function DocsLayout({ children }: DocsLayoutProps) { 11 | return ( 12 |
13 | 14 | 15 | 16 | {children} 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/(marketing)/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { allPages } from "contentlayer/generated"; 3 | 4 | import { Mdx } from "@/components/content/mdx-components"; 5 | 6 | import "@/styles/mdx.css"; 7 | 8 | import { Metadata } from "next"; 9 | 10 | import { constructMetadata, getBlurDataURL } from "@/lib/utils"; 11 | 12 | export async function generateStaticParams() { 13 | return allPages.map((page) => ({ 14 | slug: page.slugAsParams, 15 | })); 16 | } 17 | 18 | export async function generateMetadata({ 19 | params, 20 | }: { 21 | params: { slug: string }; 22 | }): Promise { 23 | const page = allPages.find((page) => page.slugAsParams === params.slug); 24 | if (!page) { 25 | return; 26 | } 27 | 28 | const { title, description } = page; 29 | 30 | return constructMetadata({ 31 | title: `${title} – SaaS Starter`, 32 | description: description, 33 | }); 34 | } 35 | 36 | export default async function PagePage({ 37 | params, 38 | }: { 39 | params: { 40 | slug: string; 41 | }; 42 | }) { 43 | const page = allPages.find((page) => page.slugAsParams === params.slug); 44 | 45 | if (!page) { 46 | notFound(); 47 | } 48 | 49 | const images = await Promise.all( 50 | page.images.map(async (src: string) => ({ 51 | src, 52 | blurDataURL: await getBlurDataURL(src), 53 | })), 54 | ); 55 | 56 | return ( 57 |
58 |
59 |

60 | {page.title} 61 |

62 | {page.description && ( 63 |

{page.description}

64 | )} 65 |
66 |
67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/(marketing)/blog/category/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { notFound } from "next/navigation"; 3 | import { allPosts } from "contentlayer/generated"; 4 | 5 | import { BLOG_CATEGORIES } from "@/config/blog"; 6 | import { constructMetadata, getBlurDataURL } from "@/lib/utils"; 7 | import { BlogCard } from "@/components/content/blog-card"; 8 | 9 | export async function generateStaticParams() { 10 | return BLOG_CATEGORIES.map((category) => ({ 11 | slug: category.slug, 12 | })); 13 | } 14 | 15 | export async function generateMetadata({ 16 | params, 17 | }: { 18 | params: { slug: string }; 19 | }): Promise { 20 | const category = BLOG_CATEGORIES.find( 21 | (category) => category.slug === params.slug, 22 | ); 23 | if (!category) { 24 | return; 25 | } 26 | 27 | const { title, description } = category; 28 | 29 | return constructMetadata({ 30 | title: `${title} Posts – Next SaaS Starter`, 31 | description, 32 | }); 33 | } 34 | 35 | export default async function BlogCategory({ 36 | params, 37 | }: { 38 | params: { 39 | slug: string; 40 | }; 41 | }) { 42 | const category = BLOG_CATEGORIES.find((ctg) => ctg.slug === params.slug); 43 | 44 | if (!category) { 45 | notFound(); 46 | } 47 | 48 | const articles = await Promise.all( 49 | allPosts 50 | .filter((post) => post.categories.includes(category.slug)) 51 | .sort((a, b) => b.date.localeCompare(a.date)) 52 | .map(async (post) => ({ 53 | ...post, 54 | blurDataURL: await getBlurDataURL(post.image), 55 | })), 56 | ); 57 | 58 | return ( 59 |
60 | {articles.map((article, idx) => ( 61 | 62 | ))} 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/(marketing)/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | import { BlogHeaderLayout } from "@/components/content/blog-header-layout"; 2 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 3 | 4 | export default function BlogLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | return ( 10 | <> 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { allPosts } from "contentlayer/generated"; 2 | 3 | import { constructMetadata, getBlurDataURL } from "@/lib/utils"; 4 | import { BlogPosts } from "@/components/content/blog-posts"; 5 | 6 | export const metadata = constructMetadata({ 7 | title: "Blog – SaaS Starter", 8 | description: "Latest news and updates from Next SaaS Starter.", 9 | }); 10 | 11 | export default async function BlogPage() { 12 | const posts = await Promise.all( 13 | allPosts 14 | .filter((post) => post.published) 15 | .sort((a, b) => b.date.localeCompare(a.date)) 16 | .map(async (post) => ({ 17 | ...post, 18 | blurDataURL: await getBlurDataURL(post.image), 19 | })), 20 | ); 21 | 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /app/(marketing)/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | 5 | export default function Error({ 6 | reset, 7 | }: { 8 | reset: () => void; 9 | }) { 10 | 11 | return ( 12 |
13 |

Something went wrong!

14 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { NavBar } from "@/components/layout/navbar"; 2 | import { SiteFooter } from "@/components/layout/site-footer"; 3 | import { NavMobile } from "@/components/layout/mobile-nav"; 4 | 5 | interface MarketingLayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default function MarketingLayout({ children }: MarketingLayoutProps) { 10 | return ( 11 |
12 | 13 | 14 |
{children}
15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/(marketing)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 |

404

8 | 404 15 |

16 | Page not found. Back to{" "} 17 | 21 | Homepage 22 | 23 | . 24 |

25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import { infos } from "@/config/landing"; 2 | import BentoGrid from "@/components/sections/bentogrid"; 3 | import Features from "@/components/sections/features"; 4 | import HeroLanding from "@/components/sections/hero-landing"; 5 | import InfoLanding from "@/components/sections/info-landing"; 6 | import Powered from "@/components/sections/powered"; 7 | import PreviewLanding from "@/components/sections/preview-landing"; 8 | import Testimonials from "@/components/sections/testimonials"; 9 | 10 | export default function IndexPage() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | {/* */} 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/(marketing)/pricing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { HeaderSection } from "@/components/shared/header-section"; 3 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 4 | 5 | export default function Loading() { 6 | return ( 7 |
8 | 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 | 17 | 18 | 19 |
20 | 21 |
22 | 23 | 24 |
25 |
26 |
27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { getCurrentUser } from "@/lib/session"; 5 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 6 | import { constructMetadata } from "@/lib/utils"; 7 | import { ComparePlans } from "@/components/pricing/compare-plans"; 8 | import { PricingCards } from "@/components/pricing/pricing-cards"; 9 | import { PricingFaq } from "@/components/pricing/pricing-faq"; 10 | 11 | export const metadata = constructMetadata({ 12 | title: "Pricing – SaaS Starter", 13 | description: "Explore our subscription plans.", 14 | }); 15 | 16 | export default async function PricingPage() { 17 | const user = await getCurrentUser(); 18 | 19 | if (user?.role === "ADMIN") { 20 | return ( 21 |
22 |

Seriously?

23 | 403 30 |

31 | You are an {user.role}. Back to{" "} 32 | 36 | Dashboard 37 | 38 | . 39 |

40 |
41 | ); 42 | } 43 | 44 | let subscriptionPlan; 45 | if (user && user.id) { 46 | subscriptionPlan = await getUserSubscriptionPlan(user.id); 47 | } 48 | 49 | return ( 50 |
51 | 52 |
53 | 54 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/(protected)/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | 5 | interface ProtectedLayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default async function Dashboard({ children }: ProtectedLayoutProps) { 10 | const user = await getCurrentUser(); 11 | if (!user || user.role !== "ADMIN") redirect("/login"); 12 | 13 | return <>{children}; 14 | } 15 | -------------------------------------------------------------------------------- /app/(protected)/admin/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { DashboardHeader } from "@/components/dashboard/header"; 3 | 4 | export default function AdminPanelLoading() { 5 | return ( 6 | <> 7 | 11 |
12 |
13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 |
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/(protected)/admin/orders/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { DashboardHeader } from "@/components/dashboard/header"; 3 | 4 | export default function OrdersLoading() { 5 | return ( 6 | <> 7 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/(protected)/admin/orders/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { constructMetadata } from "@/lib/utils"; 5 | import { Button } from "@/components/ui/button"; 6 | import { DashboardHeader } from "@/components/dashboard/header"; 7 | import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; 8 | 9 | export const metadata = constructMetadata({ 10 | title: "Orders – SaaS Starter", 11 | description: "Check and manage your latest orders.", 12 | }); 13 | 14 | export default async function OrdersPage() { 15 | // const user = await getCurrentUser(); 16 | // if (!user || user.role !== "ADMIN") redirect("/login"); 17 | 18 | return ( 19 | <> 20 | 24 | 25 | 26 | No orders listed 27 | 28 | You don't have any orders yet. Start ordering a product. 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { constructMetadata } from "@/lib/utils"; 5 | import { DashboardHeader } from "@/components/dashboard/header"; 6 | import InfoCard from "@/components/dashboard/info-card"; 7 | import TransactionsList from "@/components/dashboard/transactions-list"; 8 | 9 | export const metadata = constructMetadata({ 10 | title: "Admin – SaaS Starter", 11 | description: "Admin page for only admin management.", 12 | }); 13 | 14 | export default async function AdminPage() { 15 | const user = await getCurrentUser(); 16 | if (!user || user.role !== "ADMIN") redirect("/login"); 17 | 18 | return ( 19 | <> 20 | 24 |
25 |
26 | 27 | 28 | 29 | 30 |
31 | 32 | 33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { DashboardHeader } from "@/components/dashboard/header"; 3 | import { CardSkeleton } from "@/components/shared/card-skeleton"; 4 | 5 | export default function DashboardBillingLoading() { 6 | return ( 7 | <> 8 | 12 |
13 | 14 | 15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { getUserSubscriptionPlan } from "@/lib/subscription"; 5 | import { constructMetadata } from "@/lib/utils"; 6 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 7 | import { DashboardHeader } from "@/components/dashboard/header"; 8 | import { BillingInfo } from "@/components/pricing/billing-info"; 9 | import { Icons } from "@/components/shared/icons"; 10 | 11 | export const metadata = constructMetadata({ 12 | title: "Billing – SaaS Starter", 13 | description: "Manage billing and your subscription plan.", 14 | }); 15 | 16 | export default async function BillingPage() { 17 | const user = await getCurrentUser(); 18 | 19 | let userSubscriptionPlan; 20 | if (user && user.id && user.role === "USER") { 21 | userSubscriptionPlan = await getUserSubscriptionPlan(user.id); 22 | } else { 23 | redirect("/login"); 24 | } 25 | 26 | return ( 27 | <> 28 | 32 |
33 | 34 | 35 | This is a demo app. 36 | 37 | SaaS Starter app is a demo app using a Stripe test environment. You 38 | can find a list of test card numbers on the{" "} 39 | 45 | Stripe docs 46 | 47 | . 48 | 49 | 50 | 51 |
52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/charts/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { DashboardHeader } from "@/components/dashboard/header"; 3 | 4 | export default function ChartsLoading() { 5 | return ( 6 | <> 7 | 8 |
9 |
10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/charts/page.tsx: -------------------------------------------------------------------------------- 1 | import { constructMetadata } from "@/lib/utils"; 2 | import { Skeleton } from "@/components/ui/skeleton"; 3 | import { AreaChartStacked } from "@/components/charts/area-chart-stacked"; 4 | import { BarChartMixed } from "@/components/charts/bar-chart-mixed"; 5 | import { InteractiveBarChart } from "@/components/charts/interactive-bar-chart"; 6 | import { LineChartMultiple } from "@/components/charts/line-chart-multiple"; 7 | import { RadarChartSimple } from "@/components/charts/radar-chart-simple"; 8 | import { RadialChartGrid } from "@/components/charts/radial-chart-grid"; 9 | import { RadialShapeChart } from "@/components/charts/radial-shape-chart"; 10 | import { RadialStackedChart } from "@/components/charts/radial-stacked-chart"; 11 | import { RadialTextChart } from "@/components/charts/radial-text-chart"; 12 | import { DashboardHeader } from "@/components/dashboard/header"; 13 | 14 | export const metadata = constructMetadata({ 15 | title: "Charts – SaaS Starter", 16 | description: "List of charts by shadcn-ui", 17 | }); 18 | 19 | export default function ChartsPage() { 20 | return ( 21 | <> 22 | 23 |
24 |
25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 |
39 |
40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { DashboardHeader } from "@/components/dashboard/header"; 3 | 4 | export default function DashboardLoading() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/lib/session"; 2 | import { constructMetadata } from "@/lib/utils"; 3 | import { Button } from "@/components/ui/button"; 4 | import { DashboardHeader } from "@/components/dashboard/header"; 5 | import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; 6 | 7 | export const metadata = constructMetadata({ 8 | title: "Dashboard – SaaS Starter", 9 | description: "Create and manage content.", 10 | }); 11 | 12 | export default async function DashboardPage() { 13 | const user = await getCurrentUser(); 14 | 15 | return ( 16 | <> 17 | 21 | 22 | 23 | No content created 24 | 25 | You don't have any content yet. Start creating content. 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/dashboard/header"; 2 | import { SkeletonSection } from "@/components/shared/section-skeleton"; 3 | 4 | export default function DashboardSettingsLoading() { 5 | return ( 6 | <> 7 | 11 |
12 | 13 | 14 | 15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/(protected)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { constructMetadata } from "@/lib/utils"; 5 | import { DeleteAccountSection } from "@/components/dashboard/delete-account"; 6 | import { DashboardHeader } from "@/components/dashboard/header"; 7 | import { UserNameForm } from "@/components/forms/user-name-form"; 8 | import { UserRoleForm } from "@/components/forms/user-role-form"; 9 | 10 | export const metadata = constructMetadata({ 11 | title: "Settings – SaaS Starter", 12 | description: "Configure your account and website settings.", 13 | }); 14 | 15 | export default async function SettingsPage() { 16 | const user = await getCurrentUser(); 17 | 18 | if (!user?.id) redirect("/login"); 19 | 20 | return ( 21 | <> 22 | 26 |
27 | 28 | 29 | 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { sidebarLinks } from "@/config/dashboard"; 4 | import { getCurrentUser } from "@/lib/session"; 5 | import { SearchCommand } from "@/components/dashboard/search-command"; 6 | import { 7 | DashboardSidebar, 8 | MobileSheetSidebar, 9 | } from "@/components/layout/dashboard-sidebar"; 10 | import { ModeToggle } from "@/components/layout/mode-toggle"; 11 | import { UserAccountNav } from "@/components/layout/user-account-nav"; 12 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 13 | 14 | interface ProtectedLayoutProps { 15 | children: React.ReactNode; 16 | } 17 | 18 | export default async function Dashboard({ children }: ProtectedLayoutProps) { 19 | const user = await getCurrentUser(); 20 | 21 | if (!user) redirect("/login"); 22 | 23 | const filteredLinks = sidebarLinks.map((section) => ({ 24 | ...section, 25 | items: section.items.filter( 26 | ({ authorizeOnly }) => !authorizeOnly || authorizeOnly === user.role, 27 | ), 28 | })); 29 | 30 | return ( 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 |
49 | 50 | {children} 51 | 52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth" -------------------------------------------------------------------------------- /app/api/user/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | 5 | export const DELETE = auth(async (req) => { 6 | if (!req.auth) { 7 | return new Response("Not authenticated", { status: 401 }); 8 | } 9 | 10 | const currentUser = req.auth.user; 11 | if (!currentUser) { 12 | return new Response("Invalid user", { status: 401 }); 13 | } 14 | 15 | try { 16 | await prisma.user.delete({ 17 | where: { 18 | id: currentUser.id, 19 | }, 20 | }); 21 | } catch (error) { 22 | return new Response("Internal server error", { status: 500 }); 23 | } 24 | 25 | return new Response("User deleted successfully!", { status: 200 }); 26 | }); 27 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | 3 | import { fontGeist, fontHeading, fontSans, fontUrban } from "@/assets/fonts"; 4 | import { SessionProvider } from "next-auth/react"; 5 | import { ThemeProvider } from "next-themes"; 6 | 7 | import { cn, constructMetadata } from "@/lib/utils"; 8 | import { Toaster } from "@/components/ui/sonner"; 9 | import { Analytics } from "@/components/analytics"; 10 | import ModalProvider from "@/components/modals/providers"; 11 | import { TailwindIndicator } from "@/components/tailwind-indicator"; 12 | 13 | interface RootLayoutProps { 14 | children: React.ReactNode; 15 | } 16 | 17 | export const metadata = constructMetadata(); 18 | 19 | export default function RootLayout({ children }: RootLayoutProps) { 20 | return ( 21 | 22 | 23 | 32 | 33 | 39 | {children} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next" 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /assets/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/assets/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /assets/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/assets/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /assets/fonts/GeistVF.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/assets/fonts/GeistVF.woff2 -------------------------------------------------------------------------------- /assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mickasmt/next-saas-stripe-starter/a78d130af7e04d0250d65c67f217976f7eb3adc2/assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/index.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | import { Inter as FontSans, Urbanist } from "next/font/google"; 3 | 4 | export const fontSans = FontSans({ 5 | subsets: ["latin"], 6 | variable: "--font-sans", 7 | }) 8 | 9 | export const fontUrban = Urbanist({ 10 | subsets: ["latin"], 11 | variable: "--font-urban", 12 | }) 13 | 14 | export const fontHeading = localFont({ 15 | src: "./CalSans-SemiBold.woff2", 16 | variable: "--font-heading", 17 | }) 18 | 19 | export const fontGeist = localFont({ 20 | src: "./GeistVF.woff2", 21 | variable: "--font-geist", 22 | }) 23 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from "next-auth"; 2 | import Google from "next-auth/providers/google"; 3 | import Resend from "next-auth/providers/resend"; 4 | 5 | import { env } from "@/env.mjs"; 6 | import { sendVerificationRequest } from "@/lib/email"; 7 | 8 | export default { 9 | providers: [ 10 | Google({ 11 | clientId: env.GOOGLE_CLIENT_ID, 12 | clientSecret: env.GOOGLE_CLIENT_SECRET, 13 | }), 14 | Resend({ 15 | apiKey: env.RESEND_API_KEY, 16 | from: env.EMAIL_FROM, 17 | // sendVerificationRequest, 18 | }), 19 | ], 20 | } satisfies NextAuthConfig; 21 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import authConfig from "@/auth.config"; 2 | import { PrismaAdapter } from "@auth/prisma-adapter"; 3 | import { UserRole } from "@prisma/client"; 4 | import NextAuth, { type DefaultSession } from "next-auth"; 5 | 6 | import { prisma } from "@/lib/db"; 7 | import { getUserById } from "@/lib/user"; 8 | 9 | // More info: https://authjs.dev/getting-started/typescript#module-augmentation 10 | declare module "next-auth" { 11 | interface Session { 12 | user: { 13 | role: UserRole; 14 | } & DefaultSession["user"]; 15 | } 16 | } 17 | 18 | export const { 19 | handlers: { GET, POST }, 20 | auth, 21 | } = NextAuth({ 22 | adapter: PrismaAdapter(prisma), 23 | session: { strategy: "jwt" }, 24 | pages: { 25 | signIn: "/login", 26 | // error: "/auth/error", 27 | }, 28 | callbacks: { 29 | async session({ token, session }) { 30 | if (session.user) { 31 | if (token.sub) { 32 | session.user.id = token.sub; 33 | } 34 | 35 | if (token.email) { 36 | session.user.email = token.email; 37 | } 38 | 39 | if (token.role) { 40 | session.user.role = token.role; 41 | } 42 | 43 | session.user.name = token.name; 44 | session.user.image = token.picture; 45 | } 46 | 47 | return session; 48 | }, 49 | 50 | async jwt({ token }) { 51 | if (!token.sub) return token; 52 | 53 | const dbUser = await getUserById(token.sub); 54 | 55 | if (!dbUser) return token; 56 | 57 | token.name = dbUser.name; 58 | token.email = dbUser.email; 59 | token.picture = dbUser.image; 60 | token.role = dbUser.role; 61 | 62 | return token; 63 | }, 64 | }, 65 | ...authConfig, 66 | // debug: process.env.NODE_ENV !== "production" 67 | }); 68 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/analytics.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Analytics as VercelAnalytics } from "@vercel/analytics/react" 4 | 5 | export function Analytics() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /components/charts/radar-chart-simple.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TrendingUp } from "lucide-react"; 4 | import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"; 5 | 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { 15 | ChartConfig, 16 | ChartContainer, 17 | ChartTooltip, 18 | ChartTooltipContent, 19 | } from "@/components/ui/chart"; 20 | 21 | const chartData = [ 22 | { month: "January", desktop: 186 }, 23 | { month: "February", desktop: 305 }, 24 | { month: "March", desktop: 237 }, 25 | { month: "April", desktop: 273 }, 26 | { month: "May", desktop: 209 }, 27 | { month: "June", desktop: 214 }, 28 | ]; 29 | 30 | const chartConfig = { 31 | desktop: { 32 | label: "Desktop", 33 | color: "hsl(var(--chart-1))", 34 | }, 35 | } satisfies ChartConfig; 36 | 37 | export function RadarChartSimple() { 38 | return ( 39 | 40 | {/* 41 | Radar Chart 42 | 43 | Showing total visitors for the last 6 months 44 | 45 | */} 46 | 47 | 51 | 52 | } /> 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 |
65 | Trending up by 5.2% this month 66 |
67 |
68 | January - June 2024 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /components/charts/radial-chart-grid.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { TrendingUp } from "lucide-react" 4 | import { PolarGrid, RadialBar, RadialBarChart } from "recharts" 5 | 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card" 14 | import { 15 | ChartConfig, 16 | ChartContainer, 17 | ChartTooltip, 18 | ChartTooltipContent, 19 | } from "@/components/ui/chart" 20 | const chartData = [ 21 | { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" }, 22 | { browser: "safari", visitors: 200, fill: "var(--color-safari)" }, 23 | { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" }, 24 | { browser: "edge", visitors: 173, fill: "var(--color-edge)" }, 25 | { browser: "other", visitors: 90, fill: "var(--color-other)" }, 26 | ] 27 | 28 | const chartConfig = { 29 | visitors: { 30 | label: "Visitors", 31 | }, 32 | chrome: { 33 | label: "Chrome", 34 | color: "hsl(var(--chart-1))", 35 | }, 36 | safari: { 37 | label: "Safari", 38 | color: "hsl(var(--chart-2))", 39 | }, 40 | firefox: { 41 | label: "Firefox", 42 | color: "hsl(var(--chart-3))", 43 | }, 44 | edge: { 45 | label: "Edge", 46 | color: "hsl(var(--chart-4))", 47 | }, 48 | other: { 49 | label: "Other", 50 | color: "hsl(var(--chart-5))", 51 | }, 52 | } satisfies ChartConfig 53 | 54 | export function RadialChartGrid() { 55 | return ( 56 | 57 | {/* 58 | Radial Chart - Grid 59 | January - June 2024 60 | */} 61 | 62 | 66 | 67 | } 70 | /> 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | Trending up by 5.2% this month 79 |
80 |
81 | Showing total visitors for the last 6 months 82 |
83 |
84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /components/content/author.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { BLOG_AUTHORS } from "@/config/blog"; 4 | import { getBlurDataURL } from "@/lib/utils"; 5 | import BlurImage from "@/components/shared/blur-image"; 6 | 7 | export default async function Author({ 8 | username, 9 | imageOnly, 10 | }: { 11 | username: string; 12 | imageOnly?: boolean; 13 | }) { 14 | const authors = BLOG_AUTHORS; 15 | 16 | return imageOnly ? ( 17 | 27 | ) : ( 28 | 34 | 44 |
45 |

46 | {authors[username].name} 47 |

48 |

49 | @{authors[username].twitter} 50 |

51 |
52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/content/blog-card.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Post } from "contentlayer/generated"; 3 | 4 | import { cn, formatDate, placeholderBlurhash } from "@/lib/utils"; 5 | import BlurImage from "@/components/shared/blur-image"; 6 | 7 | import Author from "./author"; 8 | 9 | export function BlogCard({ 10 | data, 11 | priority, 12 | horizontale = false, 13 | }: { 14 | data: Post & { 15 | blurDataURL: string; 16 | }; 17 | priority?: boolean; 18 | horizontale?: boolean; 19 | }) { 20 | return ( 21 |
29 | {data.image && ( 30 |
31 | 45 |
46 | )} 47 |
53 |
54 |

55 | {data.title} 56 |

57 | {data.description && ( 58 |

59 | {data.description} 60 |

61 | )} 62 |
63 |
64 |
65 | {data.authors.map((author) => ( 66 | 67 | ))} 68 |
69 | 70 | {data.date && ( 71 |

72 | {formatDate(data.date)} 73 |

74 | )} 75 |
76 |
77 | 78 | View Article 79 | 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /components/content/blog-posts.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from "@/.contentlayer/generated"; 2 | 3 | import { BlogCard } from "./blog-card"; 4 | 5 | export function BlogPosts({ 6 | posts, 7 | }: { 8 | posts: (Post & { 9 | blurDataURL: string; 10 | })[]; 11 | }) { 12 | return ( 13 |
14 | 15 | 16 |
17 | {posts.slice(1).map((post, idx) => ( 18 | 19 | ))} 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/content/mdx-card.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | interface CardProps extends React.HTMLAttributes { 6 | href?: string 7 | disabled?: boolean 8 | } 9 | 10 | export function MdxCard({ 11 | href, 12 | className, 13 | children, 14 | disabled, 15 | ...props 16 | }: CardProps) { 17 | return ( 18 |
26 |
27 |
28 | {children} 29 |
30 |
31 | {href && ( 32 | 33 | View 34 | 35 | )} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/dashboard/delete-account.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { siteConfig } from "@/config/site"; 4 | import { Button } from "@/components/ui/button"; 5 | import { SectionColumns } from "@/components/dashboard/section-columns"; 6 | import { useDeleteAccountModal } from "@/components/modals/delete-account-modal"; 7 | import { Icons } from "@/components/shared/icons"; 8 | 9 | export function DeleteAccountSection() { 10 | const { setShowDeleteAccountModal, DeleteAccountModal } = 11 | useDeleteAccountModal(); 12 | 13 | const userPaidPlan = true; 14 | 15 | return ( 16 | <> 17 | 18 | 22 |
23 |
24 |
25 | Are you sure ? 26 | 27 | {userPaidPlan ? ( 28 |
29 |
30 | 31 |
32 | Active Subscription 33 |
34 | ) : null} 35 |
36 |
37 | Permanently delete your {siteConfig.name} account 38 | {userPaidPlan ? " and your subscription" : ""}. This action cannot 39 | be undone - please proceed with caution. 40 |
41 |
42 |
43 | 51 |
52 |
53 |
54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /components/dashboard/header.tsx: -------------------------------------------------------------------------------- 1 | interface DashboardHeaderProps { 2 | heading: string; 3 | text?: string; 4 | children?: React.ReactNode; 5 | } 6 | 7 | export function DashboardHeader({ 8 | heading, 9 | text, 10 | children, 11 | }: DashboardHeaderProps) { 12 | return ( 13 |
14 |
15 |

{heading}

16 | {text &&

{text}

} 17 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/dashboard/info-card.tsx: -------------------------------------------------------------------------------- 1 | import { Users } from "lucide-react" 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card" 9 | 10 | export default function InfoCard() { 11 | return ( 12 | 13 | 14 | Subscriptions 15 | 16 | 17 | 18 |
+2350
19 |

+180.1% from last month

20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/dashboard/section-columns.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface SectionColumnsType { 4 | title: string; 5 | description?: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | export function SectionColumns({ 10 | title, 11 | description, 12 | children, 13 | }: SectionColumnsType) { 14 | return ( 15 |
16 |
17 |

{title}

18 |

19 | {description} 20 |

21 |
22 |
{children}
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/dashboard/upgrade-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | 10 | export function UpgradeCard() { 11 | return ( 12 | 13 | 14 | Upgrade to Pro 15 | 16 | Unlock all features and get unlimited access to our support team. 17 | 18 | 19 | 20 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /components/docs/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | import { Icons } from "../shared/icons"; 4 | 5 | interface DocsPageHeaderProps extends React.HTMLAttributes { 6 | heading: string; 7 | text?: string; 8 | } 9 | 10 | export function DocsPageHeader({ 11 | heading, 12 | text, 13 | className, 14 | ...props 15 | }: DocsPageHeaderProps) { 16 | return ( 17 | <> 18 |
19 |
Docs
20 | 21 |
22 | {heading} 23 |
24 |
25 | 26 |
27 |

28 | {heading} 29 |

30 | {text && ( 31 |

{text}

32 | )} 33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/docs/pager.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { Doc } from "contentlayer/generated" 3 | 4 | import { docsConfig } from "@/config/docs" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | import { Icons } from "@/components/shared/icons" 8 | 9 | interface DocsPagerProps { 10 | doc: Doc 11 | } 12 | 13 | export function DocsPager({ doc }: DocsPagerProps) { 14 | const pager = getPagerForDoc(doc) 15 | 16 | if (!pager) { 17 | return null 18 | } 19 | 20 | return ( 21 |
22 | {pager?.prev && ( 23 | 27 | 28 | {pager.prev.title} 29 | 30 | )} 31 | {pager?.next && ( 32 | 36 | {pager.next.title} 37 | 38 | 39 | )} 40 |
41 | ) 42 | } 43 | 44 | export function getPagerForDoc(doc: Doc) { 45 | const flattenedLinks = [null, ...flatten(docsConfig.sidebarNav), null] 46 | const activeIndex = flattenedLinks.findIndex( 47 | (link) => doc.slug === link?.href 48 | ) 49 | const prev = activeIndex !== 0 ? flattenedLinks[activeIndex - 1] : null 50 | const next = 51 | activeIndex !== flattenedLinks.length - 1 52 | ? flattenedLinks[activeIndex + 1] 53 | : null 54 | return { 55 | prev, 56 | next, 57 | } 58 | } 59 | 60 | export function flatten(links: { items?}[]) { 61 | return links.reduce((flat, link) => { 62 | return flat.concat(link.items ? flatten(link.items) : link) 63 | }, []) 64 | } 65 | -------------------------------------------------------------------------------- /components/docs/search.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { Input } from "@/components/ui/input" 7 | import { toast } from "@/components/ui/use-toast" 8 | 9 | interface DocsSearchProps extends React.HTMLAttributes {} 10 | 11 | export function DocsSearch({ className, ...props }: DocsSearchProps) { 12 | function onSubmit(event: React.SyntheticEvent) { 13 | event.preventDefault() 14 | 15 | return toast({ 16 | title: "Not implemented", 17 | description: "We're still working on the search.", 18 | }) 19 | } 20 | 21 | return ( 22 |
27 | 32 | 33 | K 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/docs/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { NavItem } from "types"; 7 | import { docsConfig } from "@/config/docs"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | export interface DocsSidebarNavProps { 11 | setOpen?: (boolean) => void; 12 | } 13 | 14 | export function DocsSidebarNav({ setOpen }: DocsSidebarNavProps) { 15 | const pathname = usePathname(); 16 | const items = docsConfig.sidebarNav; 17 | 18 | return items.length > 0 ? ( 19 |
20 | {items.map((item) => ( 21 |
22 |

23 | {item.title} 24 |

25 | {item.items ? ( 26 | 31 | ) : null} 32 |
33 | ))} 34 |
35 | ) : null; 36 | } 37 | 38 | interface DocsSidebarNavItemsProps { 39 | items: NavItem[]; 40 | pathname: string | null; 41 | setOpen?: (boolean) => void; 42 | } 43 | 44 | export function DocsSidebarNavItems({ 45 | items, 46 | setOpen, 47 | pathname, 48 | }: DocsSidebarNavItemsProps) { 49 | return items?.length > 0 ? ( 50 |
51 | {items.map((item, index) => 52 | !item.disabled && item.href ? ( 53 | { 57 | if (setOpen) setOpen(false); 58 | }} 59 | className={cn( 60 | "flex w-full items-center rounded-md px-2 py-1.5 text-muted-foreground hover:underline", 61 | { 62 | "font-medium text-blue-600 dark:text-blue-400": 63 | pathname === item.href, 64 | }, 65 | )} 66 | target={item.external ? "_blank" : ""} 67 | rel={item.external ? "noreferrer" : ""} 68 | > 69 | {item.title} 70 | 71 | ) : ( 72 | 76 | {item.title} 77 | 78 | ), 79 | )} 80 |
81 | ) : null; 82 | } 83 | -------------------------------------------------------------------------------- /components/forms/billing-form-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | import { generateUserStripe } from "@/actions/generate-user-stripe"; 5 | import { SubscriptionPlan, UserSubscriptionPlan } from "@/types"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { Icons } from "@/components/shared/icons"; 9 | 10 | interface BillingFormButtonProps { 11 | offer: SubscriptionPlan; 12 | subscriptionPlan: UserSubscriptionPlan; 13 | year: boolean; 14 | } 15 | 16 | export function BillingFormButton({ 17 | year, 18 | offer, 19 | subscriptionPlan, 20 | }: BillingFormButtonProps) { 21 | let [isPending, startTransition] = useTransition(); 22 | const generateUserStripeSession = generateUserStripe.bind( 23 | null, 24 | offer.stripeIds[year ? "yearly" : "monthly"], 25 | ); 26 | 27 | const stripeSessionAction = () => 28 | startTransition(async () => await generateUserStripeSession()); 29 | 30 | const userOffer = 31 | subscriptionPlan.stripePriceId === 32 | offer.stripeIds[year ? "yearly" : "monthly"]; 33 | 34 | return ( 35 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components/forms/customer-portal-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTransition } from "react"; 4 | import { openCustomerPortal } from "@/actions/open-customer-portal"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Icons } from "@/components/shared/icons"; 8 | 9 | interface CustomerPortalButtonProps { 10 | userStripeId: string; 11 | } 12 | 13 | export function CustomerPortalButton({ 14 | userStripeId, 15 | }: CustomerPortalButtonProps) { 16 | let [isPending, startTransition] = useTransition(); 17 | const generateUserStripeSession = openCustomerPortal.bind(null, userStripeId); 18 | 19 | const stripeSessionAction = () => 20 | startTransition(async () => await generateUserStripeSession()); 21 | 22 | return ( 23 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /components/forms/newsletter-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { z } from "zod"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { Input } from "@/components/ui/input"; 17 | import { toast } from "@/components/ui/use-toast"; 18 | 19 | const FormSchema = z.object({ 20 | email: z.string().email({ 21 | message: "Enter a valid email.", 22 | }), 23 | }); 24 | 25 | export function NewsletterForm() { 26 | const form = useForm>({ 27 | resolver: zodResolver(FormSchema), 28 | defaultValues: { 29 | email: "", 30 | }, 31 | }); 32 | 33 | function onSubmit(data: z.infer) { 34 | form.reset(); 35 | toast({ 36 | title: "You submitted the following values:", 37 | description: ( 38 |
39 |           {JSON.stringify(data, null, 2)}
40 |         
41 | ), 42 | }); 43 | } 44 | 45 | return ( 46 |
47 | 51 | ( 55 | 56 | Subscribe to our newsletter 57 | 58 | 64 | 65 | 66 | 67 | )} 68 | /> 69 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /components/layout/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu" 13 | import { Icons } from "@/components/shared/icons" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | 30 | Light 31 | 32 | setTheme("dark")}> 33 | 34 | Dark 35 | 36 | setTheme("system")}> 37 | 38 | System 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/modals/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, Dispatch, ReactNode, SetStateAction } from "react"; 4 | 5 | import { useSignInModal } from "@/components/modals//sign-in-modal"; 6 | 7 | export const ModalContext = createContext<{ 8 | setShowSignInModal: Dispatch>; 9 | }>({ 10 | setShowSignInModal: () => {}, 11 | }); 12 | 13 | export default function ModalProvider({ children }: { children: ReactNode }) { 14 | const { SignInModal, setShowSignInModal } = useSignInModal(); 15 | 16 | return ( 17 | 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /components/modals/sign-in-modal.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from "next-auth/react"; 2 | import { 3 | Dispatch, 4 | SetStateAction, 5 | useCallback, 6 | useMemo, 7 | useState, 8 | } from "react"; 9 | 10 | import { Icons } from "@/components/shared/icons"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Modal } from "@/components/ui/modal"; 13 | import { siteConfig } from "@/config/site"; 14 | 15 | function SignInModal({ 16 | showSignInModal, 17 | setShowSignInModal, 18 | }: { 19 | showSignInModal: boolean; 20 | setShowSignInModal: Dispatch>; 21 | }) { 22 | const [signInClicked, setSignInClicked] = useState(false); 23 | 24 | return ( 25 | 26 |
27 |
28 | 29 | 30 | 31 |

Sign In

32 |

33 | This is strictly for demo purposes - only your email and profile 34 | picture will be stored. 35 |

36 |
37 | 38 |
39 | 58 |
59 |
60 |
61 | ); 62 | } 63 | 64 | export function useSignInModal() { 65 | const [showSignInModal, setShowSignInModal] = useState(false); 66 | 67 | const SignInModalCallback = useCallback(() => { 68 | return ( 69 | 73 | ); 74 | }, [showSignInModal, setShowSignInModal]); 75 | 76 | return useMemo( 77 | () => ({ 78 | setShowSignInModal, 79 | SignInModal: SignInModalCallback, 80 | }), 81 | [setShowSignInModal, SignInModalCallback], 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /components/pricing/billing-info.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import * as React from "react"; 3 | 4 | import { CustomerPortalButton } from "@/components/forms/customer-portal-button"; 5 | import { buttonVariants } from "@/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { cn, formatDate } from "@/lib/utils"; 15 | import { UserSubscriptionPlan } from "types"; 16 | 17 | interface BillingInfoProps extends React.HTMLAttributes { 18 | userSubscriptionPlan: UserSubscriptionPlan; 19 | } 20 | 21 | export function BillingInfo({ userSubscriptionPlan }: BillingInfoProps) { 22 | const { 23 | title, 24 | description, 25 | stripeCustomerId, 26 | isPaid, 27 | isCanceled, 28 | stripeCurrentPeriodEnd, 29 | } = userSubscriptionPlan; 30 | 31 | return ( 32 | 33 | 34 | Subscription Plan 35 | 36 | You are currently on the {title} plan. 37 | 38 | 39 | {description} 40 | 41 | {isPaid ? ( 42 |

43 | {isCanceled 44 | ? "Your plan will be canceled on " 45 | : "Your plan renews on "} 46 | {formatDate(stripeCurrentPeriodEnd)}. 47 |

48 | ) : null} 49 | 50 | {isPaid && stripeCustomerId ? ( 51 | 52 | ) : ( 53 | 54 | Choose a plan 55 | 56 | )} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /components/pricing/pricing-faq.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionItem, 5 | AccordionTrigger, 6 | } from "@/components/ui/accordion"; 7 | 8 | import { HeaderSection } from "../shared/header-section"; 9 | 10 | const pricingFaqData = [ 11 | { 12 | id: "item-1", 13 | question: "What is the cost of the free plan?", 14 | answer: 15 | "Our free plan is completely free, with no monthly or annual charges. It's a great way to get started and explore our basic features.", 16 | }, 17 | { 18 | id: "item-2", 19 | question: "How much does the Basic Monthly plan cost?", 20 | answer: 21 | "The Basic Monthly plan is priced at $15 per month. It provides access to our core features and is billed on a monthly basis.", 22 | }, 23 | { 24 | id: "item-3", 25 | question: "What is the price of the Pro Monthly plan?", 26 | answer: 27 | "The Pro Monthly plan is available for $25 per month. It offers advanced features and is billed on a monthly basis for added flexibility.", 28 | }, 29 | { 30 | id: "item-4", 31 | question: "Do you offer any annual subscription plans?", 32 | answer: 33 | "Yes, we offer annual subscription plans for even more savings. The Basic Annual plan is $144 per year, and the Pro Annual plan is $300 per year.", 34 | }, 35 | { 36 | id: "item-5", 37 | question: "Is there a trial period for the paid plans?", 38 | answer: 39 | "We offer a 14-day free trial for both the Pro Monthly and Pro Annual plans. It's a great way to experience all the features before committing to a paid subscription.", 40 | }, 41 | ]; 42 | 43 | export function PricingFaq() { 44 | return ( 45 |
46 | 53 | 54 | 55 | {pricingFaqData.map((faqItem) => ( 56 | 57 | {faqItem.question} 58 | 59 | {faqItem.answer} 60 | 61 | 62 | ))} 63 | 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /components/sections/info-landing.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { InfoLdg } from "@/types"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Icons } from "@/components/shared/icons"; 6 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 7 | 8 | interface InfoLandingProps { 9 | data: InfoLdg; 10 | reverse?: boolean; 11 | } 12 | 13 | export default function InfoLanding({ 14 | data, 15 | reverse = false, 16 | }: InfoLandingProps) { 17 | return ( 18 |
19 | 20 |
21 |

22 | {data.title} 23 |

24 |

25 | {data.description} 26 |

27 |
28 | {data.list.map((item, index) => { 29 | const Icon = Icons[item.icon || "arrowRight"]; 30 | return ( 31 |
32 |
33 | 34 | {item.title} 35 |
36 |
37 | {item.description} 38 |
39 |
40 | ); 41 | })} 42 |
43 |
44 |
50 |
51 | {data.title} 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /components/sections/preview-landing.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; 4 | 5 | export default function PreviewLanding() { 6 | return ( 7 |
8 | 9 |
10 |
11 | preview landing 19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/sections/testimonials.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { testimonials } from "@/config/landing"; 4 | import { HeaderSection } from "@/components/shared/header-section"; 5 | 6 | export default function Testimonials() { 7 | return ( 8 |
9 |
10 | 16 | 17 |
18 | {testimonials.map((item) => ( 19 |
20 |
21 |
22 |
23 |
24 | 25 | {item.name} 32 | 33 |
34 |

35 | {item.name} 36 |

37 |

38 | {item.job} 39 |

40 |
41 |
42 | {item.review} 43 |
44 |
45 |
46 |
47 | ))} 48 |
49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/shared/blur-image.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import type { ComponentProps } from "react"; 5 | import Image from "next/image"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | export default function BlurImage(props: ComponentProps) { 10 | const [isLoading, setLoading] = useState(true); 11 | 12 | return ( 13 | {props.alt} setLoading(false)} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/shared/callout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | Ban, 4 | CircleAlert, 5 | CircleCheckBig, 6 | FileText, 7 | Info, 8 | Lightbulb, 9 | } from "lucide-react"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | interface CalloutProps { 14 | twClass?: string; 15 | children?: React.ReactNode; 16 | type?: keyof typeof dataCallout; 17 | } 18 | 19 | const dataCallout = { 20 | default: { 21 | icon: Info, 22 | classes: 23 | "border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200", 24 | }, 25 | danger: { 26 | icon: CircleAlert, 27 | classes: 28 | "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", 29 | }, 30 | error: { 31 | icon: Ban, 32 | classes: 33 | "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", 34 | }, 35 | idea: { 36 | icon: Lightbulb, 37 | classes: 38 | "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", 39 | }, 40 | info: { 41 | icon: Info, 42 | classes: 43 | "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", 44 | }, 45 | note: { 46 | icon: FileText, 47 | classes: 48 | "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", 49 | }, 50 | success: { 51 | icon: CircleCheckBig, 52 | classes: 53 | "border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300", 54 | }, 55 | warning: { 56 | icon: AlertTriangle, 57 | classes: 58 | "border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300", 59 | }, 60 | }; 61 | 62 | export function Callout({ 63 | children, 64 | twClass, 65 | type = "default", 66 | ...props 67 | }: CalloutProps) { 68 | const { icon: Icon, classes } = dataCallout[type]; 69 | return ( 70 |
78 |
79 | 80 |
81 |
{children}
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /components/shared/card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | } from "@/components/ui/card"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | 9 | export function CardSkeleton() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components/shared/copy-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | import { Icons } from "./icons"; 9 | 10 | interface CopyButtonProps extends React.HTMLAttributes { 11 | value: string; 12 | } 13 | 14 | export function CopyButton({ value, className, ...props }: CopyButtonProps) { 15 | const [hasCopied, setHasCopied] = React.useState(false); 16 | 17 | React.useEffect(() => { 18 | setTimeout(() => { 19 | setHasCopied(false); 20 | }, 2000); 21 | }, [hasCopied]); 22 | 23 | const handleCopyValue = (value: string) => { 24 | navigator.clipboard.writeText(value); 25 | setHasCopied(true); 26 | }; 27 | 28 | return ( 29 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/shared/empty-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Icons } from "@/components/shared/icons"; 5 | 6 | interface EmptyPlaceholderProps extends React.HTMLAttributes {} 7 | 8 | export function EmptyPlaceholder({ 9 | className, 10 | children, 11 | ...props 12 | }: EmptyPlaceholderProps) { 13 | return ( 14 |
21 |
22 | {children} 23 |
24 |
25 | ); 26 | } 27 | 28 | interface EmptyPlaceholderIconProps 29 | extends Partial> { 30 | name: keyof typeof Icons; 31 | ref?: 32 | | ((instance: SVGSVGElement | null) => void) 33 | | React.RefObject 34 | | null; 35 | } 36 | 37 | EmptyPlaceholder.Icon = function EmptyPlaceholderIcon({ 38 | name, 39 | className, 40 | ...props 41 | }: EmptyPlaceholderIconProps) { 42 | const Icon = Icons[name]; 43 | 44 | if (!Icon) { 45 | return null; 46 | } 47 | 48 | return ( 49 |
50 | 51 |
52 | ); 53 | }; 54 | 55 | interface EmptyPlaceholderTitleProps 56 | extends React.HTMLAttributes {} 57 | 58 | EmptyPlaceholder.Title = function EmptyPlaceholderTitle({ 59 | className, 60 | ...props 61 | }: EmptyPlaceholderTitleProps) { 62 | return ( 63 |

67 | ); 68 | }; 69 | 70 | interface EmptyPlaceholderDescriptionProps 71 | extends React.HTMLAttributes {} 72 | 73 | EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ 74 | className, 75 | ...props 76 | }: EmptyPlaceholderDescriptionProps) { 77 | return ( 78 |

85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /components/shared/header-section.tsx: -------------------------------------------------------------------------------- 1 | interface HeaderSectionProps { 2 | label?: string; 3 | title: string; 4 | subtitle?: string; 5 | } 6 | 7 | export function HeaderSection({ label, title, subtitle }: HeaderSectionProps) { 8 | return ( 9 |

10 | {label ? ( 11 |
12 | {label} 13 |
14 | ) : null} 15 |

16 | {title} 17 |

18 | {subtitle ? ( 19 |

20 | {subtitle} 21 |

22 | ) : null} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/shared/max-width-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export default function MaxWidthWrapper({ 6 | className, 7 | children, 8 | large = false, 9 | }: { 10 | className?: string; 11 | large?: boolean; 12 | children: ReactNode; 13 | }) { 14 | return ( 15 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/shared/section-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export function SkeletonSection({ card = false }: { card?: boolean }) { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 | {card ? ( 12 | 13 | ) : ( 14 | <> 15 |
16 | 17 | 18 |
19 | 20 | 21 | )} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /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/shared/icons" 6 | 7 | interface UserAvatarProps extends AvatarProps { 8 | user: Pick 9 | } 10 | 11 | export function UserAvatar({ user, ...props }: UserAvatarProps) { 12 | return ( 13 | 14 | {user.image ? ( 15 | 16 | ) : ( 17 | 18 | {user.name} 19 | 20 | 21 | )} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null 3 | 4 | return ( 5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 55 |
{children}
56 |
57 | )) 58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 59 | 60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 61 | -------------------------------------------------------------------------------- /components/ui/alert.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 alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.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/badge.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 badgeVariants = cva( 7 | "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", 13 | secondary: 14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", 15 | destructive: 16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ring-offset-background select-none active:scale-[0.98]", 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 | disable: 21 | "border border-input bg-transparent text-neutral-600 cursor-not-allowed", 22 | }, 23 | size: { 24 | default: "h-10 py-2 px-4", 25 | sm: "h-9 px-3", 26 | lg: "h-11 px-8", 27 | icon: "size-10", 28 | }, 29 | rounded: { 30 | default: "rounded-md", 31 | sm: "rounded-sm", 32 | lg: "rounded-lg", 33 | xl: "rounded-xl", 34 | "2xl": "rounded-2xl", 35 | full: "rounded-full", 36 | }, 37 | }, 38 | defaultVariants: { 39 | variant: "default", 40 | size: "default", 41 | rounded: "default", 42 | }, 43 | }, 44 | ); 45 | 46 | export interface ButtonProps 47 | extends React.ButtonHTMLAttributes, 48 | VariantProps {} 49 | 50 | const Button = React.forwardRef( 51 | ({ className, variant, size, rounded, ...props }, ref) => { 52 | return ( 53 |