From a66c8969b43e599ce99f137278f1de767310ab01 Mon Sep 17 00:00:00 2001 From: Yehoshua Sandler Date: Wed, 7 May 2025 16:10:41 -0500 Subject: [PATCH] feat: contact form --- .env.example | 13 + package-lock.json | 115 +++++++- package.json | 4 + src/app/(frontend)/layout.tsx | 2 + src/blocks/ContactForm/component.tsx | 146 +++++++++++ src/blocks/ContactForm/config.ts | 14 + src/blocks/RenderBlocks.tsx | 2 + src/collections/Pages.ts | 3 +- src/components/ContactForm.tsx | 104 ++++++++ src/components/popover-form.tsx | 305 ++++++++++++++++++++++ src/components/project-card.tsx | 4 +- src/components/ui/blur-fade.tsx | 2 +- src/components/ui/input.tsx | 21 ++ src/components/ui/label.tsx | 24 ++ src/components/ui/sonner.tsx | 25 ++ src/components/ui/textarea.tsx | 18 ++ src/payload-types.ts | 376 ++++++++++++++++++++++++++- src/payload.config.ts | 36 +++ 18 files changed, 1206 insertions(+), 8 deletions(-) create mode 100644 src/blocks/ContactForm/component.tsx create mode 100644 src/blocks/ContactForm/config.ts create mode 100644 src/components/ContactForm.tsx create mode 100644 src/components/popover-form.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/.env.example b/.env.example index bacd4f6..95fb000 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,20 @@ DATABASE_URI=postgres://postgres:@127.0.0.1:5432/your-database-name PAYLOAD_SECRET=YOUR_SECRET_HERE + +DOMAIN_NAME=localhost:3000 PORT=3000 + ACCESS_KEY_ID= SECRET_ACCESS_KEY= BUCKET_NAME= S3_ENDPOINT= + +DEFAULT_EMAIL= +SMTP_HOST= +SMTP_USER= +SMTP_PASS= +SMTP_PORT=587 +PASSWORD_RESET_EXPIRATION_IN_MINUTES=30 + + + diff --git a/package-lock.json b/package-lock.json index cbd18c8..91f33b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "@payloadcms/db-postgres": "3.33.0", "@payloadcms/next": "3.33.0", "@payloadcms/payload-cloud": "3.33.0", + "@payloadcms/plugin-form-builder": "3.33.0", "@payloadcms/richtext-lexical": "3.33.0", "@payloadcms/storage-s3": "3.33.0", "@radix-ui/react-avatar": "^1.1.7", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-tooltip": "^1.2.4", "@tailwindcss/postcss": "^4.1.4", @@ -30,8 +32,10 @@ "payload": "3.33.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.56.2", "react-markdown": "^10.1.0", "sharp": "0.32.6", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tw-animate-css": "^1.2.8" }, @@ -3707,6 +3711,21 @@ "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } }, + "node_modules/@payloadcms/plugin-form-builder": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@payloadcms/plugin-form-builder/-/plugin-form-builder-3.33.0.tgz", + "integrity": "sha512-9i5tG144VGtO7zfrIzT5AwYZlgiVjvJ8ud7Ok/iuaYSk6H2owf7vcDY0tAAp5sf6pGrv1brRHPej5hD2K2Posg==", + "license": "MIT", + "dependencies": { + "@payloadcms/ui": "3.33.0", + "escape-html": "^1.0.3" + }, + "peerDependencies": { + "payload": "3.33.0", + "react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020", + "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" + } + }, "node_modules/@payloadcms/richtext-lexical": { "version": "3.33.0", "resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.33.0.tgz", @@ -3818,6 +3837,16 @@ "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } }, + "node_modules/@payloadcms/ui/node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", @@ -3958,6 +3987,70 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.6.tgz", + "integrity": "sha512-S/hv1mTlgcPX2gCTJrWuTjSXf7ER3Zf7zWGtOprxhIIY93Qin3n5VgNA0Ez9AgrK/lEtlYgzLd4f5x6AVar4Yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", + "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", @@ -12309,6 +12402,22 @@ "react": ">=16.13.1" } }, + "node_modules/react-hook-form": { + "version": "7.56.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.2.tgz", + "integrity": "sha512-vpfuHuQMF/L6GpuQ4c3ZDo+pRYxIi40gQqsCmmfUBwm+oqvBhKhwghCuj2o00YCgSfU6bR9KC/xnQGWm3Gr08A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-image-crop": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-10.1.8.tgz", @@ -12992,9 +13101,9 @@ } }, "node_modules/sonner": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", - "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", "license": "MIT", "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", diff --git a/package.json b/package.json index 0c75c9e..ed0c19c 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "@payloadcms/db-postgres": "3.33.0", "@payloadcms/next": "3.33.0", "@payloadcms/payload-cloud": "3.33.0", + "@payloadcms/plugin-form-builder": "3.33.0", "@payloadcms/richtext-lexical": "3.33.0", "@payloadcms/storage-s3": "3.33.0", "@radix-ui/react-avatar": "^1.1.7", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-tooltip": "^1.2.4", "@tailwindcss/postcss": "^4.1.4", @@ -36,8 +38,10 @@ "payload": "3.33.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.56.2", "react-markdown": "^10.1.0", "sharp": "0.32.6", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tw-animate-css": "^1.2.8" }, diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index ccde032..214a079 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -6,6 +6,7 @@ import { Inter as FontSans } from 'next/font/google' import { getPayload } from 'payload' import configPromise from '@payload-config' import Navbar from '@/globals/Nav/component' +import { Toaster } from '@/components/ui/sonner' const fontSans = FontSans({ subsets: ['latin'], @@ -74,6 +75,7 @@ export default function RootLayout({
{children}
+
diff --git a/src/blocks/ContactForm/component.tsx b/src/blocks/ContactForm/component.tsx new file mode 100644 index 0000000..ffea49a --- /dev/null +++ b/src/blocks/ContactForm/component.tsx @@ -0,0 +1,146 @@ +'use client' + +import { FormEvent, useState } from 'react' +import { + PopoverForm, + PopoverFormButton, + PopoverFormCutOutLeftIcon, + PopoverFormCutOutRightIcon, + PopoverFormSeparator, + PopoverFormSuccess, +} from '../../components/popover-form' +import type { Form as ContactFormData, ContactFormBlock as ContactFormProps } from '@/payload-types' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { toast } from 'sonner' + +type Props = { + className?: string +} & ContactFormProps + +type FormState = 'idle' | 'loading' | 'success' + +export const ContactForm = (props: Props) => { + const [formState, setFormState] = useState('idle') + const [open, setOpen] = useState(false) + + const { form } = props + const { title, fields, submitButtonLabel, id } = form as ContactFormData + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (formState !== 'idle') return + setFormState('loading') + + const formData = new FormData(e.currentTarget) + + console.log('...', [...formData.entries()]) + console.log('not spread', Object.entries(formData)) + + const dataToSend = [...formData.entries()].map(([name, value]) => { + return { + field: name, + value, + } + }) + console.log('datatosend', dataToSend) + + try { + const submitResponse = await fetch('/api/form-submissions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + form: id, + submissionData: dataToSend, + }), + }) + console.log(submitResponse) + if (submitResponse.status !== 201) { + setFormState('idle') + toast('There was an issue with your submission. Please try again.') + } else setFormState('success') + } catch (err) { + console.log(err) + setFormState('idle') + toast('There was an issue with your submission. Please try again.') + } + } + + const renderFields = (fields: Record[]) => { + return fields?.map((f) => { + switch (f.blockType) { + case 'text': + return ( +
+ + +
+ ) + case 'textarea': + return ( +
+ +