├── .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 | ![demo](https://github.com/webdevcody/ppai-next-starter/assets/39573679/46f68cfd-5f85-4150-ace6-5a140ac5a3a5) 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 | 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 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
35 | 36 |
37 | hero image 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 |
6 |
7 | 11 | Landwind Logo 18 | StarterKit 19 | 20 | 21 | © 2024 StarterKit. All Rights Reserved. Built with{" "} 22 | 28 | Project Planner AI 29 | 30 | 31 | 124 |
125 |
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 | 66 | 67 | 68 | 69 | Are you sure? 70 | 71 | Deleting your account means you will not be able to recover your 72 | data in the future. Please type Please delete to 73 | confirm. 74 | 75 | 76 | 77 |
78 | 79 | ( 83 | 84 | Confirm 85 | 86 | 87 | 88 | 89 | 90 | )} 91 | /> 92 | 93 | 94 | Cancel 95 | 96 | Delete 97 | 98 | 99 | 100 | 101 |
102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/app/(main)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | import { DeleteAccountButton } from "./_components/delete-account-button"; 4 | 5 | export default function SettingsPage() { 6 | return ( 7 |
8 |

Account Settings

9 | 10 |
11 |
12 |
13 | 14 | Manage Subscription 15 | 16 |
17 | 18 |
19 |
20 |
You can cancel your subscription with the link below
21 | 30 |
31 |
32 |
33 | 34 |
35 |
36 | 37 | Danger Zone 38 | 39 |
40 | 41 |
42 |
43 |
You can delete your account below
44 | 45 |
46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/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 | 71 | 72 | 73 | 74 | Create a Todo 75 | 76 |
77 | 78 | ( 82 | 83 | Text 84 | 85 | 86 | 87 | 88 | 89 | )} 90 | /> 91 | 92 |
93 | 102 | Create 103 |
104 | 105 | 106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/app/(main)/todos/_components/todo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Todo } from "@/db/schema"; 4 | import { TrashIcon } from "lucide-react"; 5 | import { useTransition } from "react"; 6 | import { useToast } from "@/components/ui/use-toast"; 7 | import { deleteTodoAction, setTodoCompleteStatusAction } from "./actions"; 8 | import { LoaderButton } from "@/components/loader-button"; 9 | import { Checkbox } from "@/components/ui/checkbox"; 10 | import { trackEvent } from "@/lib/events"; 11 | 12 | function TodoCheckbox({ todo }: { todo: Todo }) { 13 | const [pending, startTransition] = useTransition(); 14 | 15 | return ( 16 | { 20 | trackEvent("user toggled todo"); 21 | startTransition(() => { 22 | setTodoCompleteStatusAction(todo.id, checked as boolean); 23 | }); 24 | }} 25 | /> 26 | ); 27 | } 28 | 29 | export function Todo({ todo }: { todo: Todo }) { 30 | const { toast } = useToast(); 31 | const [pending, startTransition] = useTransition(); 32 | 33 | return ( 34 |
38 |
39 | 40 | 41 | 47 |
48 | 49 | { 52 | trackEvent("user deleted todo"); 53 | startTransition(() => { 54 | deleteTodoAction(todo.id) 55 | .then(() => { 56 | toast({ 57 | title: "Todo Deleted", 58 | description: "Your todo has been removed", 59 | }); 60 | }) 61 | .catch((e) => { 62 | toast({ 63 | title: "Something went wrong", 64 | description: e.message, 65 | variant: "destructive", 66 | }); 67 | }); 68 | }); 69 | }} 70 | variant="destructive" 71 | title="Delete Todo" 72 | > 73 | 74 | 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /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 | 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 | 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 | 61 | 62 | 63 | 64 | Feedback 65 | {description} 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | ); 76 | } 77 | 78 | return ( 79 | 80 | 81 | 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 |
137 |
138 | 141 | ( 145 | 146 | )} 147 | /> 148 | {errors.name && typeof errors.name.message === "string" && ( 149 |

{errors.name.message}

150 | )} 151 |
152 |
153 | 156 | ( 160 |