├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── components.json ├── drizzle.config.ts ├── drizzle ├── 0000_blue_logan.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── arrow.svg ├── grid.svg ├── images │ └── app │ │ ├── demo1.png │ │ ├── demo2.png │ │ ├── demo3.png │ │ └── demo4.png ├── next.svg └── vercel.svg ├── src ├── app │ ├── (admin) │ │ ├── layout.tsx │ │ ├── results │ │ │ ├── FormsPicker.tsx │ │ │ ├── ResultsDisplay.tsx │ │ │ ├── Table.tsx │ │ │ └── page.tsx │ │ ├── settings │ │ │ ├── ManageSubscription.tsx │ │ │ └── page.tsx │ │ └── view-forms │ │ │ └── page.tsx │ ├── actions │ │ ├── generateForm.ts │ │ ├── getUserForms.ts │ │ ├── mutateForm.ts │ │ ├── navigateToForm.ts │ │ └── userSubscriptions.ts │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── form │ │ │ └── new │ │ │ │ └── route.ts │ │ └── stripe │ │ │ ├── checkout-session │ │ │ └── route.ts │ │ │ ├── create-portal │ │ │ └── route.ts │ │ │ └── webhook │ │ │ └── route.ts │ ├── favicon.ico │ ├── form-generator │ │ ├── UserSubscriptionWrapper.tsx │ │ └── index.tsx │ ├── forms │ │ ├── Form.tsx │ │ ├── FormField.tsx │ │ ├── FormPublishSuccess.tsx │ │ ├── FormsList.tsx │ │ ├── [formId] │ │ │ ├── page.tsx │ │ │ └── success │ │ │ │ └── page.tsx │ │ ├── edit │ │ │ └── [formId] │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── globals.css │ ├── landing-page │ │ └── index.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── payment │ │ └── success │ │ │ └── page.tsx │ └── subscription │ │ └── SubscribeBtn.tsx ├── auth.ts ├── components │ ├── icons.tsx │ ├── navigation │ │ ├── navbar.tsx │ │ └── updgradeAccBtn.tsx │ ├── progressBar.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── header.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── radio-group.tsx │ │ ├── select.tsx │ │ ├── switch.tsx │ │ └── textarea.tsx ├── db │ ├── index.ts │ └── schema.ts ├── lib │ ├── stripe-client.ts │ ├── stripe.ts │ └── utils.ts └── types │ ├── form-types.d.ts │ └── nav-types.d.ts ├── tailwind.config.js ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY="" 2 | GOOGLE_CLIENT_ID="" 3 | GOOGLE_CLIENT_SECRET=" 4 | AUTH_SECRET="" 5 | DATABASE_URL="" 6 | NEXT_PUBLIC_PUBLISHABLE_KEY="" 7 | STRIPE_SECRET_KEY="" 8 | STRIPE_WEBHOOK_SECRET="" 9 | STRIPE_WEBHOOK_LOCAL_SERCRET="" 10 | PLAUSIBLE_DOMAIN="" 11 | -------------------------------------------------------------------------------- /.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 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSaveMode": "modificationsIfAvailable", 4 | "prettier.singleAttributePerLine": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The project that uses AI to generate forms. 2 | 3 | Screenshot 2024-01-22 at 3 34 26 PM 4 | 5 | ## Tech Stack 6 | 7 | - Next-auth - authentication 8 | - Shadcn ui - ui library 9 | - Open Al - AI Integration 10 | - Drizzle - Orm 11 | - PostgreSQL - database 12 | - Stripe - payments 13 | - Tanstack - Table 14 | - Typescript - Type Checking 15 | - Plausible - Analytics 16 | - Vercel - Deployment 17 | - Stripe - Payments 18 | - Zod - Schema Validation 19 | 20 | 21 | ## Features 22 | 23 | - Authentication ✅ 24 | - AI Form Generation ✅ 25 | - Form Publish and Submissions ✅ 26 | - View your forms ✅ 27 | - Admin Panel ✅ 28 | - View Results ✅ 29 | - Settings & Upgrade Subscription ✅ 30 | - Analytics ✅ 31 | - Landing page ✅ 32 | - Edit forms ❌ (open to pull requests) 33 | 34 | ## Getting Started 35 | 36 | First, run the development server: 37 | 38 | ```bash 39 | npm run dev 40 | # or 41 | yarn dev 42 | # or 43 | pnpm dev 44 | # or 45 | bun dev 46 | ``` 47 | 48 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 49 | 50 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 51 | 52 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 53 | 54 | ## Environment Variables 55 | 56 | Create a new .env file and add your keys in the following manner: 57 | ``` 58 | OPENAI_API_KEY="" 59 | GOOGLE_CLIENT_ID="" 60 | GOOGLE_CLIENT_SECRET="" 61 | AUTH_SECRET="" 62 | DATABASE_URL="" 63 | NEXT_PUBLIC_PUBLISHABLE_KEY="" 64 | STRIPE_SECRET_KEY="" 65 | STRIPE_WEBHOOK_SECRET="" 66 | STRIPE_WEBHOOK_LOCAL_SERCRET="" 67 | PLAUSIBLE_DOMAIN="" 68 | ``` 69 | 70 | ## Deploy on Vercel 71 | 72 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 73 | 74 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 75 | -------------------------------------------------------------------------------- /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.js", 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 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./src/db/schema.ts", 5 | out: "./drizzle", 6 | driver: "pg", 7 | dbCredentials: { 8 | connectionString: 9 | process.env.DATABASE_URL || 10 | "postgres://postgres:postgres@localhost:5432/postgres", 11 | }, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /drizzle/0000_blue_logan.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "field_type" AS ENUM('RadioGroup', 'Select', 'Input', 'Textarea', 'Switch'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "account" ( 8 | "userId" text NOT NULL, 9 | "type" text NOT NULL, 10 | "provider" text NOT NULL, 11 | "providerAccountId" text NOT NULL, 12 | "refresh_token" text, 13 | "access_token" text, 14 | "expires_at" integer, 15 | "token_type" text, 16 | "scope" text, 17 | "id_token" text, 18 | "session_state" text, 19 | CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId") 20 | ); 21 | --> statement-breakpoint 22 | CREATE TABLE IF NOT EXISTS "answers" ( 23 | "id" serial PRIMARY KEY NOT NULL, 24 | "value" text, 25 | "question_id" integer, 26 | "form_submission_id" integer, 27 | "field_options_id" integer 28 | ); 29 | --> statement-breakpoint 30 | CREATE TABLE IF NOT EXISTS "field_options" ( 31 | "id" serial PRIMARY KEY NOT NULL, 32 | "text" text, 33 | "value" text, 34 | "question_id" integer 35 | ); 36 | --> statement-breakpoint 37 | CREATE TABLE IF NOT EXISTS "form_submissions" ( 38 | "id" serial PRIMARY KEY NOT NULL, 39 | "form_id" integer 40 | ); 41 | --> statement-breakpoint 42 | CREATE TABLE IF NOT EXISTS "forms" ( 43 | "id" serial PRIMARY KEY NOT NULL, 44 | "name" text, 45 | "description" text, 46 | "user_id" text, 47 | "published" boolean 48 | ); 49 | --> statement-breakpoint 50 | CREATE TABLE IF NOT EXISTS "questions" ( 51 | "id" serial PRIMARY KEY NOT NULL, 52 | "text" text, 53 | "field_type" "field_type", 54 | "form_id" integer 55 | ); 56 | --> statement-breakpoint 57 | CREATE TABLE IF NOT EXISTS "session" ( 58 | "sessionToken" text PRIMARY KEY NOT NULL, 59 | "userId" text NOT NULL, 60 | "expires" timestamp NOT NULL 61 | ); 62 | --> statement-breakpoint 63 | CREATE TABLE IF NOT EXISTS "user" ( 64 | "id" text PRIMARY KEY NOT NULL, 65 | "name" text, 66 | "email" text NOT NULL, 67 | "emailVerified" timestamp, 68 | "image" text 69 | ); 70 | --> statement-breakpoint 71 | CREATE TABLE IF NOT EXISTS "verificationToken" ( 72 | "identifier" text NOT NULL, 73 | "token" text NOT NULL, 74 | "expires" timestamp NOT NULL, 75 | CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token") 76 | ); 77 | --> statement-breakpoint 78 | DO $$ BEGIN 79 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action; 80 | EXCEPTION 81 | WHEN duplicate_object THEN null; 82 | END $$; 83 | --> statement-breakpoint 84 | DO $$ BEGIN 85 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action; 86 | EXCEPTION 87 | WHEN duplicate_object THEN null; 88 | END $$; 89 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "7c834bdc-1502-4ccf-87b7-3bd712e90495", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "userId": { 12 | "name": "userId", 13 | "type": "text", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "type": { 18 | "name": "type", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "provider": { 24 | "name": "provider", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "providerAccountId": { 30 | "name": "providerAccountId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "refresh_token": { 36 | "name": "refresh_token", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "access_token": { 42 | "name": "access_token", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "expires_at": { 48 | "name": "expires_at", 49 | "type": "integer", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "token_type": { 54 | "name": "token_type", 55 | "type": "text", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "scope": { 60 | "name": "scope", 61 | "type": "text", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "id_token": { 66 | "name": "id_token", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "session_state": { 72 | "name": "session_state", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "account_userId_user_id_fk": { 81 | "name": "account_userId_user_id_fk", 82 | "tableFrom": "account", 83 | "tableTo": "user", 84 | "columnsFrom": [ 85 | "userId" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "cascade", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": { 95 | "account_provider_providerAccountId_pk": { 96 | "name": "account_provider_providerAccountId_pk", 97 | "columns": [ 98 | "provider", 99 | "providerAccountId" 100 | ] 101 | } 102 | }, 103 | "uniqueConstraints": {} 104 | }, 105 | "answers": { 106 | "name": "answers", 107 | "schema": "", 108 | "columns": { 109 | "id": { 110 | "name": "id", 111 | "type": "serial", 112 | "primaryKey": true, 113 | "notNull": true 114 | }, 115 | "value": { 116 | "name": "value", 117 | "type": "text", 118 | "primaryKey": false, 119 | "notNull": false 120 | }, 121 | "question_id": { 122 | "name": "question_id", 123 | "type": "integer", 124 | "primaryKey": false, 125 | "notNull": false 126 | }, 127 | "form_submission_id": { 128 | "name": "form_submission_id", 129 | "type": "integer", 130 | "primaryKey": false, 131 | "notNull": false 132 | }, 133 | "field_options_id": { 134 | "name": "field_options_id", 135 | "type": "integer", 136 | "primaryKey": false, 137 | "notNull": false 138 | } 139 | }, 140 | "indexes": {}, 141 | "foreignKeys": {}, 142 | "compositePrimaryKeys": {}, 143 | "uniqueConstraints": {} 144 | }, 145 | "field_options": { 146 | "name": "field_options", 147 | "schema": "", 148 | "columns": { 149 | "id": { 150 | "name": "id", 151 | "type": "serial", 152 | "primaryKey": true, 153 | "notNull": true 154 | }, 155 | "text": { 156 | "name": "text", 157 | "type": "text", 158 | "primaryKey": false, 159 | "notNull": false 160 | }, 161 | "value": { 162 | "name": "value", 163 | "type": "text", 164 | "primaryKey": false, 165 | "notNull": false 166 | }, 167 | "question_id": { 168 | "name": "question_id", 169 | "type": "integer", 170 | "primaryKey": false, 171 | "notNull": false 172 | } 173 | }, 174 | "indexes": {}, 175 | "foreignKeys": {}, 176 | "compositePrimaryKeys": {}, 177 | "uniqueConstraints": {} 178 | }, 179 | "form_submissions": { 180 | "name": "form_submissions", 181 | "schema": "", 182 | "columns": { 183 | "id": { 184 | "name": "id", 185 | "type": "serial", 186 | "primaryKey": true, 187 | "notNull": true 188 | }, 189 | "form_id": { 190 | "name": "form_id", 191 | "type": "integer", 192 | "primaryKey": false, 193 | "notNull": false 194 | } 195 | }, 196 | "indexes": {}, 197 | "foreignKeys": {}, 198 | "compositePrimaryKeys": {}, 199 | "uniqueConstraints": {} 200 | }, 201 | "forms": { 202 | "name": "forms", 203 | "schema": "", 204 | "columns": { 205 | "id": { 206 | "name": "id", 207 | "type": "serial", 208 | "primaryKey": true, 209 | "notNull": true 210 | }, 211 | "name": { 212 | "name": "name", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": false 216 | }, 217 | "description": { 218 | "name": "description", 219 | "type": "text", 220 | "primaryKey": false, 221 | "notNull": false 222 | }, 223 | "user_id": { 224 | "name": "user_id", 225 | "type": "text", 226 | "primaryKey": false, 227 | "notNull": false 228 | }, 229 | "published": { 230 | "name": "published", 231 | "type": "boolean", 232 | "primaryKey": false, 233 | "notNull": false 234 | } 235 | }, 236 | "indexes": {}, 237 | "foreignKeys": {}, 238 | "compositePrimaryKeys": {}, 239 | "uniqueConstraints": {} 240 | }, 241 | "questions": { 242 | "name": "questions", 243 | "schema": "", 244 | "columns": { 245 | "id": { 246 | "name": "id", 247 | "type": "serial", 248 | "primaryKey": true, 249 | "notNull": true 250 | }, 251 | "text": { 252 | "name": "text", 253 | "type": "text", 254 | "primaryKey": false, 255 | "notNull": false 256 | }, 257 | "field_type": { 258 | "name": "field_type", 259 | "type": "field_type", 260 | "primaryKey": false, 261 | "notNull": false 262 | }, 263 | "form_id": { 264 | "name": "form_id", 265 | "type": "integer", 266 | "primaryKey": false, 267 | "notNull": false 268 | } 269 | }, 270 | "indexes": {}, 271 | "foreignKeys": {}, 272 | "compositePrimaryKeys": {}, 273 | "uniqueConstraints": {} 274 | }, 275 | "session": { 276 | "name": "session", 277 | "schema": "", 278 | "columns": { 279 | "sessionToken": { 280 | "name": "sessionToken", 281 | "type": "text", 282 | "primaryKey": true, 283 | "notNull": true 284 | }, 285 | "userId": { 286 | "name": "userId", 287 | "type": "text", 288 | "primaryKey": false, 289 | "notNull": true 290 | }, 291 | "expires": { 292 | "name": "expires", 293 | "type": "timestamp", 294 | "primaryKey": false, 295 | "notNull": true 296 | } 297 | }, 298 | "indexes": {}, 299 | "foreignKeys": { 300 | "session_userId_user_id_fk": { 301 | "name": "session_userId_user_id_fk", 302 | "tableFrom": "session", 303 | "tableTo": "user", 304 | "columnsFrom": [ 305 | "userId" 306 | ], 307 | "columnsTo": [ 308 | "id" 309 | ], 310 | "onDelete": "cascade", 311 | "onUpdate": "no action" 312 | } 313 | }, 314 | "compositePrimaryKeys": {}, 315 | "uniqueConstraints": {} 316 | }, 317 | "user": { 318 | "name": "user", 319 | "schema": "", 320 | "columns": { 321 | "id": { 322 | "name": "id", 323 | "type": "text", 324 | "primaryKey": true, 325 | "notNull": true 326 | }, 327 | "name": { 328 | "name": "name", 329 | "type": "text", 330 | "primaryKey": false, 331 | "notNull": false 332 | }, 333 | "email": { 334 | "name": "email", 335 | "type": "text", 336 | "primaryKey": false, 337 | "notNull": true 338 | }, 339 | "emailVerified": { 340 | "name": "emailVerified", 341 | "type": "timestamp", 342 | "primaryKey": false, 343 | "notNull": false 344 | }, 345 | "image": { 346 | "name": "image", 347 | "type": "text", 348 | "primaryKey": false, 349 | "notNull": false 350 | } 351 | }, 352 | "indexes": {}, 353 | "foreignKeys": {}, 354 | "compositePrimaryKeys": {}, 355 | "uniqueConstraints": {} 356 | }, 357 | "verificationToken": { 358 | "name": "verificationToken", 359 | "schema": "", 360 | "columns": { 361 | "identifier": { 362 | "name": "identifier", 363 | "type": "text", 364 | "primaryKey": false, 365 | "notNull": true 366 | }, 367 | "token": { 368 | "name": "token", 369 | "type": "text", 370 | "primaryKey": false, 371 | "notNull": true 372 | }, 373 | "expires": { 374 | "name": "expires", 375 | "type": "timestamp", 376 | "primaryKey": false, 377 | "notNull": true 378 | } 379 | }, 380 | "indexes": {}, 381 | "foreignKeys": {}, 382 | "compositePrimaryKeys": { 383 | "verificationToken_identifier_token_pk": { 384 | "name": "verificationToken_identifier_token_pk", 385 | "columns": [ 386 | "identifier", 387 | "token" 388 | ] 389 | } 390 | }, 391 | "uniqueConstraints": {} 392 | } 393 | }, 394 | "enums": { 395 | "field_type": { 396 | "name": "field_type", 397 | "values": { 398 | "RadioGroup": "RadioGroup", 399 | "Select": "Select", 400 | "Input": "Input", 401 | "Textarea": "Textarea", 402 | "Switch": "Switch" 403 | } 404 | } 405 | }, 406 | "schemas": {}, 407 | "_meta": { 408 | "columns": {}, 409 | "schemas": {}, 410 | "tables": {} 411 | } 412 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1706725403234, 9 | "tag": "0000_blue_logan", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "lh3.googleusercontent.com", 8 | port: "", 9 | pathname: "/a/**", 10 | }, 11 | ], 12 | }, 13 | }; 14 | 15 | module.exports = nextConfig; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-form-builder-tutorial", 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 | }, 11 | "dependencies": { 12 | "@auth/drizzle-adapter": "^0.4.0", 13 | "@hookform/resolvers": "^3.3.4", 14 | "@radix-ui/react-dialog": "^1.0.5", 15 | "@radix-ui/react-icons": "^1.3.0", 16 | "@radix-ui/react-label": "^2.0.2", 17 | "@radix-ui/react-radio-group": "^1.1.3", 18 | "@radix-ui/react-select": "^2.0.0", 19 | "@radix-ui/react-slot": "^1.0.2", 20 | "@radix-ui/react-switch": "^1.0.3", 21 | "@stripe/stripe-js": "^2.4.0", 22 | "@tanstack/react-table": "^8.11.7", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.0", 25 | "drizzle-orm": "^0.29.3", 26 | "lucide-react": "^0.309.0", 27 | "next": "14.0.4", 28 | "next-auth": "^5.0.0-beta.5", 29 | "next-plausible": "^3.12.0", 30 | "pg": "^8.11.3", 31 | "postgres": "^3.4.3", 32 | "react": "^18", 33 | "react-dom": "^18", 34 | "react-hook-form": "^7.49.3", 35 | "stripe": "^14.14.0", 36 | "tailwind-merge": "^2.2.0", 37 | "tailwindcss-animate": "^1.0.7", 38 | "zod": "^3.22.4" 39 | }, 40 | "devDependencies": { 41 | "@types/node": "^20", 42 | "@types/react": "^18", 43 | "@types/react-dom": "^18", 44 | "autoprefixer": "^10.0.1", 45 | "drizzle-kit": "^0.20.13", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.0.4", 48 | "postcss": "^8", 49 | "tailwindcss": "^3.3.0", 50 | "typescript": "^5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/grid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/app/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/judygab/ai-form-builder-tutorial/365b5dc28c5ff2845480fb59a74f33387424abe1/public/images/app/demo1.png -------------------------------------------------------------------------------- /public/images/app/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/judygab/ai-form-builder-tutorial/365b5dc28c5ff2845480fb59a74f33387424abe1/public/images/app/demo2.png -------------------------------------------------------------------------------- /public/images/app/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/judygab/ai-form-builder-tutorial/365b5dc28c5ff2845480fb59a74f33387424abe1/public/images/app/demo3.png -------------------------------------------------------------------------------- /public/images/app/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/judygab/ai-form-builder-tutorial/365b5dc28c5ff2845480fb59a74f33387424abe1/public/images/app/demo4.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/(admin)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/ui/header"; 2 | import DashboardNav from "@/components/navigation/navbar"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import FormGenerator from "../form-generator"; 5 | import { SidebarNavItem } from "@/types/nav-types"; 6 | import UpdgradeAccBtn from "@/components/navigation/updgradeAccBtn"; 7 | 8 | export default function AdminLayout({ children }: { 9 | children: React.ReactNode 10 | }) { 11 | const dashboardConfig: { 12 | sidebarNav: SidebarNavItem[] 13 | } = { 14 | sidebarNav: [ 15 | { 16 | title: "My Forms", 17 | href: "/view-forms", 18 | icon: "library", 19 | }, 20 | { 21 | title: "Results", 22 | href: "/results", 23 | icon: "list", 24 | }, 25 | { 26 | title: "Analytics", 27 | href: "/analytics", 28 | icon: "lineChart", 29 | }, 30 | { 31 | title: "Charts", 32 | href: "/charts", 33 | icon: "pieChart", 34 | }, 35 | { 36 | title: "Settings", 37 | href: "/settings", 38 | icon: "settings", 39 | }, 40 | ] 41 | } 42 | return ( 43 |
44 |
45 |
46 | 50 |
51 |
52 |

Dashboard

53 | 54 | 55 | 56 |
57 |
58 | {children} 59 |
60 |
61 |
62 | ) 63 | } -------------------------------------------------------------------------------- /src/app/(admin)/results/FormsPicker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { use, useCallback } from 'react' 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectGroup, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | } from "@/components/ui/select"; 11 | import { Label } from '@/components/ui/label'; 12 | import { useSearchParams, useRouter, usePathname } from 'next/navigation'; 13 | 14 | type SelectProps = { 15 | value: number, 16 | label?: string | null 17 | } 18 | 19 | type FormsPickerProps = { 20 | options: Array 21 | } 22 | 23 | const FormsPicker = (props: FormsPickerProps) => { 24 | const { options } = props; 25 | 26 | const searchParams = useSearchParams(); 27 | const router = useRouter(); 28 | const pathname = usePathname(); 29 | 30 | const formId = searchParams.get('formId') || options[0].value.toString(); 31 | 32 | const createQueryString = useCallback((name: string, value: string) => { 33 | console.log('searchParams', searchParams); 34 | const params = new URLSearchParams(searchParams.toString()); 35 | params.set(name, value); 36 | 37 | return params.toString(); 38 | ; 39 | }, [searchParams]) 40 | 41 | return ( 42 |
43 | 44 | 60 |
) 61 | } 62 | 63 | export default FormsPicker -------------------------------------------------------------------------------- /src/app/(admin)/results/ResultsDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Table } from './Table' 3 | import { db } from '@/db' 4 | import { eq } from 'drizzle-orm' 5 | import { forms } from '@/db/schema' 6 | 7 | type Props = { 8 | formId: number 9 | } 10 | 11 | const ResultsDisplay = async ({ formId }: Props) => { 12 | const form = await db.query.forms.findFirst({ 13 | where: eq(forms.id, formId), 14 | with: { 15 | questions: { 16 | with: { 17 | fieldOptions: true 18 | } 19 | }, 20 | submissions: { 21 | with: { 22 | answers: { 23 | with: { 24 | fieldOption: true 25 | } 26 | } 27 | } 28 | } 29 | } 30 | }) 31 | 32 | if (!form) return null; 33 | if (!form.submissions) return

No submissions on this form yet!

; 34 | console.log('form', form); 35 | return ( 36 |
37 | 41 | 42 | ) 43 | } 44 | 45 | export default ResultsDisplay -------------------------------------------------------------------------------- /src/app/(admin)/results/Table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as React from 'react' 3 | import { InferSelectModel } from 'drizzle-orm'; 4 | import { forms, answers, formSubmissions, questions, fieldOptions } from '@/db/schema'; 5 | 6 | import { 7 | createColumnHelper, 8 | flexRender, 9 | getCoreRowModel, 10 | useReactTable, 11 | } from '@tanstack/react-table' 12 | 13 | type FieldOption = InferSelectModel 14 | 15 | type Answer = InferSelectModel & { 16 | fieldOption?: FieldOption | null 17 | } 18 | 19 | type Question = InferSelectModel & { fieldOptions: FieldOption[] } 20 | 21 | type FormSubmission = InferSelectModel & { 22 | answers: Answer[] 23 | } 24 | 25 | export type Form = InferSelectModel & { 26 | questions: Question[] 27 | submissions: FormSubmission[] 28 | } | undefined 29 | 30 | interface TableProps { 31 | data: FormSubmission[] 32 | columns: Question[] 33 | } 34 | 35 | const columnHelper = createColumnHelper() 36 | 37 | export function Table(props: TableProps) { 38 | const { data } = props 39 | const columns = [ 40 | columnHelper.accessor('id', { 41 | cell: info => info.getValue(), 42 | }), 43 | ...props.columns.map((question: any, index: number) => { 44 | return columnHelper.accessor((row) => { 45 | let answer = row.answers.find((answer: any) => { 46 | return answer.questionId === question.id; 47 | }); 48 | 49 | return answer.fieldOption ? answer.fieldOption.text : answer.value; 50 | }, { 51 | header: () => question.text, 52 | id: question.id.toString(), 53 | cell: info => info.renderValue(), 54 | }) 55 | }) 56 | ] 57 | 58 | const table = useReactTable({ 59 | data, 60 | columns, 61 | getCoreRowModel: getCoreRowModel(), 62 | }) 63 | 64 | return ( 65 |
66 |
67 |
68 | 69 | {table.getHeaderGroups().map(headerGroup => ( 70 | 71 | {headerGroup.headers.map(header => ( 72 | 80 | ))} 81 | 82 | ))} 83 | 84 | 85 | {table.getRowModel().rows.map(row => ( 86 | 87 | {row.getVisibleCells().map(cell => ( 88 | 91 | ))} 92 | 93 | ))} 94 | 95 |
73 | {header.isPlaceholder 74 | ? null 75 | : flexRender( 76 | header.column.columnDef.header, 77 | header.getContext() 78 | )} 79 |
89 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 90 |
96 |
97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/app/(admin)/results/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getUserForms } from '@/app/actions/getUserForms' 3 | import { InferSelectModel } from 'drizzle-orm' 4 | import { forms } from '@/db/schema' 5 | import FormsPicker from './FormsPicker' 6 | import ResultsDisplay from './ResultsDisplay' 7 | 8 | type Props = {} 9 | 10 | const page = async ({ searchParams }: { 11 | searchParams: { 12 | [key: string]: string | string[] | undefined 13 | } 14 | }) => { 15 | const userForms: Array> = await getUserForms(); 16 | 17 | if (!userForms?.length || userForms.length === 0) { 18 | return ( 19 |
No forms found
20 | ) 21 | } 22 | 23 | const selectOptions = userForms.map((form) => { 24 | return { 25 | label: form.name, 26 | value: form.id 27 | } 28 | }) 29 | 30 | return ( 31 |
32 | 33 | 35 |
36 | ) 37 | } 38 | 39 | export default page -------------------------------------------------------------------------------- /src/app/(admin)/settings/ManageSubscription.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import React from 'react' 3 | import { useRouter } from "next/navigation"; 4 | import { Button } from '@/components/ui/button'; 5 | 6 | const ManageSubscription = () => { 7 | const router = useRouter(); 8 | 9 | const redirectToCustomerPortal = async () => { 10 | try { 11 | const response = await fetch('/api/stripe/create-portal', { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | }); 17 | const { url } = await response.json(); 18 | 19 | router.push(url.url); 20 | } 21 | catch (error) { 22 | console.error('Error redirecting to customer portal', error); 23 | } 24 | } 25 | 26 | 27 | return ( 28 | 29 | ) 30 | } 31 | 32 | export default ManageSubscription -------------------------------------------------------------------------------- /src/app/(admin)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { auth, signIn } from '@/auth'; 3 | import ManageSubscription from './ManageSubscription'; 4 | import { db } from '@/db'; 5 | import { users } from '@/db/schema'; 6 | import { eq } from 'drizzle-orm'; 7 | 8 | type Props = {} 9 | 10 | const page = async (props: Props) => { 11 | 12 | const session = await auth(); 13 | 14 | if (!session || !session.user || !session.user.id) { 15 | signIn(); 16 | return null; 17 | } 18 | 19 | const user = await db.query.users.findFirst({ 20 | where: eq(users.id, session.user.id) 21 | }) 22 | 23 | const plan = user?.subscribed ? 'premium' : 'free'; 24 | 25 | return ( 26 |
27 |

Subscription Details

You currently are on a {plan} plan

28 | ) 29 | } 30 | 31 | export default page -------------------------------------------------------------------------------- /src/app/(admin)/view-forms/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FormsList from '@/app/forms/FormsList' 3 | import { getUserForms } from '@/app/actions/getUserForms' 4 | import { InferSelectModel } from 'drizzle-orm' 5 | import { forms as dbForms } from "@/db/schema"; 6 | 7 | type Props = {} 8 | 9 | const page = async (props: Props) => { 10 | const forms: InferSelectModel[] = await getUserForms(); 11 | 12 | return ( 13 | <> 14 | 15 | ) 16 | } 17 | 18 | export default page -------------------------------------------------------------------------------- /src/app/actions/generateForm.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { z } from "zod"; 5 | 6 | import { saveForm } from "./mutateForm"; 7 | 8 | export async function generateForm( 9 | prevState: { 10 | message: string; 11 | }, 12 | formData: FormData 13 | ) { 14 | const schema = z.object({ 15 | description: z.string().min(1), 16 | }); 17 | const parse = schema.safeParse({ 18 | description: formData.get("description"), 19 | }); 20 | 21 | if (!parse.success) { 22 | console.log(parse.error); 23 | return { 24 | message: "Failed to parse data", 25 | }; 26 | } 27 | 28 | if (!process.env.OPENAI_API_KEY) { 29 | return { 30 | message: "No OpenAI API key found", 31 | }; 32 | } 33 | 34 | const data = parse.data; 35 | const promptExplanation = 36 | "Based on the description, generate a survey object with 3 fields: name(string) for the form, description(string) of the form and a questions array where every element has 2 fields: text and the fieldType and fieldType can be of these options RadioGroup, Select, Input, Textarea, Switch; and return it in json format. For RadioGroup, and Select types also return fieldOptions array with text and value fields. For example, for RadioGroup, and Select types, the field options array can be [{text: 'Yes', value: 'yes'}, {text: 'No', value: 'no'}] and for Input, Textarea, and Switch types, the field options array can be empty. For example, for Input, Textarea, and Switch types, the field options array can be []"; 37 | 38 | try { 39 | const response = await fetch("https://api.openai.com/v1/chat/completions", { 40 | headers: { 41 | "Content-Type": "application/json", 42 | Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ""}`, 43 | }, 44 | method: "POST", 45 | body: JSON.stringify({ 46 | model: "gpt-3.5-turbo", 47 | messages: [ 48 | { 49 | role: "system", 50 | content: `${data.description} ${promptExplanation}`, 51 | }, 52 | ], 53 | }), 54 | }); 55 | const json = await response.json(); 56 | 57 | const responseObj = JSON.parse(json.choices[0].message.content); 58 | 59 | const dbFormId = await saveForm({ 60 | name: responseObj.name, 61 | description: responseObj.description, 62 | questions: responseObj.questions, 63 | }); 64 | 65 | revalidatePath("/"); 66 | return { 67 | message: "success", 68 | data: { formId: dbFormId }, 69 | }; 70 | } catch (e) { 71 | console.log(e); 72 | return { 73 | message: "Failed to create form", 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/actions/getUserForms.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { db } from "@/db"; 3 | import { eq } from "drizzle-orm"; 4 | import { forms } from "@/db/schema"; 5 | import { auth } from "@/auth"; 6 | 7 | export async function getUserForms() { 8 | const session = await auth(); 9 | const userId = session?.user?.id; 10 | if (!userId) { 11 | return []; 12 | } 13 | 14 | const userForms = await db.query.forms.findMany({ 15 | where: eq(forms.userId, userId), 16 | }); 17 | return userForms; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/actions/mutateForm.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { forms, questions as dbQuestions, fieldOptions } from "@/db/schema"; 5 | import { auth } from "@/auth"; 6 | import { InferInsertModel, eq } from "drizzle-orm"; 7 | 8 | type Form = InferInsertModel; 9 | type Question = InferInsertModel; 10 | type FieldOption = InferInsertModel; 11 | 12 | interface SaveFormData extends Form { 13 | questions: Array; 14 | } 15 | 16 | export async function saveForm(data: SaveFormData) { 17 | const { name, description, questions } = data; 18 | const session = await auth(); 19 | const userId = session?.user?.id; 20 | 21 | const newForm = await db 22 | .insert(forms) 23 | .values({ 24 | name, 25 | description, 26 | userId, 27 | published: false, 28 | }) 29 | .returning({ insertedId: forms.id }); 30 | const formId = newForm[0].insertedId; 31 | 32 | // TODO: add questions and options 33 | const newQuestions = data.questions.map((question) => { 34 | return { 35 | text: question.text, 36 | fieldType: question.fieldType, 37 | fieldOptions: question.fieldOptions, 38 | formId, 39 | }; 40 | }); 41 | 42 | await db.transaction(async (tx) => { 43 | for (const question of newQuestions) { 44 | const [{ questionId }] = await tx 45 | .insert(dbQuestions) 46 | .values(question) 47 | .returning({ questionId: dbQuestions.id }); 48 | if (question.fieldOptions && question.fieldOptions.length > 0) { 49 | await tx.insert(fieldOptions).values( 50 | question.fieldOptions.map((option) => ({ 51 | text: option.text, 52 | value: option.value, 53 | questionId, 54 | })) 55 | ); 56 | } 57 | } 58 | }); 59 | 60 | return formId; 61 | } 62 | 63 | export async function publishForm(formId: number) { 64 | await db.update(forms).set({ published: true }).where(eq(forms.id, formId)); 65 | } 66 | -------------------------------------------------------------------------------- /src/app/actions/navigateToForm.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | export async function navigate(id: number) { 6 | redirect(`/forms/edit/${id}`); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/actions/userSubscriptions.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { users } from "@/db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | 5 | export async function createSubscription({ 6 | stripeCustomerId, 7 | }: { 8 | stripeCustomerId: string; 9 | }) { 10 | await db 11 | .update(users) 12 | .set({ 13 | subscribed: true, 14 | }) 15 | .where( 16 | eq( 17 | users.stripeCustomerId, 18 | stripeCustomerId 19 | ) 20 | ); 21 | } 22 | 23 | export async function deleteSubscription({ 24 | stripeCustomerId, 25 | }: { 26 | stripeCustomerId: string; 27 | }) { 28 | await db 29 | .update(users) 30 | .set({ 31 | subscribed: false, 32 | }) 33 | .where( 34 | eq( 35 | users.stripeCustomerId, 36 | stripeCustomerId 37 | ) 38 | ); 39 | } 40 | 41 | export async function getUserSubscription({ 42 | userId, 43 | }: { 44 | userId: string; 45 | }) { 46 | const user = 47 | await db.query.users.findFirst({ 48 | where: eq(users.id, userId), 49 | }); 50 | 51 | return user?.subscribed; 52 | } 53 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth"; 2 | -------------------------------------------------------------------------------- /src/app/api/form/new/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { 3 | forms, 4 | formSubmissions, 5 | answers as dbAnswers, 6 | } from "@/db/schema"; 7 | 8 | export async function POST( 9 | request: Request 10 | ): Promise { 11 | const data = await request.json(); 12 | 13 | const newFormSubmission = await db 14 | .insert(formSubmissions) 15 | .values({ 16 | formId: data.formId, 17 | }) 18 | .returning({ 19 | insertedId: formSubmissions.id, 20 | }); 21 | const [{ insertedId }] = 22 | newFormSubmission; 23 | 24 | await db.transaction(async (tx) => { 25 | for (const answer of data.answers) { 26 | const [{ answerId }] = await tx 27 | .insert(dbAnswers) 28 | .values({ 29 | formSubmissionId: insertedId, 30 | ...answer, 31 | }) 32 | .returning({ 33 | answerId: dbAnswers.id, 34 | }); 35 | } 36 | }); 37 | 38 | return Response.json( 39 | { formSubmissionsId: insertedId }, 40 | { status: 200 } 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/api/stripe/checkout-session/route.ts: -------------------------------------------------------------------------------- 1 | import { stripe } from "@/lib/stripe"; 2 | import { auth } from "@/auth"; 3 | import { db } from "@/db"; 4 | import { eq } from "drizzle-orm"; 5 | import { users } from "@/db/schema"; 6 | 7 | export async function POST( 8 | req: Request 9 | ) { 10 | const { price, quantity = 1 } = 11 | await req.json(); 12 | const userSession = await auth(); 13 | const userId = userSession?.user?.id; 14 | const userEmail = 15 | userSession?.user?.email; 16 | 17 | if (!userId) { 18 | return new Response( 19 | JSON.stringify({ 20 | error: "Unauthorized", 21 | }), 22 | { 23 | status: 401, 24 | } 25 | ); 26 | } 27 | 28 | const user = 29 | await db.query.users.findFirst({ 30 | where: eq(users.id, userId), 31 | }); 32 | let customer; 33 | 34 | if (user?.stripeCustomerId) { 35 | customer = { 36 | id: user.stripeCustomerId, 37 | }; 38 | } else { 39 | const customerData: { 40 | metadata: { 41 | dbId: string; 42 | }; 43 | } = { 44 | metadata: { 45 | dbId: userId, 46 | }, 47 | }; 48 | 49 | const response = 50 | await stripe.customers.create( 51 | customerData 52 | ); 53 | 54 | customer = { id: response.id }; 55 | 56 | await db 57 | .update(users) 58 | .set({ 59 | stripeCustomerId: customer.id, 60 | }) 61 | .where(eq(users.id, userId)); 62 | } 63 | 64 | const baseUrl = 65 | process.env.NEXT_PUBLIC_BASE_URL || 66 | "http://localhost:3000"; 67 | 68 | try { 69 | const session = 70 | await stripe.checkout.sessions.create( 71 | { 72 | success_url: `${baseUrl}/payment/success`, 73 | customer: customer.id, 74 | payment_method_types: [ 75 | "card", 76 | ], 77 | line_items: [ 78 | { 79 | price, 80 | quantity, 81 | }, 82 | ], 83 | mode: "subscription", 84 | } 85 | ); 86 | 87 | if (session) { 88 | return new Response( 89 | JSON.stringify({ 90 | sessionId: session.id, 91 | }), 92 | { 93 | status: 200, 94 | } 95 | ); 96 | } else { 97 | return new Response( 98 | JSON.stringify({ 99 | error: 100 | "Failed to create session", 101 | }), 102 | { 103 | status: 500, 104 | } 105 | ); 106 | } 107 | } catch (error) { 108 | console.error( 109 | "Error creating checkout session", 110 | error 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/app/api/stripe/create-portal/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import { db } from "@/db"; 3 | import { users } from "@/db/schema"; 4 | import { stripe } from "@/lib/stripe"; 5 | import { eq } from "drizzle-orm"; 6 | 7 | export async function POST( 8 | req: Request 9 | ) { 10 | const session = await auth(); 11 | const userId = session?.user?.id; 12 | 13 | if (!userId) { 14 | return new Response( 15 | JSON.stringify({ 16 | error: "Unauthorized", 17 | }), 18 | { 19 | status: 401, 20 | } 21 | ); 22 | } 23 | 24 | const user = 25 | await db.query.users.findFirst({ 26 | where: eq(users.id, userId), 27 | }); 28 | 29 | if (!user) { 30 | return new Response( 31 | JSON.stringify({ 32 | error: "User not found", 33 | }), 34 | { 35 | status: 404, 36 | } 37 | ); 38 | } 39 | 40 | let customer; 41 | if (user?.stripeCustomerId) { 42 | customer = { 43 | id: user.stripeCustomerId, 44 | }; 45 | } else { 46 | const customerData: { 47 | metadata: { 48 | dbId: string; 49 | }; 50 | } = { 51 | metadata: { 52 | dbId: userId, 53 | }, 54 | }; 55 | const response = 56 | await stripe.customers.create( 57 | customerData 58 | ); 59 | 60 | customer = { id: response.id }; 61 | } 62 | const baseUrl = 63 | process.env.NEXT_PUBLIC_BASE_URL || 64 | "http://localhost:3000"; 65 | const url = 66 | await stripe.billingPortal.sessions.create( 67 | { 68 | customer: customer.id, 69 | return_url: `${baseUrl}/settings`, 70 | } 71 | ); 72 | 73 | return new Response( 74 | JSON.stringify({ url }), 75 | { 76 | status: 200, 77 | } 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/app/api/stripe/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { stripe } from "@/lib/stripe"; 3 | import { 4 | createSubscription, 5 | deleteSubscription, 6 | } from "@/app/actions/userSubscriptions"; 7 | 8 | const relevantEvents = new Set([ 9 | "checkout.session.completed", 10 | "customer.subscription.updated", 11 | "customer.subscription.deleted", 12 | "customer.subscription.created", 13 | ]); 14 | 15 | export async function POST( 16 | req: Request 17 | ) { 18 | const body = await req.text(); 19 | const sig = req.headers.get( 20 | "stripe-signature" 21 | ) as string; 22 | if ( 23 | !process.env.STRIPE_WEBHOOK_SERCRET 24 | ) { 25 | throw new Error( 26 | "STRIPE_WEBHOOK_SECRET is not set" 27 | ); 28 | } 29 | 30 | if (!sig) return; 31 | 32 | const event = 33 | stripe.webhooks.constructEvent( 34 | body, 35 | sig, 36 | process.env.STRIPE_WEBHOOK_SERCRET 37 | ); 38 | 39 | const data = event.data 40 | .object as Stripe.Subscription; 41 | 42 | if (relevantEvents.has(event.type)) { 43 | switch (event.type) { 44 | case "customer.subscription.created": { 45 | await createSubscription({ 46 | stripeCustomerId: 47 | data.customer as string, 48 | }); 49 | break; 50 | } 51 | case "customer.subscription.deleted": { 52 | await deleteSubscription({ 53 | stripeCustomerId: 54 | data.customer as string, 55 | }); 56 | break; 57 | } 58 | default: { 59 | break; 60 | } 61 | } 62 | } 63 | 64 | return new Response( 65 | JSON.stringify({ received: true }), 66 | { 67 | status: 200, 68 | } 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/judygab/ai-form-builder-tutorial/365b5dc28c5ff2845480fb59a74f33387424abe1/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/form-generator/UserSubscriptionWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { auth } from '@/auth' 3 | import { getUserSubscription } from '@/app/actions/userSubscriptions' 4 | import { Button } from '@/components/ui/button' 5 | import { db } from '@/db' 6 | import { users, forms } from '@/db/schema' 7 | import { eq } from 'drizzle-orm' 8 | import { MAX_FREE_FROMS } from '@/lib/utils' 9 | import { Lock } from "lucide-react"; 10 | 11 | type Props = { 12 | children: React.ReactNode 13 | } 14 | 15 | const UserSubscriptionWrapper = async ({ children }: Props) => { 16 | const session = await auth(); 17 | const userId = session?.user?.id; 18 | if (!userId) { 19 | return null; 20 | } 21 | const subscription = await getUserSubscription({ userId }); 22 | const userForms = await db.query.forms.findMany({ 23 | where: eq(forms.userId, userId) 24 | }) 25 | const userFormsCount = userForms.length; 26 | 27 | if (subscription || userFormsCount < MAX_FREE_FROMS) { 28 | return { children }; 29 | } 30 | 31 | return ( 32 | 33 | ) 34 | } 35 | 36 | export default UserSubscriptionWrapper -------------------------------------------------------------------------------- /src/app/form-generator/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useEffect } from 'react' 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | DialogFooter 11 | } from "@/components/ui/dialog" 12 | import { Button } from "@/components/ui/button"; 13 | import { Textarea } from '@/components/ui/textarea'; 14 | 15 | import { generateForm } from '@/actions/generateForm'; 16 | import { useFormState, useFormStatus } from 'react-dom'; 17 | 18 | import { useSession, signIn } from "next-auth/react"; 19 | import { navigate } from '../actions/navigateToForm'; 20 | 21 | import { Plus } from 'lucide-react'; 22 | import {usePlausible} from 'next-plausible' 23 | 24 | 25 | type Props = {} 26 | 27 | const initialState: { 28 | message: string; 29 | data?: any; 30 | } = { 31 | message: "" 32 | } 33 | 34 | export function SubmitButton() { 35 | const { pending } = useFormStatus(); 36 | return ( 37 | 40 | ); 41 | } 42 | 43 | const FormGenerator = (props: Props) => { 44 | const [state, formAction] = useFormState(generateForm, initialState); 45 | const [open, setOpen] = useState(false); 46 | const session = useSession(); 47 | const plausible = usePlausible() 48 | 49 | useEffect(() => { 50 | if (state.message === "success") { 51 | setOpen(false); 52 | navigate(state.data.formId); 53 | } 54 | 55 | }, [state.message]) 56 | 57 | const onFormCreate = () => { 58 | plausible('create-form') 59 | if (session.data?.user) { 60 | setOpen(true); 61 | } else { 62 | signIn(); 63 | } 64 | } 65 | 66 | return ( 67 | 68 | 71 | 72 | 73 | Create New Form 74 | 75 |
76 |
77 |