├── .env.sample
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── docker-compose.yml
├── drizzle.config.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── computer.jpeg
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── (landing)
│ │ └── _sections
│ │ │ ├── features.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── pricing.tsx
│ │ │ └── reserved.tsx
│ ├── (legal)
│ │ ├── privacy
│ │ │ └── page.tsx
│ │ └── terms-of-service
│ │ │ └── page.tsx
│ ├── (main)
│ │ ├── settings
│ │ │ ├── _components
│ │ │ │ ├── actions.tsx
│ │ │ │ └── delete-account-button.tsx
│ │ │ └── page.tsx
│ │ └── todos
│ │ │ ├── _components
│ │ │ ├── actions.ts
│ │ │ ├── create-todo-button.tsx
│ │ │ ├── todo.tsx
│ │ │ └── validation.ts
│ │ │ └── page.tsx
│ ├── (subscribe)
│ │ └── success
│ │ │ └── page.tsx
│ ├── _components
│ │ ├── footer.tsx
│ │ ├── get-started-button.tsx
│ │ ├── header
│ │ │ ├── feedback.tsx
│ │ │ ├── header.tsx
│ │ │ └── links.tsx
│ │ ├── mode-toggle.tsx
│ │ ├── providers.tsx
│ │ └── theme-provider.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ └── webhooks
│ │ │ └── stripe
│ │ │ └── route.ts
│ ├── changelog
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── auth
│ │ ├── signed-in.tsx
│ │ ├── signed-out.tsx
│ │ └── subscription-status.tsx
│ ├── custom
│ │ └── edit-text.tsx
│ ├── loader-button.tsx
│ ├── send-event-on-load.tsx
│ ├── stripe
│ │ └── upgrade-button
│ │ │ ├── actions.ts
│ │ │ └── upgrade-button.tsx
│ ├── submit-button.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── tabs.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── use-toast.ts
├── data-access
│ ├── subscriptions.ts
│ ├── todos.ts
│ └── users.ts
├── db
│ ├── index.ts
│ └── schema.ts
├── env.ts
├── hooks
│ └── use-media-query.tsx
├── lib
│ ├── auth.ts
│ ├── events.ts
│ ├── get-server-session.ts
│ ├── stripe.ts
│ └── utils.ts
├── middleware.ts
└── use-cases
│ ├── authorization.ts
│ ├── subscriptions.ts
│ ├── todos.ts
│ └── users.ts
├── tailwind.config.ts
└── tsconfig.json
/.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 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ProjectPlannerAI StarterKit - A boilerplate for building SaaS products
2 |
3 | Welcome to the [ProjectPlannerAI](https://projectplannerai.com) StarterKit boilerplate! This is a github template which contains the following technology we feel is a great starting point for any new SaaS product.
4 |
5 | Built with the Next.js 14 App Router, TypeScript, Drizzle ORM, Stripe, Shadcn & Tailwind CSS.
6 |
7 | 
8 |
9 | ## Features
10 |
11 | - 🔒 Authentication (Next-Auth)
12 | - 🚨 Authorization
13 | - 💳 Subscription Management (Stripe)
14 | - 💵 Stripe Integration / Webhooks
15 | - 🗂️ Todo Management
16 | - 🌧️ Drizzle ORM
17 | - 😎 Light / Dark Mode
18 | - 🌟 Tailwind CSS & ShadCN
19 | - ✅ Account Management
20 | - 🔁 Changelog
21 | - 📈 Analytics
22 | - 💬 Feedback
23 |
24 | We kept this project pretty simple but with enough functionality to allow you to start adding on new features as needed.
25 |
26 | ## Getting started
27 |
28 | Start by clicking the "Use this template" button on the github repo. We suggest creating a new repository so you can track your code changes. After, clone your own repository down to your computer and start working on it.
29 |
30 | ### Prerequisites
31 |
32 | This starter kit uses Docker and Docker Compose to run a postgres database, so you will need to either have those installed, or modify the project to point to a hosted database solution.
33 |
34 | ## How to Run
35 |
36 | 1. `cp .env.sample .env`
37 | 2. `npm i`
38 | 3. `npm run dev`
39 | 4. `docker compose up`
40 | 5. `npm run db:push`
41 |
42 | ## Env Setup
43 |
44 | This starter kit depends on a few external services, such as **google oauth**, **stripe**, etc. You'll need to following the steps below and make sure everything is setup and copy the necessary values into your .env file:
45 |
46 | ### Database
47 |
48 | This starter kit assumes you will use the docker postgres locally, but if you'd rather use a third party database host, simply change your .env **DATABASE_URL** to point to your preferred postgres host.
49 |
50 | ### Stripe Setup
51 |
52 | This starter kit uses stripe which means you'll need to setup a stripe account at https://stripe.com. After creating an account and a project, you'll need to set the following env variables:
53 |
54 | - STRIPE_API_KEY
55 | - NEXT_PUBLIC_STRIPE_KEY
56 | - STRIPE_WEBHOOK_SECRET
57 | - PRICE_ID
58 | - NEXT_PUBLIC_STRIPE_MANAGE_URL
59 |
60 | How you can find these are outlined below:
61 |
62 | #### Stripe Keys
63 |
64 | You need to define both **NEXT_PUBLIC_STRIPE_KEY** and **STRIPE_API_KEY** inside of .env. These can get found here:
65 |
66 | - https://dashboard.stripe.com/test/apikeys
67 |
68 | #### Webhook Keys
69 |
70 | Depending on if you are developing locally or deploying to prod, there are two paths you need to take for getting a webhook key:
71 |
72 | ##### Local Development
73 |
74 | We provided an npm alias `stripe:listen` you can run if you want to setup your locally running application to listen for any stripe events. Run this command and copy the webhook secret it prints to the console into your .env file.
75 |
76 | ##### Production
77 |
78 | When going to production, you'll need to create a webhook endpoint and copy your webhook secret into _STRIPE_WEBHOOK_SECRET_:
79 |
80 | 1. https://dashboard.stripe.com/test/webhooks
81 | 2. create an endpoint pointing to https://your-domain.com/api/webhooks/stripe
82 | 3. listen for events invoice.payment_succeeded and checkout.session.completed
83 | 4. find your stripe secret key and copy into your projects
84 |
85 | #### Price Id (Product)
86 |
87 | You'll need to create a subscription product in stripe:
88 |
89 | 1. https://dashboard.stripe.com/products/create
90 | 2. Make your recurring product
91 | 3. Copy the price id
92 | 4. paste price id into .env of **PRICE_ID**
93 |
94 | #### Customer Portal
95 |
96 | Stripe has a built in way for customers to cancel their subscriptions. You'll need to enable this feature:
97 |
98 | 1. https://dashboard.stripe.com/settings/billing/portal
99 | 2. Click activate portal link button
100 | 3. Copy your portal link
101 | 4. Paste as env variable as **NEXT_PUBLIC_STRIPE_MANAGE_URL**
102 |
103 | ### Project Planner ID
104 |
105 | After you create your project inside of https://projectplannerai.com, copy your project id from your url and set in:
106 |
107 | - **NEXT_PUBLIC_PROJECT_PLANNER_ID**
108 |
109 | ### HOSTNAME
110 |
111 | When deplying to production, you want to set HOSTNAME to your FQDN, such as `https://you-domain.com`
112 |
113 | ### Next-Auth
114 |
115 | We use [Next-Auth](https://next-auth.js.org/) for our authentication library. In order to get this start kit setup correctly, you need to setup a google provider.
116 |
117 | #### Google Provider
118 |
119 | By default, this starter only comes with the google provider which you'll need to setup:
120 |
121 | 1. https://console.cloud.google.com/apis/credentials
122 | 2. create a new project
123 | 3. setup oauth consent screen
124 | 4. create credentials - oauth client id
125 | 5. for authorized javascript origins
126 |
127 | - http://localhost:3000
128 | - https://your-domain.com
129 |
130 | 6. Authorized redirect URIs
131 |
132 | - http://localhost:3000/api/auth/callback/google
133 | - https://your-domain.com/api/auth/callback/google
134 |
135 | 7. Set your google id and secret inside of .env
136 |
137 | - **GOOGLE_CLIENT_ID**
138 | - **GOOGLE_CLIENT_SECRET**
139 |
140 | 8. run `openssl rand -base64 32` and set **NEXTAUTH_SECRET** (this is used for signing the jwt)
141 |
142 | ## Contributions
143 |
144 | Everyone is welcome to contribute to this project. Feel free to open an issue if you have question or found a bug. We want to keep this starter simple with the core technology picked, so we don't recommend trying to add in various things without prior approval.
145 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/computer.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/ppai-next-starter/fe44a6649b3074f02be442b4a8684234ee394bad/public/computer.jpeg
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
26 | Go to your Dashboard
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/(landing)/_sections/pricing.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 { UpgradeButton } from "@/components/stripe/upgrade-button/upgrade-button";
5 | import { CheckIcon } from "lucide-react";
6 |
7 | export function PricingSection() {
8 | return (
9 |
10 |
11 |
12 |
13 | Designed for business teams like yours
14 |
15 |
16 | Here at Landwind we focus on markets where technology, innovation,
17 | and capital can unlock long-term value and drive economic growth.
18 |
19 |
20 |
21 |
22 |
23 |
Basic
24 |
25 | Best option if when you're just starting out.
26 |
27 |
28 | FREE
29 |
30 |
31 |
32 |
33 | Individual configuration
34 |
35 |
36 |
37 | No setup, or hidden fees
38 |
39 |
40 |
41 |
42 | Team size: 1 developer
43 |
44 |
45 |
46 |
47 |
48 | Premium support:{" "}
49 | 6 months
50 |
51 |
52 |
53 |
54 |
55 | Free updates: 6 months
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
Premium
65 |
66 | Much more features for growing teams.
67 |
68 |
69 | $10
70 | /month
71 |
72 |
73 |
74 |
75 | Individual configuration
76 |
77 |
78 |
79 | No setup, or hidden fees
80 |
81 |
82 |
83 |
84 | Team size:{" "}
85 | 10 developers
86 |
87 |
88 |
89 |
90 |
91 | Premium support:{" "}
92 | 24 months
93 |
94 |
95 |
96 |
97 |
98 | Free updates: 24 months
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/app/(landing)/_sections/reserved.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export function RightsReserved() {
4 | return (
5 |
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/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/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/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 |
64 | Delete Account
65 |
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 |
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/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 |
22 |
27 | Manage Subscription
28 |
29 |
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/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/_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 | Create Todo
71 |
72 |
73 |
74 | Create a Todo
75 |
76 |
105 |
106 |
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/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 |
45 | {todo.text}
46 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/_components/footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export function Footer() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | Company
11 |
12 |
24 |
25 |
26 |
27 | Help center
28 |
29 |
51 |
52 |
53 |
54 | Legal
55 |
56 |
57 |
58 |
59 | Privacy Policy
60 |
61 |
62 |
63 |
64 | Terms of Service
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/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 | {
11 | trackEvent("user clicked get started");
12 | signIn("google", { callbackUrl: "/todos" });
13 | }}
14 | >
15 | Login to Get Started
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/_components/header/feedback.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "@/components/ui/dialog";
14 | import {
15 | Drawer,
16 | DrawerClose,
17 | DrawerContent,
18 | DrawerDescription,
19 | DrawerFooter,
20 | DrawerHeader,
21 | DrawerTitle,
22 | DrawerTrigger,
23 | } from "@/components/ui/drawer";
24 | import { Input } from "@/components/ui/input";
25 | import { Label } from "@/components/ui/label";
26 | import { cn } from "@/lib/utils";
27 | import { zodResolver } from "@hookform/resolvers/zod";
28 | import { Loader2, MessageCircleHeart } from "lucide-react";
29 | import { Controller, useForm } from "react-hook-form";
30 | import { z } from "zod";
31 |
32 | import { useToast } from "@/components/ui/use-toast";
33 | import useMediaQuery from "@/hooks/use-media-query";
34 |
35 | type FeedbackFormProps = {
36 | setOpen: React.Dispatch>;
37 | };
38 |
39 | const feedbackSchema = z.object({
40 | name: z.string().min(2, {
41 | message: "Name is required",
42 | }),
43 | feedback: z.string().min(1, { message: "Feedback is required" }),
44 | });
45 |
46 | export default function FeedbackButton() {
47 | const [open, setOpen] = React.useState(false);
48 |
49 | const { isMobile } = useMediaQuery();
50 |
51 | const description =
52 | "We value your feedback. How can we improve your experience?";
53 |
54 | if (isMobile) {
55 | return (
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | Feedback
65 | {description}
66 |
67 |
68 |
69 |
70 | Cancel
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | Feedback
88 | {description}
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | export function FeedbackForm({ setOpen }: FeedbackFormProps) {
97 | const {
98 | control,
99 | handleSubmit,
100 | formState: { errors, isSubmitting },
101 | } = useForm({
102 | resolver: zodResolver(feedbackSchema),
103 | defaultValues: {
104 | name: "",
105 | feedback: "",
106 | },
107 | });
108 | const { toast } = useToast();
109 | const onSubmit = async (values: z.infer) => {
110 | try {
111 | await fetch("https://projectplannerai.com/api/feedback", {
112 | method: "POST",
113 | headers: {
114 | "Content-Type": "application/json",
115 | },
116 | body: JSON.stringify({
117 | name: values.name,
118 | feedback: values.feedback,
119 | projectId: process.env.NEXT_PUBLIC_PROJECT_PLANNER_ID,
120 | }),
121 | });
122 | setOpen(false);
123 | toast({
124 | title: "Feedback submitted",
125 | description: "Thank you for your feedback",
126 | });
127 | } catch (error) {
128 | console.error("Failed to send feedback:", error);
129 | }
130 | };
131 |
132 | return (
133 |
184 | );
185 | }
186 |
--------------------------------------------------------------------------------
/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 |
38 | StarterKit
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | Manage Todos
48 |
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 |
87 | Sign In
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/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 |
17 | Features
18 |
19 |
20 |
21 | Pricing
22 |
23 |
24 |
25 | Changelog
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/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 |
22 |
23 |
24 | Toggle theme
25 |
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/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
41 | {format(changelog.date, "PP")}
42 |
43 |
44 |
45 |
46 |
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/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/ppai-next-starter/fe44a6649b3074f02be442b4a8684234ee394bad/src/app/favicon.ico
--------------------------------------------------------------------------------
/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/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 | //
36 | // Go to your Dashboard
37 | //
38 | //
39 |
40 | //
41 | //
42 | //
43 |
44 |
49 | //
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/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/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/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/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 | {
95 | setIsEditing(false);
96 | form.setValue("title", value);
97 | }}
98 | >
99 |
100 |
101 |
102 |
103 |
104 | )}
105 | {!isEditing && (
106 |
107 |
{children}
108 |
109 |
setIsEditing(true)}
113 | >
114 |
115 |
116 |
117 | )}
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/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 |
16 | {isLoading ? : children}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/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/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 |
40 | {pending && } {children}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/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 |
15 | {pending && } {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/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 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/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/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/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/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/middleware.ts:
--------------------------------------------------------------------------------
1 | export { default } from "next-auth/middleware";
2 |
3 | export const config = { matcher: ["/todos", "/settings"] };
4 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------