├── .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 |
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 |
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 |
73 | {header.isPlaceholder
74 | ? null
75 | : flexRender(
76 | header.column.columnDef.header,
77 | header.getContext()
78 | )}
79 | |
80 | ))}
81 |
82 | ))}
83 |
84 |
85 | {table.getRowModel().rows.map(row => (
86 |
87 | {row.getVisibleCells().map(cell => (
88 |
89 | {flexRender(cell.column.columnDef.cell, cell.getContext())}
90 | |
91 | ))}
92 |
93 | ))}
94 |
95 |
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 |
86 | )
87 | }
88 |
89 | export default FormGenerator
--------------------------------------------------------------------------------
/src/app/forms/Form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from 'react'
3 | import { FormSelectModel, QuestionSelectModel, FieldOptionSelectModel } from '@/types/form-types'
4 | import { Form as FormComponent, FormField as ShadcdnFormField, FormItem, FormLabel, FormControl } from "@/components/ui/form";
5 | import { useForm } from "react-hook-form";
6 | import { Button } from '@/components/ui/button';
7 | import FormField from './FormField';
8 | import { publishForm } from '../actions/mutateForm';
9 | import FormPublishSuccess from './FormPublishSuccess';
10 | import { useRouter } from 'next/navigation';
11 |
12 | type Props = {
13 | form: Form,
14 | editMode?: boolean
15 | }
16 |
17 | type QuestionWithOptionsModel = QuestionSelectModel & {
18 | fieldOptions: Array
19 | }
20 |
21 | interface Form extends FormSelectModel {
22 | questions: Array
23 | }
24 |
25 | const Form = (props: Props) => {
26 | const form = useForm();
27 | const router = useRouter();
28 | const { editMode } = props;
29 | const [successDialogOpen, setSuccessDialogOpen] = useState(false);
30 |
31 | const handleDialogChange = (open: boolean) => {
32 | setSuccessDialogOpen(open);
33 | }
34 |
35 | const onSubmit = async (data: any) => {
36 | console.log(data);
37 | if (editMode) {
38 | await publishForm(props.form.id);
39 | setSuccessDialogOpen(true);
40 | } else {
41 | let answers = [];
42 | for (const [questionId, value] of Object.entries(data)) {
43 | const id = parseInt(questionId.replace('question_', ''));
44 | let fieldOptionsId = null;
45 | let textValue = null;
46 |
47 | if (typeof value == "string" && value.includes('answerId_')) {
48 | fieldOptionsId = parseInt(value.replace('answerId_', ''));
49 | } else {
50 | textValue = value as string;
51 | }
52 |
53 | answers.push({
54 | questionId: id,
55 | fieldOptionsId,
56 | value: textValue
57 | })
58 | }
59 |
60 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
61 |
62 | const response = await fetch(`${baseUrl}/api/form/new`, {
63 | method: 'POST',
64 | headers: {
65 | 'Content-Type': 'application/json'
66 | },
67 | body: JSON.stringify({ formId: props.form.id, answers })
68 | });
69 | if (response.status === 200) {
70 | router.push(`/forms/${props.form.id}/success`);
71 | } else {
72 | console.error('Error submitting form');
73 | alert('Error submitting form. Please try again later');
74 | }
75 | }
76 | }
77 |
78 |
79 | return (
80 |
81 |
{props.form.name}
82 | {props.form.description}
83 |
84 |
104 |
105 |
106 |
107 | )
108 | }
109 |
110 | export default Form
--------------------------------------------------------------------------------
/src/app/forms/FormField.tsx:
--------------------------------------------------------------------------------
1 | import React, { ChangeEvent } from 'react'
2 | import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
3 | import {
4 | Select,
5 | SelectContent,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue,
9 | } from "@/components/ui/select"
10 | import { FormControl, FormLabel } from '@/components/ui/form';
11 | import { Switch } from '@/components/ui/switch';
12 | import { Textarea } from '@/components/ui/textarea';
13 | import { Input } from '@/components/ui/input';
14 | import { QuestionSelectModel } from '@/types/form-types';
15 | import { FieldOptionSelectModel } from '@/types/form-types';
16 | import { Label } from '@/components/ui/label';
17 |
18 | type Props = {
19 | element: QuestionSelectModel & {
20 | fieldOptions: Array
21 | }
22 | value: string,
23 | onChange: (value?: string | ChangeEvent) => void
24 | }
25 |
26 | const FormField = ({ element, value, onChange }: Props) => {
27 | if (!element) return null;
28 |
29 | const components = {
30 | Input: () => ,
31 | Switch: () => ,
32 | Textarea: () => ,
33 | Select: () => (
34 |
44 | ),
45 | RadioGroup: () => (
46 |
47 | {element.fieldOptions.map((option, index) => (
48 |
49 |
50 | {option.text}
51 |
52 |
53 |
54 | ))}
55 |
56 | )
57 | }
58 |
59 | return element.fieldType && components[element.fieldType] ? components[element.fieldType]() : null;
60 | }
61 |
62 | export default FormField
--------------------------------------------------------------------------------
/src/app/forms/FormPublishSuccess.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from 'react'
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogTitle,
7 | DialogHeader,
8 | DialogDescription
9 | } from "@/components/ui/dialog";
10 | import { Button } from '@/components/ui/button';
11 | import { Link2Icon } from "@radix-ui/react-icons";
12 |
13 | type Props = {
14 | formId: number,
15 | open: boolean,
16 | onOpenChange: (open: boolean) => void
17 | }
18 |
19 | const FormPublishSuccess = (props: Props) => {
20 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
21 |
22 | const copyToClipboard = () => {
23 | navigator.clipboard.writeText(baseUrl + '/forms/' + props.formId)
24 | .then(() => alert("Copied to clipboard"))
25 | .catch((error) => alert("Failed to copy to clipboard"));
26 | }
27 |
28 | return (
29 |
51 | )
52 | }
53 |
54 | export default FormPublishSuccess
--------------------------------------------------------------------------------
/src/app/forms/FormsList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { forms } from '@/db/schema';
3 | import { InferSelectModel } from 'drizzle-orm';
4 | import {
5 | Card,
6 | CardContent,
7 | CardDescription,
8 | CardFooter,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card"
12 | import { Button } from '@/components/ui/button';
13 | import Link from 'next/link';
14 |
15 | type Form = InferSelectModel;
16 |
17 | type Props = {
18 | forms: Form[]
19 | }
20 |
21 | const FormsList = (props: Props) => {
22 | return (
23 | {props.forms.map((form: Form) => (
24 |
25 |
26 | {form.name}
27 |
28 |
29 | {form.description}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | ))}
38 | )
39 | }
40 |
41 | export default FormsList
--------------------------------------------------------------------------------
/src/app/forms/[formId]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { db } from '@/db';
3 | import { forms } from '@/db/schema';
4 | import { eq } from 'drizzle-orm';
5 | import { auth } from '@/auth';
6 | import Form from '../Form';
7 |
8 |
9 | const page = async ({ params }: {
10 | params: {
11 | formId: string
12 | }
13 | }) => {
14 | const formId = params.formId;
15 |
16 | if (!formId) {
17 | return Form not found
18 | };
19 |
20 | const form = await db.query.forms.findFirst({
21 | where: eq(forms.id, parseInt(formId)),
22 | with: {
23 | questions: {
24 | with: {
25 | fieldOptions: true
26 | }
27 | }
28 | }
29 | })
30 |
31 | if (!form) {
32 | return Form not found
33 | }
34 |
35 | return (
36 |
37 | )
38 | }
39 | export default page;
--------------------------------------------------------------------------------
/src/app/forms/[formId]/success/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
3 |
4 |
5 | const page = () => {
6 | return (
7 |
8 | Success
9 |
10 | Your answers were recorded successfully. Thank you for submitting the form!
11 |
12 | )
13 | }
14 |
15 | export default page
--------------------------------------------------------------------------------
/src/app/forms/edit/[formId]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { db } from '@/db';
3 | import { forms } from '@/db/schema';
4 | import { eq } from 'drizzle-orm';
5 | import { auth } from '@/auth';
6 | import Form from '../../Form';
7 |
8 |
9 | const page = async ({ params }: {
10 | params: {
11 | formId: string
12 | }
13 | }) => {
14 | const formId = params.formId;
15 |
16 | if (!formId) {
17 | return Form not found
18 | };
19 |
20 | const session = await auth();
21 | const userId = session?.user?.id;
22 | const form = await db.query.forms.findFirst({
23 | where: eq(forms.id, parseInt(formId)),
24 | with: {
25 | questions: {
26 | with: {
27 | fieldOptions: true
28 | }
29 | }
30 | }
31 | })
32 |
33 | if (userId !== form?.userId) {
34 | return You are not authorized to view this page
35 | }
36 |
37 | if (!form) {
38 | return Form not found
39 | }
40 |
41 | return (
42 |
43 | )
44 | }
45 | export default page;
--------------------------------------------------------------------------------
/src/app/forms/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const FormEditLayout = ({
4 | children
5 | }: {
6 | children: React.ReactNode
7 | }) => {
8 | return (
9 | {children}
10 | )
11 | }
12 |
13 | export default FormEditLayout
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 |
6 | @layer base {
7 | :root {
8 | --background: 0 0% 100%;
9 | --foreground: 224 71.4% 4.1%;
10 | --card: 0 0% 100%;
11 | --card-foreground: 224 71.4% 4.1%;
12 | --popover: 0 0% 100%;
13 | --popover-foreground: 224 71.4% 4.1%;
14 | --primary: 262.1 83.3% 57.8%;
15 | --primary-foreground: 210 20% 98%;
16 | --secondary: 220 14.3% 95.9%;
17 | --secondary-foreground: 220.9 39.3% 11%;
18 | --muted: 220 14.3% 95.9%;
19 | --muted-foreground: 220 8.9% 46.1%;
20 | --accent: 220 14.3% 95.9%;
21 | --accent-foreground: 220.9 39.3% 11%;
22 | --destructive: 0 84.2% 60.2%;
23 | --destructive-foreground: 210 20% 98%;
24 | --border: 220 13% 91%;
25 | --input: 220 13% 91%;
26 | --ring: 262.1 83.3% 57.8%;
27 | --radius: 0.5rem;
28 | }
29 |
30 | .dark {
31 | --background: 224 71.4% 4.1%;
32 | --foreground: 210 20% 98%;
33 | --card: 224 71.4% 4.1%;
34 | --card-foreground: 210 20% 98%;
35 | --popover: 224 71.4% 4.1%;
36 | --popover-foreground: 210 20% 98%;
37 | --primary: 263.4 70% 50.4%;
38 | --primary-foreground: 210 20% 98%;
39 | --secondary: 215 27.9% 16.9%;
40 | --secondary-foreground: 210 20% 98%;
41 | --muted: 215 27.9% 16.9%;
42 | --muted-foreground: 217.9 10.6% 64.9%;
43 | --accent: 215 27.9% 16.9%;
44 | --accent-foreground: 210 20% 98%;
45 | --destructive: 0 62.8% 30.6%;
46 | --destructive-foreground: 210 20% 98%;
47 | --border: 215 27.9% 16.9%;
48 | --input: 215 27.9% 16.9%;
49 | --ring: 263.4 70% 50.4%;
50 | }
51 | }
52 |
53 |
54 | @layer base {
55 | * {
56 | @apply border-border;
57 | }
58 | body {
59 | @apply bg-background text-foreground;
60 | }
61 | }
--------------------------------------------------------------------------------
/src/app/landing-page/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from 'next/image'
3 | import FormGenerator from '../form-generator'
4 | import PlausibleProvider from 'next-plausible'
5 |
6 | type Props = {}
7 |
8 | const LandingPage = (props: Props) => {
9 | return (
10 |
11 |
12 | Create your forms
in seconds not hours
13 |
14 | Generate, publish and share your form right away with AI. Dive into insightful results, charts and analytics.
15 |
16 |
17 |
18 |
19 |
20 | How It Works
21 |
63 |
64 |
65 | )
66 | }
67 |
68 | export default LandingPage
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import './globals.css'
4 |
5 | import PlausibleProvider from 'next-plausible'
6 |
7 | const inter = Inter({ subsets: ['latin'] })
8 |
9 | export const metadata: Metadata = {
10 | title: 'Create Next App',
11 | description: 'Generated by create next app',
12 | }
13 |
14 | export default function RootLayout({
15 | children,
16 | }: {
17 | children: React.ReactNode
18 | }) {
19 | return (
20 |
21 |
22 |
23 |
24 | {children}
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Header from "@/components/ui/header";
2 | import { SessionProvider } from 'next-auth/react';
3 | import LandingPage from './landing-page';
4 |
5 | export default function Home() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/payment/success/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
3 | import Link from 'next/link'
4 |
5 |
6 | const page = () => {
7 | return (
8 |
9 | Success
10 |
11 | Your account has been updated. Go to the dashboard to create more forms
12 |
13 | )
14 | }
15 |
16 | export default page
--------------------------------------------------------------------------------
/src/app/subscription/SubscribeBtn.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { getStripe } from '@/lib/stripe-client';
3 | import React from 'react'
4 | import { useRouter } from 'next/navigation';
5 |
6 | type Props = {
7 | userId?: string,
8 | price: string,
9 | }
10 |
11 | const SubscribeBtn = ({ userId, price }: Props) => {
12 | const router = useRouter();
13 |
14 | const handleCheckout = async (price: string) => {
15 | if (!userId) {
16 | router.push('/login');
17 | }
18 |
19 | try {
20 | const { sessionId } = await fetch('/api/stripe/checkout-session', {
21 | method: 'POST',
22 | headers: {
23 | 'Content-Type': 'application/json',
24 | },
25 | body: JSON.stringify({ price }),
26 | }).then((res) => res.json());
27 |
28 | console.log('sessionId:', sessionId);
29 | const stripe = await getStripe();
30 | stripe?.redirectToCheckout({ sessionId });
31 |
32 | } catch (error) {
33 | console.error('Error:', error)
34 | }
35 | }
36 |
37 | return (
38 |
39 | )
40 | }
41 |
42 | export default SubscribeBtn
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth, { type Session, type User } from "next-auth";
2 | import GoogleProvider from "next-auth/providers/google";
3 | import { DrizzleAdapter } from "@auth/drizzle-adapter";
4 | import { db } from "./db/index";
5 |
6 | export const {
7 | handlers: { GET, POST },
8 | auth,
9 | signIn,
10 | signOut,
11 | } = NextAuth({
12 | adapter: DrizzleAdapter(db),
13 | providers: [
14 | GoogleProvider({
15 | clientId: process.env.GOOGLE_CLIENT_ID,
16 | clientSecret: process.env.GOOGLE_CLIENT_SECRET,
17 | }),
18 | ],
19 | callbacks: {
20 | async session({ session, user }: { session: Session; user?: User }) {
21 | if (user && session?.user) {
22 | session.user.id = user.id;
23 | }
24 | return session;
25 | },
26 | },
27 | });
28 |
--------------------------------------------------------------------------------
/src/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Library,
3 | LineChart,
4 | PieChart,
5 | Settings2,
6 | Settings,
7 | UserRoundCog,
8 | List,
9 | type IconNode as LucideIcon
10 | } from "lucide-react";
11 |
12 | export type Icon = LucideIcon;
13 |
14 | export const Icons = {
15 | library: Library,
16 | lineChart: LineChart,
17 | pieChart: PieChart,
18 | settings2: Settings2,
19 | settings: Settings,
20 | userRoundCog: UserRoundCog,
21 | list: List,
22 | }
--------------------------------------------------------------------------------
/src/components/navigation/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from 'react'
3 | import Link from 'next/link';
4 | import { usePathname } from 'next/navigation';
5 | import { SidebarNavItem } from '@/types/nav-types';
6 |
7 | import { cn } from '@/lib/utils';
8 | import { Icons } from '../icons';
9 |
10 | interface DashboardNavProps {
11 | items: SidebarNavItem[];
12 | }
13 |
14 | const DashboardNav = ({ items }: DashboardNavProps) => {
15 | const path = usePathname();
16 |
17 | if (!items?.length) return null;
18 |
19 | return (
20 |
42 | )
43 | }
44 |
45 | export default DashboardNav
--------------------------------------------------------------------------------
/src/components/navigation/updgradeAccBtn.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import { getUserForms } from '@/app/actions/getUserForms'
4 | import { MAX_FREE_FROMS } from '@/lib/utils'
5 | import ProgressBar from '../progressBar'
6 | import SubscribeBtn from '@/app/subscription/SubscribeBtn'
7 | import { auth } from '@/auth'
8 | import { getUserSubscription } from '@/app/actions/userSubscriptions'
9 |
10 | type Props = {}
11 |
12 | const UpdgradeAccBtn = async (props: Props) => {
13 | const session = await auth();
14 | const userId = session?.user?.id;
15 | if (!userId) {
16 | return null;
17 | }
18 | const subscription = await getUserSubscription({ userId });
19 | if (subscription) {
20 | return null;
21 | }
22 | const forms = await getUserForms();
23 | const formCount = forms.length;
24 | const percent = (formCount / MAX_FREE_FROMS) * 100;
25 |
26 | return (
27 |
28 |
29 |
{formCount} out of {MAX_FREE_FROMS} forms generated.
30 |
31 |
32 | {' '} for unlimited forms.
33 |
34 |
35 | )
36 | }
37 |
38 | export default UpdgradeAccBtn
--------------------------------------------------------------------------------
/src/components/progressBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type Props = {
4 | value: number
5 | }
6 |
7 | const ProgressBar = (props: Props) => {
8 | return (
9 |
12 | )
13 | }
14 |
15 | export default ProgressBar
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/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/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/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/header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { auth, signIn, signOut } from "@/auth";
3 | import { Button } from './button';
4 | import Image from 'next/image';
5 | import Link from 'next/link';
6 |
7 | type Props = {}
8 |
9 | function SignOut() {
10 | return (
11 |
17 | )
18 | }
19 |
20 | const Header = async (props: Props) => {
21 | const session = await auth();
22 |
23 | return (
24 |
46 | )
47 | }
48 |
49 | export default Header
--------------------------------------------------------------------------------
/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/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/postgres-js";
2 | import postgres from "postgres";
3 | import * as schema from "./schema";
4 |
5 | const connectionString =
6 | process.env.DATABASE_URL ||
7 | "postgres://postgres:postgres@localhost:5432/postgres";
8 |
9 | const client = postgres(connectionString);
10 | export const db = drizzle(client, { schema });
11 |
--------------------------------------------------------------------------------
/src/db/schema.ts:
--------------------------------------------------------------------------------
1 | import {
2 | timestamp,
3 | pgTable,
4 | text,
5 | primaryKey,
6 | integer,
7 | serial,
8 | boolean,
9 | pgEnum,
10 | } from "drizzle-orm/pg-core";
11 | import type { AdapterAccount } from "@auth/core/adapters";
12 | import { relations } from "drizzle-orm";
13 |
14 | export const formElements = pgEnum("field_type", [
15 | "RadioGroup",
16 | "Select",
17 | "Input",
18 | "Textarea",
19 | "Switch",
20 | ]);
21 |
22 | export const users = pgTable("user", {
23 | id: text("id").notNull().primaryKey(),
24 | name: text("name"),
25 | email: text("email").notNull(),
26 | emailVerified: timestamp(
27 | "emailVerified",
28 | { mode: "date" }
29 | ),
30 | image: text("image"),
31 | stripeCustomerId: text(
32 | "stripe_customer_id"
33 | ),
34 | subscribed: boolean("subscribed"),
35 | });
36 |
37 | export const accounts = pgTable(
38 | "account",
39 | {
40 | userId: text("userId")
41 | .notNull()
42 | .references(() => users.id, { onDelete: "cascade" }),
43 | type: text("type").$type().notNull(),
44 | provider: text("provider").notNull(),
45 | providerAccountId: text("providerAccountId").notNull(),
46 | refresh_token: text("refresh_token"),
47 | access_token: text("access_token"),
48 | expires_at: integer("expires_at"),
49 | token_type: text("token_type"),
50 | scope: text("scope"),
51 | id_token: text("id_token"),
52 | session_state: text("session_state"),
53 | },
54 | (account) => ({
55 | compoundKey: primaryKey({
56 | columns: [account.provider, account.providerAccountId],
57 | }),
58 | })
59 | );
60 |
61 | export const sessions = pgTable("session", {
62 | sessionToken: text("sessionToken").notNull().primaryKey(),
63 | userId: text("userId")
64 | .notNull()
65 | .references(() => users.id, { onDelete: "cascade" }),
66 | expires: timestamp("expires", { mode: "date" }).notNull(),
67 | });
68 |
69 | export const verificationTokens = pgTable(
70 | "verificationToken",
71 | {
72 | identifier: text("identifier").notNull(),
73 | token: text("token").notNull(),
74 | expires: timestamp("expires", { mode: "date" }).notNull(),
75 | },
76 | (vt) => ({
77 | compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
78 | })
79 | );
80 |
81 | export const forms = pgTable("forms", {
82 | id: serial("id").primaryKey(),
83 | name: text("name"),
84 | description: text("description"),
85 | userId: text("user_id"),
86 | published: boolean("published"),
87 | });
88 |
89 | export const formsRelations = relations(
90 | forms,
91 | ({ many, one }) => ({
92 | questions: many(questions),
93 | user: one(users, {
94 | fields: [forms.userId],
95 | references: [users.id],
96 | }),
97 | submissions: many(formSubmissions),
98 | })
99 | );
100 |
101 | export const questions = pgTable("questions", {
102 | id: serial("id").primaryKey(),
103 | text: text("text"),
104 | fieldType: formElements("field_type"),
105 | formId: integer("form_id"),
106 | });
107 |
108 | export const questionsRelations =
109 | relations(
110 | questions,
111 | ({ one, many }) => ({
112 | form: one(forms, {
113 | fields: [questions.formId],
114 | references: [forms.id],
115 | }),
116 | fieldOptions: many(fieldOptions),
117 | answers: many(answers),
118 | })
119 | );
120 |
121 | export const fieldOptions = pgTable(
122 | "field_options",
123 | {
124 | id: serial("id").primaryKey(),
125 | text: text("text"),
126 | value: text("value"),
127 | questionId: integer("question_id"),
128 | }
129 | );
130 |
131 | export const fieldOptionsRelations =
132 | relations(
133 | fieldOptions,
134 | ({ one }) => ({
135 | question: one(questions, {
136 | fields: [
137 | fieldOptions.questionId,
138 | ],
139 | references: [questions.id],
140 | }),
141 | })
142 | );
143 |
144 | export const answers = pgTable(
145 | "answers",
146 | {
147 | id: serial("id").primaryKey(),
148 | value: text("value"),
149 | questionId: integer("question_id"),
150 | formSubmissionId: integer(
151 | "form_submission_id"
152 | ),
153 | fieldOptionsId: integer(
154 | "field_options_id"
155 | ),
156 | }
157 | );
158 |
159 | export const answersRelations = relations(
160 | answers,
161 | ({ one }) => ({
162 | question: one(questions, {
163 | fields: [answers.questionId],
164 | references: [questions.id],
165 | }),
166 | formSubmission: one(
167 | formSubmissions,
168 | {
169 | fields: [
170 | answers.formSubmissionId,
171 | ],
172 | references: [
173 | formSubmissions.id,
174 | ],
175 | }
176 | ),
177 | fieldOption: one(fieldOptions, {
178 | fields: [answers.fieldOptionsId],
179 | references: [fieldOptions.id],
180 | }),
181 | })
182 | );
183 |
184 | export const formSubmissions = pgTable(
185 | "form_submissions",
186 | {
187 | id: serial("id").primaryKey(),
188 | formId: integer("form_id"),
189 | }
190 | );
191 |
192 | export const formSubmissionsRelations =
193 | relations(
194 | formSubmissions,
195 | ({ one, many }) => ({
196 | form: one(forms, {
197 | fields: [
198 | formSubmissions.formId,
199 | ],
200 | references: [forms.id],
201 | }),
202 | answers: many(answers),
203 | })
204 | );
--------------------------------------------------------------------------------
/src/lib/stripe-client.ts:
--------------------------------------------------------------------------------
1 | import {
2 | loadStripe,
3 | Stripe,
4 | } from "@stripe/stripe-js";
5 |
6 | let stripePromise: Promise;
7 |
8 | export const getStripe = () => {
9 | if (!stripePromise) {
10 | stripePromise = loadStripe(
11 | process.env
12 | .NEXT_PUBLIC_PUBLISHABLE_KEY ??
13 | ""
14 | );
15 | }
16 |
17 | return stripePromise;
18 | };
19 |
--------------------------------------------------------------------------------
/src/lib/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 |
3 | export const stripe = new Stripe(
4 | process.env.STRIPE_SECRET_KEY || " ",
5 | {
6 | apiVersion: "2023-10-16",
7 | typescript: true,
8 | }
9 | );
10 |
--------------------------------------------------------------------------------
/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 |
8 | export const MAX_FREE_FROMS = 3;
9 |
--------------------------------------------------------------------------------
/src/types/form-types.d.ts:
--------------------------------------------------------------------------------
1 | import { InferSelectModel } from "drizzle-orm";
2 | import { forms, questions, fieldOptions } from "@/db/schema";
3 |
4 | export type FormSelectModel = InferSelectModel;
5 | export type QuestionSelectModel = InferSelectModel;
6 | export type FieldOptionSelectModel = InferSelectModel;
7 |
--------------------------------------------------------------------------------
/src/types/nav-types.d.ts:
--------------------------------------------------------------------------------
1 | import { Icons } from "../components/icons";
2 |
3 | type NavLink = {
4 | title: string;
5 | href: string;
6 | disabled?: boolean;
7 | };
8 |
9 | type SidebarNavItem = {
10 | title: string;
11 | disabled?: boolean;
12 | external?: boolean;
13 | icon?: keyof typeof Icons;
14 | } & (
15 | | {
16 | href: string;
17 | items?: never;
18 | }
19 | | {
20 | href?: string;
21 | items: NavLink[];
22 | }
23 | );
24 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*", "./src/app/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------