├── .eslintrc.json ├── src ├── app │ ├── favicon.ico │ ├── (main) │ │ ├── todos │ │ │ ├── _components │ │ │ │ ├── validation.ts │ │ │ │ ├── actions.ts │ │ │ │ ├── todo.tsx │ │ │ │ └── create-todo-button.tsx │ │ │ └── page.tsx │ │ └── settings │ │ │ ├── _components │ │ │ ├── actions.tsx │ │ │ └── delete-account-button.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── _components │ │ ├── theme-provider.tsx │ │ ├── get-started-button.tsx │ │ ├── providers.tsx │ │ ├── header │ │ │ ├── links.tsx │ │ │ ├── header.tsx │ │ │ └── feedback.tsx │ │ ├── mode-toggle.tsx │ │ └── footer.tsx │ ├── (subscribe) │ │ └── success │ │ │ └── page.tsx │ ├── (landing) │ │ └── _sections │ │ │ ├── features.tsx │ │ │ ├── hero.tsx │ │ │ ├── pricing.tsx │ │ │ └── reserved.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── (legal) │ │ ├── privacy │ │ │ └── page.tsx │ │ └── terms-of-service │ │ │ └── page.tsx │ ├── changelog │ │ └── page.tsx │ └── globals.css ├── middleware.ts ├── lib │ ├── utils.ts │ ├── stripe.ts │ ├── events.ts │ ├── get-server-session.ts │ └── auth.ts ├── data-access │ ├── users.ts │ ├── subscriptions.ts │ └── todos.ts ├── components │ ├── auth │ │ ├── signed-out.tsx │ │ ├── signed-in.tsx │ │ └── subscription-status.tsx │ ├── loader-button.tsx │ ├── submit-button.tsx │ ├── send-event-on-load.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── checkbox.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── card.tsx │ │ ├── drawer.tsx │ │ ├── dialog.tsx │ │ ├── use-toast.ts │ │ ├── form.tsx │ │ ├── alert-dialog.tsx │ │ ├── toast.tsx │ │ └── dropdown-menu.tsx │ ├── stripe │ │ └── upgrade-button │ │ │ ├── actions.ts │ │ │ └── upgrade-button.tsx │ └── custom │ │ └── edit-text.tsx ├── use-cases │ ├── users.ts │ ├── subscriptions.ts │ ├── authorization.ts │ └── todos.ts ├── db │ ├── index.ts │ └── schema.ts ├── hooks │ └── use-media-query.tsx └── env.ts ├── public ├── computer.jpeg ├── vercel.svg └── next.svg ├── next.config.mjs ├── postcss.config.mjs ├── drizzle.config.ts ├── docker-compose.yml ├── components.json ├── .env.sample ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── tailwind.config.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/ppai-next-starter/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/computer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/ppai-next-starter/HEAD/public/computer.jpeg -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { default } from "next-auth/middleware"; 2 | 3 | export const config = { matcher: ["/todos", "/settings"] }; 4 | -------------------------------------------------------------------------------- /src/app/(main)/todos/_components/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const todoSchema = z.object({ 4 | text: z.string().min(1).max(500), 5 | }); 6 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import Stripe from "stripe"; 3 | 4 | export const stripe = new Stripe(env.STRIPE_API_KEY, { 5 | apiVersion: "2024-04-10", 6 | typescript: true, 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authConfig } from "@/lib/auth"; 3 | 4 | const handler = NextAuth(authConfig); 5 | 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/data-access/users.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/db"; 2 | import { users } from "@/db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | 5 | export async function deleteUser(userId: string) { 6 | await database.delete(users).where(eq(users.id, userId)); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/auth/signed-out.tsx: -------------------------------------------------------------------------------- 1 | import { AwaitedReactNode } from "react"; 2 | import { getSSRSession } from "@/lib/get-server-session"; 3 | 4 | export async function SignedOut({ children }: { children: AwaitedReactNode }) { 5 | const { isLoggedIn } = await getSSRSession(); 6 | return !isLoggedIn && children; 7 | } 8 | -------------------------------------------------------------------------------- /src/data-access/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/db"; 2 | 3 | export async function getSubscription(userId: string) { 4 | const subscription = await database.query.subscriptions.findFirst({ 5 | where: (users, { eq }) => eq(users.userId, userId), 6 | }); 7 | 8 | return subscription; 9 | } 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | export default defineConfig({ 5 | schema: "./src/db/schema.ts", 6 | driver: "pg", 7 | dbCredentials: { 8 | connectionString: env.DATABASE_URL, 9 | }, 10 | verbose: true, 11 | strict: true, 12 | }); 13 | -------------------------------------------------------------------------------- /src/use-cases/users.ts: -------------------------------------------------------------------------------- 1 | import { deleteUser } from "@/data-access/users"; 2 | 3 | export async function deleteUserUseCase( 4 | userId: string, 5 | userToDeleteId: string 6 | ) { 7 | if (userId !== userToDeleteId) { 8 | throw new Error("You can only delete your own account"); 9 | } 10 | 11 | await deleteUser(userId); 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | ppai-starter: 4 | image: postgres 5 | restart: always 6 | container_name: ppai-starter 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_PASSWORD: example 11 | PGDATA: /data/postgres 12 | volumes: 13 | - postgres:/data/postgres 14 | 15 | volumes: 16 | postgres: 17 | -------------------------------------------------------------------------------- /src/app/_components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/use-cases/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { getSubscription } from "@/data-access/subscriptions"; 2 | 3 | export async function isUserSubscribed(userId: string) { 4 | const subscription = await getSubscription(userId); 5 | 6 | if (!subscription) { 7 | return false; 8 | } 9 | 10 | if (subscription.stripeCurrentPeriodEnd < new Date().toISOString()) { 11 | return false; 12 | } 13 | 14 | return true; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/(main)/settings/_components/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getSSRSession } from "@/lib/get-server-session"; 4 | import { deleteUserUseCase } from "@/use-cases/users"; 5 | 6 | export async function deleteAccountAction() { 7 | const { user } = await getSSRSession(); 8 | 9 | if (!user) { 10 | throw new Error("You must be signed in to delete your account"); 11 | } 12 | 13 | await deleteUserUseCase(user.id, user.id); 14 | } 15 | -------------------------------------------------------------------------------- /src/use-cases/authorization.ts: -------------------------------------------------------------------------------- 1 | import { getTodo } from "@/data-access/todos"; 2 | 3 | /** 4 | * Use this method inside of use cases to verify a user has access to a todo. 5 | */ 6 | export async function getTodoAccess(userId: string, todoId: string) { 7 | const todo = await getTodo(todoId); 8 | 9 | if (!todo) { 10 | return null; 11 | } 12 | 13 | if (todo.userId !== userId) { 14 | return null; 15 | } 16 | 17 | return { todo }; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/events.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | 3 | export async function trackEvent(key: string) { 4 | if (env.NEXT_PUBLIC_SKIP_EVENTS) { 5 | return; 6 | } 7 | await fetch("https://projectplannerai.com/api/events", { 8 | method: "POST", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | body: JSON.stringify({ 13 | key, 14 | projectId: env.NEXT_PUBLIC_PROJECT_PLANNER_ID, 15 | }), 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | HOSTNAME="http://localhost:3000" 2 | DATABASE_URL="postgresql://postgres:example@localhost:5432/postgres" 3 | GOOGLE_CLIENT_ID="replace_me" 4 | GOOGLE_CLIENT_SECRET="replace_me" 5 | NEXTAUTH_SECRET="openssl rand -base64 32" 6 | STRIPE_API_KEY="replace_me" 7 | STRIPE_WEBHOOK_SECRET="replace_me" 8 | PRICE_ID="replace_me" 9 | NEXT_PUBLIC_STRIPE_KEY="replace_me" 10 | NEXT_PUBLIC_STRIPE_MANAGE_URL="replace_me" 11 | NEXT_PUBLIC_PROJECT_PLANNER_ID="replace_me" 12 | NEXT_PUBLIC_SKIP_EVENT=true 13 | -------------------------------------------------------------------------------- /src/app/_components/get-started-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { trackEvent } from "@/lib/events"; 5 | import { signIn } from "next-auth/react"; 6 | 7 | export function GetStartedButton() { 8 | return ( 9 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/loader-button.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react"; 2 | import { Button, ButtonProps } from "@/components/ui/button"; 3 | 4 | export function LoaderButton({ 5 | children, 6 | isLoading, 7 | ...props 8 | }: ButtonProps & { isLoading?: boolean }) { 9 | return ( 10 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | .env -------------------------------------------------------------------------------- /src/app/_components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SessionProvider } from "next-auth/react"; 4 | import { ReactNode } from "react"; 5 | import { ThemeProvider } from "./theme-provider"; 6 | 7 | export function Providers({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | 16 | {children} 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/auth/signed-in.tsx: -------------------------------------------------------------------------------- 1 | import { Session } from "next-auth"; 2 | import { AwaitedReactNode } from "react"; 3 | import { getSSRSession } from "@/lib/get-server-session"; 4 | 5 | export async function SignedIn({ 6 | children, 7 | }: { 8 | children: 9 | | (({ user }: { user: Session["user"] }) => AwaitedReactNode) 10 | | AwaitedReactNode; 11 | }) { 12 | const { isLoggedIn, user } = await getSSRSession(); 13 | if (children instanceof Function) { 14 | return isLoggedIn && children?.({ user: user! }); 15 | } else { 16 | return isLoggedIn && children; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/submit-button.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react"; 2 | import { ReactNode } from "react"; 3 | import { useFormStatus } from "react-dom"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export function SubmitButton({ children }: { children: ReactNode }) { 7 | const { pending } = useFormStatus(); 8 | 9 | return ( 10 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/get-server-session.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GetServerSidePropsContext, 3 | NextApiRequest, 4 | NextApiResponse, 5 | } from "next"; 6 | import { getServerSession as getNextAuthServerSession } from "next-auth"; 7 | import { authConfig } from "./auth"; 8 | import "server-only"; 9 | 10 | export async function getSSRSession( 11 | ...args: 12 | | [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]] 13 | | [NextApiRequest, NextApiResponse] 14 | | [] 15 | ) { 16 | const session = await getNextAuthServerSession(...args, authConfig); 17 | 18 | return { 19 | isLoggedIn: !!session, 20 | user: session?.user, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import * as schema from "./schema"; 3 | import { PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js"; 4 | import postgres from "postgres"; 5 | 6 | declare global { 7 | // eslint-disable-next-line no-var -- only var works here 8 | var db: PostgresJsDatabase | undefined; 9 | } 10 | 11 | let db: PostgresJsDatabase; 12 | 13 | if (env.NODE_ENV === "production") { 14 | db = drizzle(postgres(env.DATABASE_URL), { schema }); 15 | } else { 16 | if (!global.db) { 17 | global.db = drizzle(postgres(env.DATABASE_URL), { schema }); 18 | } 19 | db = global.db; 20 | } 21 | 22 | export { db as database }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/send-event-on-load.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { trackEvent } from "@/lib/events"; 4 | import { useEffect, useRef } from "react"; 5 | 6 | export function SendEventOnLoad({ 7 | eventKey, 8 | afterEventSent, 9 | }: { 10 | eventKey: string; 11 | afterEventSent?: () => void; 12 | }) { 13 | const isSent = useRef(false); 14 | 15 | useEffect(() => { 16 | if (isSent.current) return; 17 | isSent.current = true; 18 | trackEvent(eventKey) 19 | .then(() => { 20 | afterEventSent?.(); 21 | }) 22 | .catch((err) => { 23 | console.error("Error sending event:", err); 24 | }); 25 | }, [eventKey, afterEventSent]); 26 | 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /src/app/_components/header/links.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | export function Links() { 8 | const path = usePathname(); 9 | 10 | if (path !== "/") { 11 | return null; 12 | } 13 | 14 | return ( 15 |
16 | 19 | 20 | 23 | 24 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/auth/subscription-status.tsx: -------------------------------------------------------------------------------- 1 | import { AwaitedReactNode } from "react"; 2 | import { getSSRSession } from "@/lib/get-server-session"; 3 | import { isUserSubscribed } from "@/use-cases/subscriptions"; 4 | 5 | export async function Unsubscribed({ 6 | children, 7 | }: { 8 | children: AwaitedReactNode; 9 | }) { 10 | const { isLoggedIn, user } = await getSSRSession(); 11 | if (!isLoggedIn) return null; 12 | const isSubscribed = isLoggedIn ? await isUserSubscribed(user!.id) : false; 13 | if (!isSubscribed) return children; 14 | return null; 15 | } 16 | 17 | export async function Subscribed({ children }: { children: AwaitedReactNode }) { 18 | const { isLoggedIn, user } = await getSSRSession(); 19 | if (!isLoggedIn) return null; 20 | const isSubscribed = isLoggedIn ? await isUserSubscribed(user!.id) : false; 21 | if (isSubscribed) return children; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "@/components/ui/toast" 11 | import { useToast } from "@/components/ui/use-toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(subscribe)/success/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback } from "react"; 4 | 5 | import { useRouter } from "next/navigation"; 6 | 7 | import { Loader2 } from "lucide-react"; 8 | import { SendEventOnLoad } from "@/components/send-event-on-load"; 9 | 10 | export default function SuccessPage() { 11 | const router = useRouter(); 12 | 13 | const afterEventSent = useCallback(() => { 14 | setTimeout(() => { 15 | router.push("/todos"); 16 | }, 1500); 17 | }, [router]); 18 | 19 | return ( 20 | <> 21 | 25 |
26 |

Subscription Successful

27 |

Thank you for subscribing!

28 |

redirecting to your dashboard...

29 | 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(landing)/_sections/features.tsx: -------------------------------------------------------------------------------- 1 | export function FeaturesSection() { 2 | return ( 3 |
7 |

8 | Including all of the modern libraries you'd need 9 |

10 | 11 |
    12 |
  • Authentication (Next-Auth)
  • 13 |
  • Authorization (custom)
  • 14 |
  • Subscription Management (Stripe)
  • 15 |
  • Stripe Integration / Webhooks
  • 16 |
  • Todo Management
  • 17 |
  • Drizzle ORM
  • 18 |
  • Light / Dark Mode
  • 19 |
  • ShadCN components
  • 20 |
  • Tailwind CSS
  • 21 |
  • Account Deletion
  • 22 |
  • Changelog (via Project Planner AI)
  • 23 |
  • Analytics (via Project Planner AI)
  • 24 |
  • Feedback (via Project Planner AI)
  • 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Web Dev Cody 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 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/stripe/upgrade-button/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { env } from "@/env"; 4 | import { getSSRSession } from "@/lib/get-server-session"; 5 | import { stripe } from "@/lib/stripe"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export async function generateStripeSessionAction() { 9 | const session = await getSSRSession(); 10 | 11 | if (!session) { 12 | throw new Error("Unauthorized"); 13 | } 14 | 15 | if (!session.user) { 16 | throw new Error("Unauthorized"); 17 | } 18 | 19 | if (!session.user.email) { 20 | throw new Error("Email is required"); 21 | } 22 | 23 | if (!session.user.id) { 24 | throw new Error("Id is required"); 25 | } 26 | 27 | const stripeSession = await stripe.checkout.sessions.create({ 28 | success_url: `${env.HOSTNAME}/success`, 29 | cancel_url: `${env.HOSTNAME}/cancel`, 30 | payment_method_types: ["card"], 31 | mode: "subscription", 32 | customer_email: session.user.email, 33 | line_items: [ 34 | { 35 | price: env.PRICE_ID, 36 | quantity: 1, 37 | }, 38 | ], 39 | metadata: { 40 | userId: session.user.id, 41 | }, 42 | }); 43 | 44 | redirect(stripeSession.url!); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/stripe/upgrade-button/upgrade-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { generateStripeSessionAction } from "./actions"; 5 | import { useFormStatus } from "react-dom"; 6 | import { Loader2Icon } from "lucide-react"; 7 | import { ReactNode } from "react"; 8 | import { trackEvent } from "@/lib/events"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | export function UpgradeButton({ className }: { className?: string }) { 12 | return ( 13 |
{ 15 | e.preventDefault(); 16 | trackEvent("user clicked upgrade button"); 17 | generateStripeSessionAction(); 18 | }} 19 | > 20 | Upgrade 21 |
22 | ); 23 | } 24 | 25 | function LoaderButton({ 26 | children, 27 | className, 28 | }: { 29 | children: ReactNode; 30 | className?: string; 31 | }) { 32 | const { pending } = useFormStatus(); 33 | 34 | return ( 35 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/use-media-query.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useMediaQuery() { 4 | const [device, setDevice] = useState<'mobile' | 'tablet' | 'desktop' | null>( 5 | null, 6 | ); 7 | const [dimensions, setDimensions] = useState<{ 8 | width: number; 9 | height: number; 10 | } | null>(null); 11 | 12 | useEffect(() => { 13 | const checkDevice = () => { 14 | if (window.matchMedia('(max-width: 640px)').matches) { 15 | setDevice('mobile'); 16 | } else if ( 17 | window.matchMedia('(min-width: 641px) and (max-width: 1024px)').matches 18 | ) { 19 | setDevice('tablet'); 20 | } else { 21 | setDevice('desktop'); 22 | } 23 | setDimensions({ width: window.innerWidth, height: window.innerHeight }); 24 | }; 25 | 26 | // Initial detection 27 | checkDevice(); 28 | 29 | // Listener for windows resize 30 | window.addEventListener('resize', checkDevice); 31 | 32 | // Cleanup listener 33 | return () => { 34 | window.removeEventListener('resize', checkDevice); 35 | }; 36 | }, []); 37 | 38 | return { 39 | device, 40 | width: dimensions?.width, 41 | height: dimensions?.height, 42 | isMobile: device === 'mobile', 43 | isTablet: device === 'tablet', 44 | isDesktop: device === 'desktop', 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/(main)/todos/_components/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getSSRSession } from "@/lib/get-server-session"; 4 | import z from "zod"; 5 | import { todoSchema } from "./validation"; 6 | import { 7 | createTodoUseCase, 8 | deleteTodoUseCase, 9 | setTodoCompleteStatusUseCase, 10 | } from "@/use-cases/todos"; 11 | import { revalidatePath } from "next/cache"; 12 | 13 | export async function createTodoAction(data: z.infer) { 14 | const { user } = await getSSRSession(); 15 | 16 | if (!user) { 17 | throw new Error("Unauthorized"); 18 | } 19 | 20 | const newTodo = todoSchema.parse(data); 21 | 22 | await createTodoUseCase(user.id, newTodo.text); 23 | 24 | revalidatePath("/todos"); 25 | } 26 | 27 | export async function deleteTodoAction(todoId: string) { 28 | const { user } = await getSSRSession(); 29 | 30 | if (!user) { 31 | throw new Error("Unauthorized"); 32 | } 33 | 34 | await deleteTodoUseCase(user.id, todoId); 35 | 36 | revalidatePath("/todos"); 37 | } 38 | 39 | export async function setTodoCompleteStatusAction( 40 | todoId: string, 41 | isCompleted: boolean 42 | ) { 43 | const { user } = await getSSRSession(); 44 | 45 | if (!user) { 46 | throw new Error("Unauthorized"); 47 | } 48 | 49 | await setTodoCompleteStatusUseCase(user.id, todoId, isCompleted); 50 | 51 | revalidatePath("/todos"); 52 | } 53 | -------------------------------------------------------------------------------- /src/app/(main)/todos/page.tsx: -------------------------------------------------------------------------------- 1 | import { getSSRSession } from "@/lib/get-server-session"; 2 | import { CreateTodoButton } from "./_components/create-todo-button"; 3 | import { getTodosUseCase } from "@/use-cases/todos"; 4 | import { Button } from "@/components/ui/button"; 5 | import { TrashIcon } from "lucide-react"; 6 | import { Todo } from "./_components/todo"; 7 | 8 | export default async function TodosPage() { 9 | const { user } = await getSSRSession(); 10 | 11 | if (!user) { 12 | return ( 13 |
14 |

Unauthorized

15 |
16 | ); 17 | } 18 | 19 | const todos = await getTodosUseCase(user.id); 20 | 21 | const hasTodos = todos.length > 0; 22 | 23 | return ( 24 |
25 |
26 |

Your Todos

27 | 28 |
29 | 30 |
31 | 32 | {hasTodos && ( 33 |
34 | {todos.map((todo) => ( 35 | 36 | ))} 37 |
38 | )} 39 | 40 | {!hasTodos && ( 41 |
42 |

You have no todos

43 |
44 | )} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | DATABASE_URL: z.string().url(), 7 | NODE_ENV: z.string().optional(), 8 | GOOGLE_CLIENT_ID: z.string().min(1), 9 | GOOGLE_CLIENT_SECRET: z.string().min(1), 10 | NEXTAUTH_SECRET: z.string().min(1), 11 | STRIPE_API_KEY: z.string().min(1), 12 | STRIPE_WEBHOOK_SECRET: z.string().min(1), 13 | PRICE_ID: z.string().min(1), 14 | HOSTNAME: z.string().min(1), 15 | }, 16 | client: { 17 | NEXT_PUBLIC_STRIPE_KEY: z.string().min(1), 18 | NEXT_PUBLIC_PROJECT_PLANNER_ID: z.string().min(1), 19 | NEXT_PUBLIC_SKIP_EVENTS: z.string().optional(), 20 | }, 21 | runtimeEnv: { 22 | NODE_ENV: process.env.NODE_ENV, 23 | DATABASE_URL: process.env.DATABASE_URL, 24 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 25 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 26 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 27 | STRIPE_API_KEY: process.env.STRIPE_API_KEY, 28 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 29 | PRICE_ID: process.env.PRICE_ID, 30 | NEXT_PUBLIC_STRIPE_KEY: process.env.NEXT_PUBLIC_STRIPE_KEY, 31 | HOSTNAME: process.env.HOSTNAME, 32 | NEXT_PUBLIC_PROJECT_PLANNER_ID: process.env.NEXT_PUBLIC_PROJECT_PLANNER_ID, 33 | NEXT_PUBLIC_SKIP_EVENTS: process.env.NEXT_PUBLIC_SKIP_EVENTS, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/app/_components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/data-access/todos.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/db"; 2 | import { Todo, todos } from "@/db/schema"; 3 | import { count, eq } from "drizzle-orm"; 4 | 5 | /** 6 | * Here is an example CRUD methods for the todo table. 7 | * If you plan to keep your code base "clean", we recommend 8 | * no where else know about dizzle other than your data-access directory. 9 | */ 10 | 11 | export async function getTodo(todoId: string) { 12 | const todo = await database.query.todos.findFirst({ 13 | where: (todos, { eq }) => eq(todos.id, todoId), 14 | }); 15 | 16 | return todo; 17 | } 18 | 19 | export async function getTodos(userId: string) { 20 | const todos = await database.query.todos.findMany({ 21 | where: (todos, { eq }) => eq(todos.userId, userId), 22 | orderBy: (todos, { asc }) => [asc(todos.createdAt)], 23 | }); 24 | 25 | return todos; 26 | } 27 | 28 | export async function createTodo(newTodo: Omit) { 29 | const [todo] = await database.insert(todos).values(newTodo).returning(); 30 | return todo; 31 | } 32 | 33 | export async function updateTodo(todoId: string, updatedFields: Partial) { 34 | await database.update(todos).set(updatedFields).where(eq(todos.id, todoId)); 35 | } 36 | 37 | export async function deleteTodo(todoId: string) { 38 | await database.delete(todos).where(eq(todos.id, todoId)); 39 | } 40 | 41 | export async function getTodosCount(userId: string) { 42 | const [{ count: totalTodos }] = await database 43 | .select({ count: count() }) 44 | .from(todos) 45 | .where(eq(todos.userId, userId)); 46 | 47 | return totalTodos; 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/db"; 2 | import { env } from "@/env"; 3 | import { DrizzleAdapter } from "@auth/drizzle-adapter"; 4 | import { AuthOptions, DefaultSession } from "next-auth"; 5 | import { Adapter } from "next-auth/adapters"; 6 | import GoogleProvider from "next-auth/providers/google"; 7 | 8 | declare module "next-auth" { 9 | interface Session extends DefaultSession { 10 | user: { 11 | id: string; 12 | } & DefaultSession["user"]; 13 | } 14 | } 15 | 16 | export const authConfig = { 17 | adapter: DrizzleAdapter(database) as Adapter, 18 | session: { 19 | strategy: "jwt", 20 | }, 21 | providers: [ 22 | GoogleProvider({ 23 | clientId: env.GOOGLE_CLIENT_ID, 24 | clientSecret: env.GOOGLE_CLIENT_SECRET, 25 | }), 26 | ], 27 | callbacks: { 28 | async jwt({ token }) { 29 | const dbUser = await database.query.users.findFirst({ 30 | where: (users, { eq }) => eq(users.email, token.email!), 31 | }); 32 | 33 | if (!dbUser) { 34 | throw new Error("no user with email found"); 35 | } 36 | 37 | return { 38 | id: dbUser.id, 39 | name: dbUser.name, 40 | email: dbUser.email, 41 | picture: dbUser.image, 42 | }; 43 | }, 44 | async session({ token, session }) { 45 | if (token) { 46 | session.user.id = token.id as string; 47 | session.user.name = token.name; 48 | session.user.email = token.email; 49 | session.user.image = token.picture; 50 | } 51 | 52 | return session; 53 | }, 54 | }, 55 | } satisfies AuthOptions; 56 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/app/globals.css"; 2 | import type { Metadata } from "next"; 3 | import NextTopLoader from "nextjs-toploader"; 4 | import { Toaster } from "@/components/ui/toaster"; 5 | import { Inter as FontSans } from "next/font/google"; 6 | import { cn } from "@/lib/utils"; 7 | import { Providers } from "@/app/_components/providers"; 8 | import { Header } from "@/app/_components/header/header"; 9 | import { ReactNode } from "react"; 10 | import { Footer } from "@/app/_components/footer"; 11 | import { SendEventOnLoad } from "@/components/send-event-on-load"; 12 | import { RightsReserved } from "./(landing)/_sections/reserved"; 13 | 14 | const fontSans = FontSans({ 15 | subsets: ["latin"], 16 | variable: "--font-sans", 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "Starter Kit", 21 | description: "The Project Planner AI Next Starter Kit", 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: Readonly<{ 27 | children: ReactNode; 28 | }>) { 29 | return ( 30 | 31 | 37 | 38 | 39 | 40 | 41 |
42 |
43 |
{children}
44 |
45 | 46 |
47 |
48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignedIn } from "@/components/auth/signed-in"; 2 | import { SignedOut } from "@/components/auth/signed-out"; 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { GetStartedButton } from "./_components/get-started-button"; 6 | import Image from "next/image"; 7 | import { PricingSection } from "./(landing)/_sections/pricing"; 8 | import { HeroSection } from "./(landing)/_sections/hero"; 9 | import { FeaturesSection } from "./(landing)/_sections/features"; 10 | 11 | export default async function Home() { 12 | return ( 13 | //

14 | // The Project Planner AI Starter Kit 15 | //

16 | 17 | //

This SaaS starter kit includes:

18 | 19 | //
    20 | //
  • Authentication (Next-Auth)
  • 21 | //
  • Authorization (custom)
  • 22 | //
  • Subscription Management (Stripe)
  • 23 | //
  • Stripe Integration / Webhooks
  • 24 | //
  • Todo Management
  • 25 | //
  • Drizzle ORM
  • 26 | //
  • Light / Dark Mode
  • 27 | //
  • ShadCN components
  • 28 | //
  • Tailwind CSS
  • 29 | //
  • Account Deletion
  • 30 | //
  • Changelog (via Project Planner AI)
  • 31 | //
  • Analytics (via Project Planner AI)
  • 32 | //
  • Feedback (via Project Planner AI)
  • 33 | 34 | // 35 | // 38 | // 39 | 40 | // 41 | // 42 | // 43 | 44 |
    45 | 46 | 47 | 48 |
    49 | // 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/use-cases/todos.ts: -------------------------------------------------------------------------------- 1 | import { isUserSubscribed } from "./subscriptions"; 2 | import { getTodoAccess } from "./authorization"; 3 | import { 4 | createTodo, 5 | deleteTodo, 6 | updateTodo, 7 | getTodosCount, 8 | getTodos, 9 | } from "@/data-access/todos"; 10 | 11 | const TODO_LIMIT = 1; 12 | 13 | export async function createTodoUseCase(userId: string, text: string) { 14 | const isSubscribed = await isUserSubscribed(userId); 15 | 16 | if (!isSubscribed) { 17 | const total = await getTodosCount(userId); 18 | console.log({ total }); 19 | if (total >= TODO_LIMIT) { 20 | throw new Error( 21 | "Todo limit reached - Upgrade to premium to add more todos" 22 | ); 23 | } 24 | } 25 | 26 | const todo = await createTodo({ 27 | text, 28 | userId, 29 | isCompleted: false, 30 | }); 31 | 32 | return todo; 33 | } 34 | 35 | export async function deleteTodoUseCase(userId: string, todoId: string) { 36 | const accessObj = await getTodoAccess(userId, todoId); 37 | 38 | if (!accessObj) { 39 | throw new Error("Unauthorized"); 40 | } 41 | 42 | await deleteTodo(todoId); 43 | } 44 | 45 | export async function setTodoCompleteStatusUseCase( 46 | userId: string, 47 | todoId: string, 48 | isCompleted: boolean 49 | ) { 50 | const accessObj = await getTodoAccess(userId, todoId); 51 | 52 | if (!accessObj) { 53 | throw new Error("Unauthorized"); 54 | } 55 | 56 | await updateTodo(todoId, { isCompleted }); 57 | } 58 | 59 | export async function getTodoByIdUseCase(userId: string, todoId: string) { 60 | const accessObj = await getTodoAccess(userId, todoId); 61 | 62 | if (!accessObj) { 63 | throw new Error("Unauthorized"); 64 | } 65 | 66 | return accessObj.todo; 67 | } 68 | 69 | export async function getTodosUseCase(userId: string) { 70 | const todos = await getTodos(userId); 71 | 72 | return todos; 73 | } 74 | -------------------------------------------------------------------------------- /src/app/(legal)/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | export default function PrivacyPolicy() { 2 | return ( 3 |
    4 |

    Privacy Policy

    5 |

    6 | Here is the start of a basic privacy policy page. This is using the 7 | tailwind prose class which makes it very easy to build out a privacy 8 | policy page just typing normal html. As you see, we do not provide any 9 | real legal lingo because we are not lawyers. This is just a template to 10 | get you started. Please use a privacy policy generator or consult a 11 | lawyer to craft a privacy policy that fits your business. 12 |

    13 | 14 |

    1. Example

    15 | 16 |

    17 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 18 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 19 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 20 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 21 | fermentum. 22 |

    23 | 24 |

    2. Example

    25 | 26 |

    27 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 28 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 29 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 30 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 31 | fermentum. 32 |

    33 | 34 |

    3. Example

    35 | 36 |

    37 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 38 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 39 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 40 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 41 | fermentum. 42 |

    43 |
    44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/(legal)/terms-of-service/page.tsx: -------------------------------------------------------------------------------- 1 | export default function TermsOfServicePage() { 2 | return ( 3 |
    4 |

    Terms of Service

    5 |

    6 | Here is the start of a basic terms of service page. This is using the 7 | tailwind prose class which makes it very easy to build out a terms of 8 | service page just typing normal html. As you see, we do not provide any 9 | real legal lingo because we are not lawyers. This is just a template to 10 | get you started. Please use a terms of service generator or consult a 11 | lawyer to craft a terms of service that fits your business. 12 |

    13 | 14 |

    1. Example

    15 | 16 |

    17 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 18 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 19 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 20 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 21 | fermentum. 22 |

    23 | 24 |

    2. Example

    25 | 26 |

    27 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 28 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 29 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 30 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 31 | fermentum. 32 |

    33 | 34 |

    3. Example

    35 | 36 |

    37 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec 38 | fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. Sed 39 | nec fermentum risus. Nullam sit amet sapien vel nisi lacinia fermentum. 40 | Sed nec fermentum risus. Nullam sit amet sapien vel nisi lacinia 41 | fermentum. 42 |

    43 |
    44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/(main)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | import { DeleteAccountButton } from "./_components/delete-account-button"; 4 | 5 | export default function SettingsPage() { 6 | return ( 7 |
    8 |

    Account Settings

    9 | 10 |
    11 |
    12 |
    13 | 14 | Manage Subscription 15 | 16 |
    17 | 18 |
    19 |
    20 |
    You can cancel your subscription with the link below
    21 | 30 |
    31 |
    32 |
    33 | 34 |
    35 |
    36 | 37 | Danger Zone 38 | 39 |
    40 | 41 |
    42 |
    43 |
    You can delete your account below
    44 | 45 |
    46 |
    47 |
    48 |
    49 |
    50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { stripe } from "@/lib/stripe"; 3 | import { headers } from "next/headers"; 4 | import type Stripe from "stripe"; 5 | import { subscriptions } from "@/db/schema"; 6 | import { eq } from "drizzle-orm"; 7 | import { database } from "@/db"; 8 | 9 | export async function POST(req: Request) { 10 | const body = await req.text(); 11 | const signature = headers().get("Stripe-Signature") as string; 12 | 13 | let event: Stripe.Event; 14 | 15 | try { 16 | event = stripe.webhooks.constructEvent( 17 | body, 18 | signature, 19 | env.STRIPE_WEBHOOK_SECRET 20 | ); 21 | } catch (error) { 22 | return new Response( 23 | `Webhook Error: ${ 24 | error instanceof Error ? error.message : "Unknown error" 25 | }`, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | const session = event.data.object as Stripe.Checkout.Session; 31 | 32 | if (event.type === "checkout.session.completed") { 33 | const subscription = await stripe.subscriptions.retrieve( 34 | session.subscription as string 35 | ); 36 | 37 | await database.insert(subscriptions).values({ 38 | userId: session.metadata!.userId, 39 | stripeSubscriptionId: subscription.id, 40 | stripeCustomerId: subscription.customer as string, 41 | stripePriceId: subscription.items.data[0]?.price.id, 42 | stripeCurrentPeriodEnd: new Date( 43 | subscription.current_period_end * 1000 44 | ).toISOString(), 45 | }); 46 | } else if (event.type === "invoice.payment_succeeded") { 47 | const subscription = await stripe.subscriptions.retrieve( 48 | session.subscription as string 49 | ); 50 | 51 | await database 52 | .update(subscriptions) 53 | .set({ 54 | stripePriceId: subscription.items.data[0]?.price.id, 55 | stripeCurrentPeriodEnd: new Date( 56 | subscription.current_period_end * 1000 57 | ).toISOString(), 58 | }) 59 | .where(eq(subscriptions.stripeSubscriptionId, subscription.id)); 60 | } 61 | 62 | return new Response(null, { status: 200 }); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/(landing)/_sections/hero.tsx: -------------------------------------------------------------------------------- 1 | import { GetStartedButton } from "@/app/_components/get-started-button"; 2 | import { SignedIn } from "@/components/auth/signed-in"; 3 | import { SignedOut } from "@/components/auth/signed-out"; 4 | import { Button } from "@/components/ui/button"; 5 | import Image from "next/image"; 6 | import Link from "next/link"; 7 | 8 | export function HeroSection() { 9 | return ( 10 |
    11 |
    12 |
    13 |
    14 |

    15 | The Starter Kit you've needed from the start. 16 |

    17 |

    18 | This free and{" "} 19 | open-source starter kit we 20 | created for you to acheive your next{" "} 21 | SaaS projects with ease. 22 |

    23 |
    24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 |
    34 |
    35 | 36 |
    37 | hero image 44 |
    45 |
    46 |
    47 |
    48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
    17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
    29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

    44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

    56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

    64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
    76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ppai-next-starter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/webhooks/stripe", 11 | "db:push": "drizzle-kit push:pg --config=drizzle.config.ts", 12 | "db:studio": "drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@auth/drizzle-adapter": "^0.9.0", 16 | "@hello-pangea/dnd": "^16.6.0", 17 | "@hookform/resolvers": "^3.3.4", 18 | "@radix-ui/react-alert-dialog": "^1.0.5", 19 | "@radix-ui/react-avatar": "^1.0.4", 20 | "@radix-ui/react-checkbox": "^1.0.4", 21 | "@radix-ui/react-dialog": "^1.0.5", 22 | "@radix-ui/react-dropdown-menu": "^2.0.6", 23 | "@radix-ui/react-label": "^2.0.2", 24 | "@radix-ui/react-slot": "^1.0.2", 25 | "@radix-ui/react-tabs": "^1.0.4", 26 | "@radix-ui/react-toast": "^1.1.5", 27 | "@t3-oss/env-nextjs": "^0.9.2", 28 | "@tiptap/pm": "^2.3.0", 29 | "@tiptap/react": "^2.3.0", 30 | "@tiptap/starter-kit": "^2.3.0", 31 | "@types/lodash": "^4.17.0", 32 | "class-variance-authority": "^0.7.0", 33 | "clsx": "^2.1.0", 34 | "date-fns": "^3.6.0", 35 | "drizzle-orm": "^0.30.8", 36 | "lodash": "^4.17.21", 37 | "lucide-react": "^0.368.0", 38 | "next": "14.2.2", 39 | "next-auth": "^4.24.7", 40 | "next-themes": "^0.3.0", 41 | "nextjs-toploader": "^1.6.11", 42 | "pg": "^8.11.5", 43 | "postgres": "^3.4.4", 44 | "react": "^18", 45 | "react-dom": "^18", 46 | "react-hook-form": "^7.51.3", 47 | "react-markdown": "^9.0.1", 48 | "server-only": "^0.0.1", 49 | "stripe": "^15.1.0", 50 | "tailwind-merge": "^2.2.2", 51 | "tailwindcss-animate": "^1.0.7", 52 | "vaul": "^0.9.0", 53 | "zod": "^3.22.4" 54 | }, 55 | "devDependencies": { 56 | "@tailwindcss/typography": "^0.5.12", 57 | "@types/node": "^20", 58 | "@types/react": "^18", 59 | "@types/react-dom": "^18", 60 | "drizzle-kit": "^0.20.17", 61 | "eslint": "^8", 62 | "eslint-config-next": "14.2.0", 63 | "postcss": "^8", 64 | "tailwindcss": "^3.4.1", 65 | "typescript": "^5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/changelog/page.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { format } from "date-fns"; 3 | import { unstable_noStore } from "next/cache"; 4 | import Markdown from "react-markdown"; 5 | 6 | type ChangeLog = { 7 | id: string; 8 | date: string; 9 | title: string; 10 | post: string; 11 | }; 12 | 13 | export default async function ChangelogPage() { 14 | unstable_noStore(); 15 | 16 | const changelogs = await fetch( 17 | `https://projectplannerai.com/api/changelog?projectId=${env.NEXT_PUBLIC_PROJECT_PLANNER_ID}` 18 | ).then(async (res) => res.json() as Promise); 19 | 20 | return ( 21 |
    22 |
    23 |

    24 | The StarterKit Changelog 25 |

    26 |
    27 | 28 | {changelogs.length === 0 && ( 29 |
    No changelogs found
    30 | )} 31 | 32 |
      33 | {changelogs.map((changelog) => ( 34 |
    • 38 |
      39 |

      40 | 43 |

      44 |
      45 | 46 |
      47 |
      48 |
      49 |
      50 | 51 |
      52 |
      53 |
      54 |

      {changelog.title}

      55 | 56 | 57 | {changelog.post} 58 | 59 |
      60 |
      61 |
      62 |
    • 63 | ))} 64 |
    65 |
    66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/app/(main)/todos/_components/todo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Todo } from "@/db/schema"; 4 | import { TrashIcon } from "lucide-react"; 5 | import { useTransition } from "react"; 6 | import { useToast } from "@/components/ui/use-toast"; 7 | import { deleteTodoAction, setTodoCompleteStatusAction } from "./actions"; 8 | import { LoaderButton } from "@/components/loader-button"; 9 | import { Checkbox } from "@/components/ui/checkbox"; 10 | import { trackEvent } from "@/lib/events"; 11 | 12 | function TodoCheckbox({ todo }: { todo: Todo }) { 13 | const [pending, startTransition] = useTransition(); 14 | 15 | return ( 16 | { 20 | trackEvent("user toggled todo"); 21 | startTransition(() => { 22 | setTodoCompleteStatusAction(todo.id, checked as boolean); 23 | }); 24 | }} 25 | /> 26 | ); 27 | } 28 | 29 | export function Todo({ todo }: { todo: Todo }) { 30 | const { toast } = useToast(); 31 | const [pending, startTransition] = useTransition(); 32 | 33 | return ( 34 |
    38 |
    39 | 40 | 41 | 47 |
    48 | 49 | { 52 | trackEvent("user deleted todo"); 53 | startTransition(() => { 54 | deleteTodoAction(todo.id) 55 | .then(() => { 56 | toast({ 57 | title: "Todo Deleted", 58 | description: "Your todo has been removed", 59 | }); 60 | }) 61 | .catch((e) => { 62 | toast({ 63 | title: "Something went wrong", 64 | description: e.message, 65 | variant: "destructive", 66 | }); 67 | }); 68 | }); 69 | }} 70 | variant="destructive" 71 | title="Delete Todo" 72 | > 73 | 74 | 75 |
    76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | const { fontFamily } = require("tailwindcss/defaultTheme"); 3 | 4 | const config = { 5 | darkMode: ["class"], 6 | content: [ 7 | "./pages/**/*.{ts,tsx}", 8 | "./components/**/*.{ts,tsx}", 9 | "./app/**/*.{ts,tsx}", 10 | "./src/**/*.{ts,tsx}", 11 | ], 12 | prefix: "", 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: "2rem", 17 | screens: { 18 | "2xl": "1400px", 19 | }, 20 | }, 21 | extend: { 22 | fontFamily: { 23 | sans: ["var(--font-sans)", ...fontFamily.sans], 24 | }, 25 | colors: { 26 | border: "hsl(var(--border))", 27 | input: "hsl(var(--input))", 28 | ring: "hsl(var(--ring))", 29 | background: "hsl(var(--background))", 30 | foreground: "hsl(var(--foreground))", 31 | primary: { 32 | DEFAULT: "hsl(var(--primary))", 33 | foreground: "hsl(var(--primary-foreground))", 34 | }, 35 | secondary: { 36 | DEFAULT: "hsl(var(--secondary))", 37 | foreground: "hsl(var(--secondary-foreground))", 38 | }, 39 | destructive: { 40 | DEFAULT: "hsl(var(--destructive))", 41 | foreground: "hsl(var(--destructive-foreground))", 42 | }, 43 | muted: { 44 | DEFAULT: "hsl(var(--muted))", 45 | foreground: "hsl(var(--muted-foreground))", 46 | }, 47 | accent: { 48 | DEFAULT: "hsl(var(--accent))", 49 | foreground: "hsl(var(--accent-foreground))", 50 | }, 51 | popover: { 52 | DEFAULT: "hsl(var(--popover))", 53 | foreground: "hsl(var(--popover-foreground))", 54 | }, 55 | card: { 56 | DEFAULT: "hsl(var(--card))", 57 | foreground: "hsl(var(--card-foreground))", 58 | }, 59 | }, 60 | borderRadius: { 61 | lg: "var(--radius)", 62 | md: "calc(var(--radius) - 2px)", 63 | sm: "calc(var(--radius) - 4px)", 64 | }, 65 | keyframes: { 66 | "accordion-down": { 67 | from: { height: "0" }, 68 | to: { height: "var(--radix-accordion-content-height)" }, 69 | }, 70 | "accordion-up": { 71 | from: { height: "var(--radix-accordion-content-height)" }, 72 | to: { height: "0" }, 73 | }, 74 | }, 75 | animation: { 76 | "accordion-down": "accordion-down 0.2s ease-out", 77 | "accordion-up": "accordion-up 0.2s ease-out", 78 | }, 79 | }, 80 | }, 81 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], 82 | } satisfies Config; 83 | 84 | export default config; 85 | -------------------------------------------------------------------------------- /src/app/_components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export function Footer() { 4 | return ( 5 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 40.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | 77 | a { 78 | @apply text-foreground; 79 | } 80 | } 81 | 82 | .tiptap { 83 | > * + * { 84 | margin-top: 0.75em; 85 | } 86 | 87 | ul, 88 | ol { 89 | padding: 0 1rem; 90 | } 91 | 92 | h1, 93 | h2, 94 | h3, 95 | h4, 96 | h5, 97 | h6 { 98 | line-height: 1.1; 99 | } 100 | 101 | code { 102 | background-color: rgba(#616161, 0.1); 103 | color: #616161; 104 | } 105 | 106 | pre { 107 | background: #0d0d0d; 108 | color: #fff; 109 | font-family: "JetBrainsMono", monospace; 110 | padding: 0.75rem 1rem; 111 | border-radius: 0.5rem; 112 | 113 | code { 114 | color: inherit; 115 | padding: 0; 116 | background: none; 117 | font-size: 0.8rem; 118 | } 119 | } 120 | 121 | img { 122 | max-width: 100%; 123 | height: auto; 124 | } 125 | 126 | blockquote { 127 | padding-left: 1rem; 128 | border-left: 2px solid rgba(#0d0d0d, 0.1); 129 | } 130 | 131 | hr { 132 | border: none; 133 | border-top: 2px solid rgba(#0d0d0d, 0.1); 134 | margin: 2rem 0; 135 | } 136 | } 137 | 138 | .ProseMirror { 139 | @apply border border-white min-h-[280px] p-4 rounded-xl; 140 | } 141 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | timestamp, 3 | pgTable, 4 | text, 5 | serial, 6 | varchar, 7 | primaryKey, 8 | integer, 9 | date, 10 | boolean, 11 | uuid, 12 | unique, 13 | time, 14 | } from "drizzle-orm/pg-core"; 15 | import type { AdapterAccount } from "@auth/core/adapters"; 16 | import { sql } from "drizzle-orm"; 17 | 18 | /** 19 | * NEXT-AUTH TABLES 20 | */ 21 | export const users = pgTable("user", { 22 | id: text("id").notNull().primaryKey(), 23 | name: text("name"), 24 | email: text("email").notNull(), 25 | emailVerified: timestamp("emailVerified", { mode: "date" }), 26 | image: text("image"), 27 | }); 28 | 29 | export const accounts = pgTable( 30 | "account", 31 | { 32 | userId: text("userId") 33 | .notNull() 34 | .references(() => users.id, { onDelete: "cascade" }), 35 | type: text("type").$type().notNull(), 36 | provider: text("provider").notNull(), 37 | providerAccountId: text("providerAccountId").notNull(), 38 | refresh_token: text("refresh_token"), 39 | access_token: text("access_token"), 40 | expires_at: integer("expires_at"), 41 | token_type: text("token_type"), 42 | scope: text("scope"), 43 | id_token: text("id_token"), 44 | session_state: text("session_state"), 45 | }, 46 | (account) => ({ 47 | primaryKey: [account.provider, account.providerAccountId], 48 | }) 49 | ); 50 | 51 | export const sessions = pgTable("session", { 52 | sessionToken: text("sessionToken").notNull().primaryKey(), 53 | userId: text("userId") 54 | .notNull() 55 | .references(() => users.id, { onDelete: "cascade" }), 56 | expires: timestamp("expires", { mode: "date" }).notNull(), 57 | }); 58 | 59 | export const verificationTokens = pgTable( 60 | "verificationToken", 61 | { 62 | identifier: text("identifier").notNull(), 63 | token: text("token").notNull(), 64 | expires: timestamp("expires", { mode: "date" }).notNull(), 65 | }, 66 | (vt) => ({ 67 | primaryKey: [vt.identifier, vt.token], 68 | }) 69 | ); 70 | 71 | /** 72 | * APP SPECIFIC TABLES 73 | */ 74 | 75 | export const todos = pgTable("todo", { 76 | id: uuid("id") 77 | .notNull() 78 | .primaryKey() 79 | .default(sql`gen_random_uuid()`), 80 | userId: text("userId") 81 | .notNull() 82 | .references(() => users.id, { onDelete: "cascade" }), 83 | text: varchar("text").notNull(), 84 | isCompleted: boolean("isCompleted").notNull().default(false), 85 | createdAt: time("createdAt") 86 | .notNull() 87 | .default(sql`now()`), 88 | }); 89 | 90 | export const subscriptions = pgTable("subscriptions", { 91 | userId: text("userId") 92 | .notNull() 93 | .primaryKey() 94 | .references(() => users.id, { onDelete: "cascade" }), 95 | stripeSubscriptionId: text("stripeSubscriptionId").notNull(), 96 | stripeCustomerId: text("stripeCustomerId").notNull(), 97 | stripePriceId: text("stripePriceId").notNull(), 98 | stripeCurrentPeriodEnd: timestamp("stripeCurrentPeriodEnd", { 99 | mode: "string", 100 | }).notNull(), 101 | }); 102 | 103 | export type Todo = typeof todos.$inferSelect; 104 | -------------------------------------------------------------------------------- /src/app/_components/header/header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuTrigger, 6 | } from "@/components/ui/dropdown-menu"; 7 | 8 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 9 | 10 | import { Button } from "@/components/ui/button"; 11 | import Link from "next/link"; 12 | import { SignedIn } from "@/components/auth/signed-in"; 13 | import { SignedOut } from "@/components/auth/signed-out"; 14 | import { UpgradeButton } from "@/components/stripe/upgrade-button/upgrade-button"; 15 | import { Unsubscribed } from "@/components/auth/subscription-status"; 16 | import { LogOut, Settings2Icon } from "lucide-react"; 17 | import { getSSRSession } from "@/lib/get-server-session"; 18 | import { ModeToggle } from "../mode-toggle"; 19 | import FeedbackButton from "./feedback"; 20 | import { Links } from "./links"; 21 | import Image from "next/image"; 22 | 23 | export async function Header() { 24 | const { user } = await getSSRSession(); 25 | 26 | return ( 27 |
    28 |
    29 |
    30 | 31 | hero image 38 | StarterKit 39 | 40 | 41 | 42 |
    43 | 44 |
    45 | 46 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | CN 65 | 66 | 67 | 68 | 69 | 70 | Settings 71 | 72 | 73 | 74 | 78 | Sign Out 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 89 | 90 |
    91 |
    92 |
    93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/app/(main)/settings/_components/delete-account-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderButton } from "@/components/loader-button"; 4 | import { 5 | AlertDialog, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger, 13 | } from "@/components/ui/alert-dialog"; 14 | import { Button } from "@/components/ui/button"; 15 | import { 16 | Form, 17 | FormControl, 18 | FormField, 19 | FormItem, 20 | FormLabel, 21 | FormMessage, 22 | } from "@/components/ui/form"; 23 | import { Input } from "@/components/ui/input"; 24 | import { zodResolver } from "@hookform/resolvers/zod"; 25 | import { useState, useTransition } from "react"; 26 | import { useForm } from "react-hook-form"; 27 | import { z } from "zod"; 28 | import { deleteAccountAction } from "./actions"; 29 | import { signOut } from "next-auth/react"; 30 | import { trackEvent } from "@/lib/events"; 31 | 32 | export const deleteSchema = z.object({ 33 | confirm: z.string().refine((v) => v === "Please delete", { 34 | message: "Please type 'Please delete' to confirm", 35 | }), 36 | }); 37 | 38 | export function DeleteAccountButton() { 39 | const [isOpen, setIsOpen] = useState(false); 40 | const [pending, startTransition] = useTransition(); 41 | 42 | const form = useForm>({ 43 | resolver: zodResolver(deleteSchema), 44 | defaultValues: { 45 | confirm: "", 46 | }, 47 | }); 48 | 49 | function onSubmit() { 50 | trackEvent("user deleted account"); 51 | startTransition(() => { 52 | deleteAccountAction().then(() => 53 | signOut({ 54 | callbackUrl: "/", 55 | }) 56 | ); 57 | }); 58 | } 59 | 60 | return ( 61 | 62 | 63 | 66 | 67 | 68 | 69 | Are you sure? 70 | 71 | Deleting your account means you will not be able to recover your 72 | data in the future. Please type Please delete to 73 | confirm. 74 | 75 | 76 | 77 |
    78 | 79 | ( 83 | 84 | Confirm 85 | 86 | 87 | 88 | 89 | 90 | )} 91 | /> 92 | 93 | 94 | Cancel 95 | 96 | Delete 97 | 98 | 99 | 100 | 101 |
    102 |
    103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/app/(main)/todos/_components/create-todo-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | import { createTodoAction } from "./actions"; 13 | import { Input } from "@/components/ui/input"; 14 | import { useState, useTransition } from "react"; 15 | import { z } from "zod"; 16 | import { zodResolver } from "@hookform/resolvers/zod"; 17 | import { useForm } from "react-hook-form"; 18 | import { 19 | Form, 20 | FormControl, 21 | FormField, 22 | FormItem, 23 | FormLabel, 24 | FormMessage, 25 | } from "@/components/ui/form"; 26 | import { LoaderButton } from "@/components/loader-button"; 27 | import { todoSchema } from "./validation"; 28 | import { useToast } from "@/components/ui/use-toast"; 29 | import { trackEvent } from "@/lib/events"; 30 | 31 | export function CreateTodoButton() { 32 | const [isOpen, setIsOpen] = useState(false); 33 | const { toast } = useToast(); 34 | const [pending, startTransition] = useTransition(); 35 | 36 | const form = useForm>({ 37 | resolver: zodResolver(todoSchema), 38 | defaultValues: { 39 | text: "", 40 | }, 41 | }); 42 | 43 | function onSubmit(values: z.infer) { 44 | trackEvent("user created todo"); 45 | startTransition(() => { 46 | createTodoAction(values) 47 | .then(() => { 48 | setIsOpen(false); 49 | toast({ 50 | title: "Todo added", 51 | description: "Your todo has been created", 52 | }); 53 | }) 54 | .catch((e) => { 55 | toast({ 56 | title: "Something went wrong", 57 | description: e.message, 58 | variant: "destructive", 59 | }); 60 | }) 61 | .finally(() => { 62 | form.reset(); 63 | }); 64 | }); 65 | } 66 | 67 | return ( 68 | 69 | 70 | 71 | 72 | 73 | 74 | Create a Todo 75 | 76 |
    77 | 78 | ( 82 | 83 | Text 84 | 85 | 86 | 87 | 88 | 89 | )} 90 | /> 91 | 92 |
    93 | 102 | Create 103 |
    104 | 105 | 106 |
    107 |
    108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/custom/edit-text.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode, useEffect, useState, useTransition } from "react"; 4 | import { Input } from "../ui/input"; 5 | import { CheckIcon, PencilIcon, XIcon } from "lucide-react"; 6 | import { Button } from "../ui/button"; 7 | import { LoaderButton } from "../loader-button"; 8 | import { cn } from "@/lib/utils"; 9 | import { z } from "zod"; 10 | import { useForm } from "react-hook-form"; 11 | import { zodResolver } from "@hookform/resolvers/zod"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormMessage, 18 | } from "../ui/form"; 19 | 20 | export const updateTitleSchema = z.object({ 21 | title: z.string().min(1), 22 | }); 23 | 24 | export function EditText({ 25 | children, 26 | onSaveAction, 27 | className, 28 | value, 29 | }: { 30 | children: ReactNode; 31 | onSaveAction: (title: string) => Promise; 32 | value: string; 33 | className?: string; 34 | }) { 35 | const [isEditing, setIsEditing] = useState(false); 36 | const [isPending, startTransition] = useTransition(); 37 | 38 | const form = useForm>({ 39 | resolver: zodResolver(updateTitleSchema), 40 | defaultValues: { 41 | title: value, 42 | }, 43 | }); 44 | 45 | function onSubmit(values: z.infer) { 46 | startTransition(() => { 47 | onSaveAction(values.title); 48 | }); 49 | setIsEditing(false); 50 | form.reset(); 51 | } 52 | 53 | useEffect(() => { 54 | form.setValue("title", value); 55 | }, [form, value]); 56 | 57 | return ( 58 |
    59 | {isEditing && ( 60 |
    61 |
    62 | 66 | ( 70 | 71 | 72 | 77 | 78 | 79 | 80 | )} 81 | /> 82 | 83 | 89 | 90 | 91 | 101 | 102 | 103 |
    104 | )} 105 | {!isEditing && ( 106 |
    107 | {children} 108 | 109 | 116 |
    117 | )} 118 |
    119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ) 17 | Drawer.displayName = "Drawer" 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal 22 | 23 | const DrawerClose = DrawerPrimitive.Close 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
    52 | {children} 53 | 54 | 55 | )) 56 | DrawerContent.displayName = "DrawerContent" 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
    66 | ) 67 | DrawerHeader.displayName = "DrawerHeader" 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
    77 | ) 78 | DrawerFooter.displayName = "DrawerFooter" 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )) 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | } 119 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
    67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
    81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
    82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |