├── .cursorrules ├── .env.example ├── .eslintrc.json ├── .github └── funding.yaml ├── .gitignore ├── README.md ├── actions ├── profiles-actions.ts ├── stripe-actions.ts └── todos-actions.ts ├── app ├── (auth) │ ├── layout.tsx │ ├── login │ │ └── [[...login]] │ │ │ └── page.tsx │ └── signup │ │ └── [[...signup]] │ │ └── page.tsx ├── (marketing) │ ├── page.tsx │ └── pricing │ │ └── page.tsx ├── api │ └── stripe │ │ └── webhooks │ │ └── route.ts ├── globals.css ├── layout.tsx └── todo │ └── page.tsx ├── components.json ├── components ├── header.tsx ├── todo-list.tsx ├── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.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 └── utilities │ └── providers.tsx ├── db ├── db.ts ├── migrations │ ├── 0000_nostalgic_mauler.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json ├── queries │ ├── profiles-queries.ts │ └── todos-queries.ts └── schema │ ├── index.ts │ ├── profiles-schema.ts │ └── todos-schema.ts ├── drizzle.config.ts ├── lib ├── stripe.ts └── utils.ts ├── license ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── tailwind.config.ts ├── tsconfig.json └── types ├── actions └── action-types.ts └── index.ts /.cursorrules: -------------------------------------------------------------------------------- 1 | # Project Specification & Guidelines 2 | 3 | Use the project specification and guidelines to build the Todo app. 4 | 5 | ## Overview 6 | 7 | Todo is a web app that allows you to manage your todos. 8 | 9 | ## Tech Stack 10 | 11 | - Frontend: Next.js, Tailwind, Shadcn, Framer Motion 12 | - Backend: Supabase, Drizzle, Server Actions 13 | - Auth: Clerk 14 | - Payments: Stripe 15 | 16 | ## Specification 17 | 18 | ## Guidelines 19 | 20 | Follow these rules: 21 | 22 | - All components should go in `/components` and be named like `example-component.tsx` unless otherwise specified 23 | - All actions should go in `/actions` and be named like `example-actions.ts` unless otherwise specified 24 | - All schemas should go in `/db/schema` and be named like `example-schema.ts` unless otherwise specified 25 | - All queries should go in `/db/queries` and be named like `example-queries.ts` unless otherwise specified 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # DB (Supabase) 2 | DATABASE_URL= 3 | 4 | # Auth (Clerk) 5 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 6 | CLERK_SECRET_KEY= 7 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login 8 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup 9 | 10 | # Payments (Stripe) 11 | STRIPE_SECRET_KEY= 12 | STRIPE_WEBHOOK_SECRET= 13 | NEXT_PUBLIC_STRIPE_PAYMENT_LINK_YEARLY= 14 | NEXT_PUBLIC_STRIPE_PAYMENT_LINK_MONTHLY= 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/funding.yaml: -------------------------------------------------------------------------------- 1 | # If you find my open-source work helpful, please consider sponsoring me! 2 | 3 | github: mckaywrigley 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Takeoff Todo App 2 | 3 | Source code for the Todo App from Section 2 of this [Takeoff](https://www.jointakeoff.com/courses/apps-with-ai) course. 4 | 5 | Join today to get access to the full course. 6 | 7 | ## Sponsors 8 | 9 | If you are interested in sponsoring my repos, please contact me at [ads@takeoffai.org](mailto:ads@takeoffai.org). 10 | 11 | Or sponsor me directly on [GitHub Sponsors](https://github.com/sponsors/mckaywrigley). 12 | 13 | ## Tech Stack 14 | 15 | - IDE: [Cursor](https://www.cursor.com/) 16 | - AI Tools: [V0](https://v0.dev/), [Perplexity](https://www.perplexity.com/) 17 | - Frontend: [Next.js](https://nextjs.org/docs), [Tailwind](https://tailwindcss.com/docs/guides/nextjs), [Shadcn](https://ui.shadcn.com/docs/installation), [Framer Motion](https://www.framer.com/motion/introduction/) 18 | - Backend: [PostgreSQL](https://www.postgresql.org/about/), [Supabase](https://supabase.com/), [Drizzle](https://orm.drizzle.team/docs/get-started-postgresql), [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) 19 | - Auth: [Clerk](https://clerk.com/) 20 | - Payments: [Stripe](https://stripe.com/) 21 | 22 | ## Prerequisites 23 | 24 | You will need accounts for the following services. 25 | 26 | They all have free plans that you can use to get started. 27 | 28 | - Create a [Cursor](https://www.cursor.com/) account 29 | - Create a [GitHub](https://github.com/) account 30 | - Create a [Supabase](https://supabase.com/) account 31 | - Create a [Clerk](https://clerk.com/) account 32 | - Create a [Stripe](https://stripe.com/) account 33 | - Create a [Vercel](https://vercel.com/) account 34 | 35 | You will likely not need paid plans unless you are building a business. 36 | 37 | ## Environment Variables 38 | 39 | ```bash 40 | # DB (Supabase) 41 | DATABASE_URL= 42 | 43 | # Auth (Clerk) 44 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 45 | CLERK_SECRET_KEY= 46 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login 47 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup 48 | 49 | # Payments (Stripe) 50 | STRIPE_SECRET_KEY= 51 | STRIPE_WEBHOOK_SECRET= 52 | NEXT_PUBLIC_STRIPE_PAYMENT_LINK_YEARLY= 53 | NEXT_PUBLIC_STRIPE_PAYMENT_LINK_MONTHLY= 54 | ``` 55 | 56 | ## Setup 57 | 58 | 1. Clone the repository 59 | 2. Copy `.env.example` to `.env.local` and fill in the environment variables from above 60 | 3. Run `npm install` to install dependencies 61 | 4. Run `npm run dev` to run the app locally 62 | -------------------------------------------------------------------------------- /actions/profiles-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createProfile, deleteProfile, getAllProfiles, getProfileByUserId, updateProfile } from "@/db/queries/profiles-queries"; 4 | import { InsertProfile } from "@/db/schema/profiles-schema"; 5 | import { ActionState } from "@/types"; 6 | import { revalidatePath } from "next/cache"; 7 | 8 | export async function createProfileAction(data: InsertProfile): Promise { 9 | try { 10 | const newProfile = await createProfile(data); 11 | revalidatePath("/profile"); 12 | return { status: "success", message: "Profile created successfully", data: newProfile }; 13 | } catch (error) { 14 | return { status: "error", message: "Failed to create profile" }; 15 | } 16 | } 17 | 18 | export async function getProfileByUserIdAction(userId: string): Promise { 19 | try { 20 | const profile = await getProfileByUserId(userId); 21 | return { status: "success", message: "Profile retrieved successfully", data: profile }; 22 | } catch (error) { 23 | return { status: "error", message: "Failed to get profile" }; 24 | } 25 | } 26 | 27 | export async function getAllProfilesAction(): Promise { 28 | try { 29 | const profiles = await getAllProfiles(); 30 | return { status: "success", message: "Profiles retrieved successfully", data: profiles }; 31 | } catch (error) { 32 | return { status: "error", message: "Failed to get profiles" }; 33 | } 34 | } 35 | 36 | export async function updateProfileAction(userId: string, data: Partial): Promise { 37 | try { 38 | const updatedProfile = await updateProfile(userId, data); 39 | revalidatePath("/profile"); 40 | return { status: "success", message: "Profile updated successfully", data: updatedProfile }; 41 | } catch (error) { 42 | return { status: "error", message: "Failed to update profile" }; 43 | } 44 | } 45 | 46 | export async function deleteProfileAction(userId: string): Promise { 47 | try { 48 | await deleteProfile(userId); 49 | revalidatePath("/profile"); 50 | return { status: "success", message: "Profile deleted successfully" }; 51 | } catch (error) { 52 | return { status: "error", message: "Failed to delete profile" }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /actions/stripe-actions.ts: -------------------------------------------------------------------------------- 1 | import { updateProfile, updateProfileByStripeCustomerId } from "@/db/queries/profiles-queries"; 2 | import { SelectProfile } from "@/db/schema"; 3 | import { stripe } from "@/lib/stripe"; 4 | import Stripe from "stripe"; 5 | 6 | type MembershipStatus = SelectProfile["membership"]; 7 | 8 | const getMembershipStatus = (status: Stripe.Subscription.Status, membership: MembershipStatus): MembershipStatus => { 9 | switch (status) { 10 | case "active": 11 | case "trialing": 12 | return membership; 13 | case "canceled": 14 | case "incomplete": 15 | case "incomplete_expired": 16 | case "past_due": 17 | case "paused": 18 | case "unpaid": 19 | return "free"; 20 | default: 21 | return "free"; 22 | } 23 | }; 24 | 25 | const getSubscription = async (subscriptionId: string) => { 26 | return stripe.subscriptions.retrieve(subscriptionId, { 27 | expand: ["default_payment_method"] 28 | }); 29 | }; 30 | 31 | export const updateStripeCustomer = async (userId: string, subscriptionId: string, customerId: string) => { 32 | try { 33 | if (!userId || !subscriptionId || !customerId) { 34 | throw new Error("Missing required parameters for updateStripeCustomer"); 35 | } 36 | 37 | const subscription = await getSubscription(subscriptionId); 38 | 39 | const updatedProfile = await updateProfile(userId, { 40 | stripeCustomerId: customerId, 41 | stripeSubscriptionId: subscription.id 42 | }); 43 | 44 | if (!updatedProfile) { 45 | throw new Error("Failed to update customer profile"); 46 | } 47 | 48 | return updatedProfile; 49 | } catch (error) { 50 | console.error("Error in updateStripeCustomer:", error); 51 | throw error instanceof Error ? error : new Error("Failed to update Stripe customer"); 52 | } 53 | }; 54 | 55 | export const manageSubscriptionStatusChange = async (subscriptionId: string, customerId: string, productId: string): Promise => { 56 | try { 57 | if (!subscriptionId || !customerId || !productId) { 58 | throw new Error("Missing required parameters for manageSubscriptionStatusChange"); 59 | } 60 | 61 | const subscription = await getSubscription(subscriptionId); 62 | 63 | const product = await stripe.products.retrieve(productId); 64 | const membership = product.metadata.membership as MembershipStatus; 65 | if (!["free", "pro"].includes(membership)) { 66 | throw new Error(`Invalid membership type in product metadata: ${membership}`); 67 | } 68 | 69 | const membershipStatus = getMembershipStatus(subscription.status, membership); 70 | 71 | await updateProfileByStripeCustomerId(customerId, { 72 | stripeSubscriptionId: subscription.id, 73 | membership: membershipStatus 74 | }); 75 | 76 | return membershipStatus; 77 | } catch (error) { 78 | console.error("Error in manageSubscriptionStatusChange:", error); 79 | throw error instanceof Error ? error : new Error("Failed to update subscription status"); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /actions/todos-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createTodo, deleteTodo, getTodo, getTodos, updateTodo } from "@/db/queries/todos-queries"; 4 | import { InsertTodo } from "@/db/schema/todos-schema"; 5 | import { ActionState } from "@/types"; 6 | import { revalidatePath } from "next/cache"; 7 | 8 | export async function createTodoAction(todo: InsertTodo): Promise { 9 | try { 10 | const newTodo = await createTodo(todo); 11 | revalidatePath("/todo"); 12 | return { status: "success", message: "Todo created successfully", data: newTodo }; 13 | } catch (error) { 14 | console.error("Error creating todo:", error); 15 | return { status: "error", message: "Failed to create todo" }; 16 | } 17 | } 18 | 19 | export async function getTodosAction(userId: string): Promise { 20 | try { 21 | const todos = await getTodos(userId); 22 | return { status: "success", message: "Todos retrieved successfully", data: todos }; 23 | } catch (error) { 24 | console.error("Error getting todos:", error); 25 | return { status: "error", message: "Failed to get todos" }; 26 | } 27 | } 28 | 29 | export async function getTodoAction(id: string): Promise { 30 | try { 31 | const todo = await getTodo(id); 32 | return { status: "success", message: "Todo retrieved successfully", data: todo }; 33 | } catch (error) { 34 | console.error("Error getting todo by ID:", error); 35 | return { status: "error", message: "Failed to get todo" }; 36 | } 37 | } 38 | 39 | export async function updateTodoAction(id: string, data: Partial): Promise { 40 | try { 41 | const updatedTodo = await updateTodo(id, data); 42 | revalidatePath("/todo"); 43 | return { status: "success", message: "Todo updated successfully", data: updatedTodo }; 44 | } catch (error) { 45 | console.error("Error updating todo:", error); 46 | return { status: "error", message: "Failed to update todo" }; 47 | } 48 | } 49 | 50 | export async function deleteTodoAction(id: string): Promise { 51 | try { 52 | await deleteTodo(id); 53 | revalidatePath("/todo"); 54 | return { status: "success", message: "Todo deleted successfully" }; 55 | } catch (error) { 56 | console.error("Error deleting todo:", error); 57 | return { status: "error", message: "Failed to delete todo" }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | interface AuthLayoutProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export default async function AuthLayout({ children }: AuthLayoutProps) { 6 | return
{children}
; 7 | } 8 | -------------------------------------------------------------------------------- /app/(auth)/login/[[...login]]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignIn } from "@clerk/nextjs"; 4 | import { dark } from "@clerk/themes"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export default function LoginPage() { 8 | const { theme } = useTheme(); 9 | 10 | return ( 11 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/(auth)/signup/[[...signup]]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignUp } from "@clerk/nextjs"; 4 | import { dark } from "@clerk/themes"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export default function SignUpPage() { 8 | const { theme } = useTheme(); 9 | 10 | return ( 11 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | export default function HomePage() { 2 | return ( 3 |
4 |
Home Page
5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /app/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import { cn } from "@/lib/utils"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export default async function PricingPage() { 7 | const { userId } = auth(); 8 | 9 | return ( 10 |
11 |

Choose Your Plan

12 |
13 | 21 | 29 |
30 |
31 | ); 32 | } 33 | 34 | interface PricingCardProps { 35 | title: string; 36 | price: string; 37 | description: string; 38 | buttonText: string; 39 | buttonLink: string; 40 | userId: string | null; 41 | } 42 | 43 | function PricingCard({ title, price, description, buttonText, buttonLink, userId }: PricingCardProps) { 44 | const finalButtonLink = userId ? `${buttonLink}?client_reference_id=${userId}` : buttonLink; 45 | 46 | return ( 47 | 48 | 49 | {title} 50 | {description} 51 | 52 | 53 |

{price}

54 |
55 | 56 | 67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/api/stripe/webhooks/route.ts: -------------------------------------------------------------------------------- 1 | import { manageSubscriptionStatusChange, updateStripeCustomer } from "@/actions/stripe-actions"; 2 | import { stripe } from "@/lib/stripe"; 3 | import { headers } from "next/headers"; 4 | import Stripe from "stripe"; 5 | 6 | const relevantEvents = new Set(["checkout.session.completed", "customer.subscription.updated", "customer.subscription.deleted"]); 7 | 8 | export async function POST(req: Request) { 9 | const body = await req.text(); 10 | const sig = headers().get("Stripe-Signature") as string; 11 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 12 | let event: Stripe.Event; 13 | 14 | try { 15 | if (!sig || !webhookSecret) { 16 | throw new Error("Webhook secret or signature missing"); 17 | } 18 | 19 | event = stripe.webhooks.constructEvent(body, sig, webhookSecret); 20 | } catch (err: any) { 21 | console.error(`Webhook Error: ${err.message}`); 22 | return new Response(`Webhook Error: ${err.message}`, { status: 400 }); 23 | } 24 | 25 | if (relevantEvents.has(event.type)) { 26 | try { 27 | switch (event.type) { 28 | case "customer.subscription.updated": 29 | case "customer.subscription.deleted": 30 | await handleSubscriptionChange(event); 31 | break; 32 | 33 | case "checkout.session.completed": 34 | await handleCheckoutSession(event); 35 | break; 36 | 37 | default: 38 | throw new Error("Unhandled relevant event!"); 39 | } 40 | } catch (error) { 41 | console.error("Webhook handler failed:", error); 42 | return new Response("Webhook handler failed. View your nextjs function logs.", { 43 | status: 400 44 | }); 45 | } 46 | } 47 | 48 | return new Response(JSON.stringify({ received: true })); 49 | } 50 | 51 | async function handleSubscriptionChange(event: Stripe.Event) { 52 | const subscription = event.data.object as Stripe.Subscription; 53 | const productId = subscription.items.data[0].price.product as string; 54 | await manageSubscriptionStatusChange(subscription.id, subscription.customer as string, productId); 55 | } 56 | 57 | async function handleCheckoutSession(event: Stripe.Event) { 58 | const checkoutSession = event.data.object as Stripe.Checkout.Session; 59 | if (checkoutSession.mode === "subscription") { 60 | const subscriptionId = checkoutSession.subscription as string; 61 | await updateStripeCustomer(checkoutSession.client_reference_id as string, subscriptionId, checkoutSession.customer as string); 62 | 63 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, { 64 | expand: ["default_payment_method"] 65 | }); 66 | 67 | const productId = subscription.items.data[0].price.product as string; 68 | await manageSubscriptionStatusChange(subscription.id, subscription.customer as string, productId); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /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: 0 0% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --primary: 0 0% 9%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 0 0% 96.1%; 16 | --secondary-foreground: 0 0% 9%; 17 | --muted: 0 0% 96.1%; 18 | --muted-foreground: 0 0% 45.1%; 19 | --accent: 0 0% 96.1%; 20 | --accent-foreground: 0 0% 9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 89.8%; 24 | --input: 0 0% 89.8%; 25 | --ring: 0 0% 3.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 0 0% 3.9%; 36 | --foreground: 0 0% 98%; 37 | --card: 0 0% 3.9%; 38 | --card-foreground: 0 0% 98%; 39 | --popover: 0 0% 3.9%; 40 | --popover-foreground: 0 0% 98%; 41 | --primary: 0 0% 98%; 42 | --primary-foreground: 0 0% 9%; 43 | --secondary: 0 0% 14.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 0 0% 14.9%; 46 | --muted-foreground: 0 0% 63.9%; 47 | --accent: 0 0% 14.9%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 0% 98%; 51 | --border: 0 0% 14.9%; 52 | --input: 0 0% 14.9%; 53 | --ring: 0 0% 83.1%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/header"; 2 | import { Toaster } from "@/components/ui/toaster"; 3 | import { Providers } from "@/components/utilities/providers"; 4 | import { createProfile, getProfileByUserId } from "@/db/queries/profiles-queries"; 5 | import { ClerkProvider } from "@clerk/nextjs"; 6 | import { auth } from "@clerk/nextjs/server"; 7 | import type { Metadata } from "next"; 8 | import { Inter } from "next/font/google"; 9 | import "./globals.css"; 10 | 11 | const inter = Inter({ subsets: ["latin"] }); 12 | 13 | export const metadata: Metadata = { 14 | title: "Todo App", 15 | description: "A full-stack template for a todo app." 16 | }; 17 | 18 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 19 | const { userId } = auth(); 20 | 21 | if (userId) { 22 | const profile = await getProfileByUserId(userId); 23 | if (!profile) { 24 | await createProfile({ userId }); 25 | } 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | 37 |
38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/todo/page.tsx: -------------------------------------------------------------------------------- 1 | import { TodoList } from "@/components/todo-list"; 2 | import { getProfileByUserId } from "@/db/queries/profiles-queries"; 3 | import { getTodos } from "@/db/queries/todos-queries"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export default async function TodoPage() { 8 | const { userId } = auth(); 9 | 10 | if (!userId) { 11 | return redirect("/login"); 12 | } 13 | 14 | const profile = await getProfileByUserId(userId); 15 | 16 | if (!profile) { 17 | return redirect("/signup"); 18 | } 19 | 20 | if (profile.membership === "free") { 21 | return redirect("/pricing"); 22 | } 23 | 24 | const todos = await getTodos(userId); 25 | 26 | return ( 27 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /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": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs"; 5 | import { CheckSquare, Menu, X } from "lucide-react"; 6 | import Link from "next/link"; 7 | import { useState } from "react"; 8 | 9 | export default function Header() { 10 | const [isMenuOpen, setIsMenuOpen] = useState(false); 11 | 12 | const toggleMenu = () => { 13 | setIsMenuOpen(!isMenuOpen); 14 | }; 15 | 16 | return ( 17 |
18 |
19 |
20 | 21 |

Todo App

22 |
23 | 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 55 |
56 |
57 |
58 | {isMenuOpen && ( 59 | 83 | )} 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /components/todo-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createTodoAction, deleteTodoAction, updateTodoAction } from "@/actions/todos-actions"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Checkbox } from "@/components/ui/checkbox"; 6 | import { Input } from "@/components/ui/input"; 7 | import { SelectTodo } from "@/db/schema"; 8 | import { Trash2 } from "lucide-react"; 9 | import { useRouter } from "next/navigation"; 10 | import { useState } from "react"; 11 | 12 | interface TodoListProps { 13 | userId: string; 14 | initialTodos: SelectTodo[]; 15 | } 16 | 17 | export function TodoList({ userId, initialTodos }: TodoListProps) { 18 | const router = useRouter(); 19 | 20 | const [newTodo, setNewTodo] = useState(""); 21 | const [todos, setTodos] = useState(initialTodos); 22 | 23 | const handleAddTodo = async () => { 24 | if (newTodo.trim() !== "") { 25 | const optimisticTodo = { 26 | id: Date.now().toString(), 27 | userId, 28 | content: newTodo, 29 | completed: false, 30 | createdAt: new Date(), 31 | updatedAt: new Date() 32 | }; 33 | setTodos((prevTodos) => [...prevTodos, optimisticTodo]); 34 | setNewTodo(""); 35 | 36 | await createTodoAction({ userId: userId, content: newTodo, completed: false }); 37 | router.refresh(); 38 | } 39 | }; 40 | 41 | const handleToggleTodo = async (id: string, completed: boolean) => { 42 | setTodos((prevTodos) => prevTodos.map((todo) => (todo.id === id ? { ...todo, completed: !completed } : todo))); 43 | 44 | await updateTodoAction(id, { completed: !completed }); 45 | router.refresh(); 46 | }; 47 | 48 | const handleRemoveTodo = async (id: string) => { 49 | setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id)); 50 | 51 | await deleteTodoAction(id); 52 | router.refresh(); 53 | }; 54 | 55 | return ( 56 |
57 |

Todo App

58 |
59 | setNewTodo(e.target.value)} 63 | placeholder="Add a new todo" 64 | className="mr-2" 65 | onKeyPress={(e) => e.key === "Enter" && handleAddTodo()} 66 | /> 67 | 68 |
69 |
    70 | {todos.map((todo) => ( 71 |
  • 75 |
    76 | handleToggleTodo(todo.id, todo.completed)} 80 | className="mr-2" 81 | /> 82 | 88 |
    89 | 97 |
  • 98 | ))} 99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /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 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } 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~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]: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 { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border 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 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 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/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>