feat: contact form

This commit is contained in:
Yehoshua Sandler 2025-05-07 16:10:41 -05:00
parent 436a076657
commit a66c8969b4
18 changed files with 1206 additions and 8 deletions

View File

@ -1,7 +1,20 @@
DATABASE_URI=postgres://postgres:<password>@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

115
package-lock.json generated
View File

@ -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",

View File

@ -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"
},

View File

@ -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({
<ThemeProvider attribute="class" defaultTheme="light">
<TooltipProvider delayDuration={0}>
<main>{children}</main>
<Toaster />
<Navbar {...navProps} />
</TooltipProvider>
</ThemeProvider>

View File

@ -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<FormState>('idle')
const [open, setOpen] = useState(false)
const { form } = props
const { title, fields, submitButtonLabel, id } = form as ContactFormData
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
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<string, any>[]) => {
return fields?.map((f) => {
switch (f.blockType) {
case 'text':
return (
<div key={f.name} className={`grid w-[${f.width}] max-w-sm items-center gap-1.5`}>
<Label className="text-muted-foreground" htmlFor={f.name}>
{f.label}
</Label>
<Input type="text" id={f.name} name={f.name} required={f.required} />
</div>
)
case 'textarea':
return (
<div key={f.name} className={`grid w-[${f.width}] max-w-sm items-center gap-1.5`}>
<Label className="text-muted-foreground" htmlFor={f.name}>
{f.label}
</Label>
<Textarea
placeholder="Type your message here."
id={f.name}
name={f.name}
required={f.required}
rows={4}
/>
</div>
)
case 'email':
return (
<div key={f.name} className={`grid w-[${f.width}] max-w-sm items-center gap-1.5`}>
<Label className="text-muted-foreground" htmlFor={f.name}>
{f.label}
</Label>
<Input type="email" id={f.name} name={f.name} required={f.required} />
</div>
)
}
})
}
return (
<div className="flex w-full items-center justify-center">
<PopoverForm
title={title}
open={open}
setOpen={setOpen}
width="364px"
height="auto"
showCloseButton={formState !== 'success'}
showSuccess={formState === 'success'}
openChild={
<form onSubmit={handleSubmit}>
<div className="relative p-6 flex flex-col gap-3">{renderFields(fields || [])}</div>
<div className="relative flex h-12 items-center px-[10px]">
<PopoverFormSeparator />
<div className="absolute left-0 top-0 -translate-x-[1.5px] -translate-y-1/2">
<PopoverFormCutOutLeftIcon />
</div>
<div className="absolute right-0 top-0 translate-x-[1.5px] -translate-y-1/2 rotate-180">
<PopoverFormCutOutRightIcon />
</div>
<PopoverFormButton loading={formState === 'loading'} text={submitButtonLabel || ''} />
</div>
</form>
}
successChild={
<PopoverFormSuccess
title="Feedback Received"
description="Thank you for supporting our project!"
/>
}
/>
</div>
)
}

View File

@ -0,0 +1,14 @@
import { Block } from "payload";
export const ContactForm: Block = {
slug: 'contactForm',
interfaceName: 'ContactFormBlock',
fields: [
{
name: 'form',
type: 'relationship',
relationTo: 'forms',
required: true,
},
],
}

View File

@ -6,6 +6,7 @@ import { SimpleList } from './SimpleList/component'
import { Showcase } from './Showcase/component'
import { ProfileBrief } from './ProfileBrief/component'
import { BadgeList } from './BadgeList/component'
import { ContactForm } from './ContactForm/component'
const blockComponents = {
simpleBrief: SimpleBrief,
@ -13,6 +14,7 @@ const blockComponents = {
showcase: Showcase,
profileBrief: ProfileBrief,
badgeList: BadgeList,
contactForm: ContactForm,
}
export const RenderBlocks: React.FC<{

View File

@ -1,4 +1,5 @@
import { BadgeList } from "@/blocks/BadgeList/config";
import { ContactForm } from "@/blocks/ContactForm/config";
import { ProfileBrief } from "@/blocks/ProfileBrief/config";
import { Showcase } from "@/blocks/Showcase/config";
import { SimpleBrief } from "@/blocks/SimpleBrief/config";
@ -21,7 +22,7 @@ export const Pages: CollectionConfig = {
{
name: 'layout',
type: 'blocks',
blocks: [SimpleList, Showcase, ProfileBrief, SimpleBrief, BadgeList],
blocks: [SimpleList, Showcase, ProfileBrief, SimpleBrief, BadgeList, ContactForm],
},
]
}

View File

@ -0,0 +1,104 @@
'use client'
import { useEffect, useState } from 'react'
import {
PopoverForm,
PopoverFormButton,
PopoverFormCutOutLeftIcon,
PopoverFormCutOutRightIcon,
PopoverFormSeparator,
PopoverFormSuccess,
} from './popover-form'
type FormState = 'idle' | 'loading' | 'success'
const ContactForm = () => {
const [formState, setFormState] = useState<FormState>('idle')
const [open, setOpen] = useState(false)
const [feedback, setFeedback] = useState('')
function submit() {
setFormState('loading')
setTimeout(() => {
setFormState('success')
}, 1500)
setTimeout(() => {
setOpen(false)
setFormState('idle')
setFeedback('')
}, 3300)
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false)
}
if (
(event.ctrlKey || event.metaKey) &&
event.key === 'Enter' &&
open &&
formState === 'idle'
) {
submit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, formState])
return (
<div className="flex w-full items-center justify-center">
<PopoverForm
title="Feedback"
open={open}
setOpen={setOpen}
width="364px"
height="192px"
showCloseButton={formState !== 'success'}
showSuccess={formState === 'success'}
openChild={
<form
onSubmit={(e) => {
e.preventDefault()
if (!feedback) return
submit()
}}
>
<div className="relative">
<textarea
autoFocus
placeholder="Feedback"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
className="h-32 w-full resize-none rounded-t-lg p-3 text-sm outline-none"
required
/>
</div>
<div className="relative flex h-12 items-center px-[10px]">
<PopoverFormSeparator />
<div className="absolute left-0 top-0 -translate-x-[1.5px] -translate-y-1/2">
<PopoverFormCutOutLeftIcon />
</div>
<div className="absolute right-0 top-0 translate-x-[1.5px] -translate-y-1/2 rotate-180">
<PopoverFormCutOutRightIcon />
</div>
<PopoverFormButton loading={formState === 'loading'} text="" />
</div>
</form>
}
successChild={
<PopoverFormSuccess
title="Feedback Received"
description="Thank you for supporting our project!"
/>
}
/>
</div>
)
}
export default ContactForm

View File

@ -0,0 +1,305 @@
'use client'
import { ReactNode, RefObject, useEffect, useRef } from 'react'
import { ChevronUp, Loader } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
type PopoverFormProps = {
open: boolean
setOpen: (open: boolean) => void
openChild?: ReactNode
successChild?: ReactNode
showSuccess: boolean
width?: string
height?: string
showCloseButton?: boolean
title: string
}
export function PopoverForm({
open,
setOpen,
openChild,
showSuccess,
successChild,
width = '364px',
height = '192px',
title = 'Feedback',
showCloseButton = false,
}: PopoverFormProps) {
const ref = useRef<HTMLDivElement>(null)
// @ts-expect-error because this was brought in through shadcn and I dont want to worry about it cause it is a nonissue
useClickOutside(ref, () => setOpen(false))
return (
<div key={title} className="flex w-full items-center justify-center z-50">
<motion.button
layoutId={`${title}-wrapper`}
onClick={() => setOpen(true)}
style={{ borderRadius: 8 }}
className="flex h-9 items-center border bg-purple-300 dark:bg-purple-800 px-3 text-sm font-medium outline-none"
>
<motion.span layoutId={`${title}-title`}>{title}</motion.span>
</motion.button>
<AnimatePresence>
{open && (
<motion.div
layoutId={`${title}-wrapper`}
className="absolute p-1 overflow-hidden bg-muted shadow-2xl outline-none"
ref={ref}
style={{ borderRadius: 10, width, height }}
>
<motion.span
aria-hidden
className="absolute left-4 top-[17px] text-sm text-muted-foreground data-[success]:text-transparent"
layoutId={`${title}-title`}
data-success={showSuccess}
>
{title}
</motion.span>
{showCloseButton && (
<div className="absolute -top-[5px] left-1/2 transform -translate-x-1/2 w-[12px] h-[26px] flex items-center justify-center z-20">
<button
onClick={() => setOpen(false)}
className="absolute z-10 -mt-1 flex items-center justify-center w-[10px] h-[6px] text-muted-foreground hover:text-foreground focus:outline-none rounded-full "
aria-label="Close"
>
<ChevronUp className="text-muted-foreground/80" />
</button>
<PopoverFormCutOutTopIcon />
</div>
)}
<AnimatePresence mode="popLayout">
{showSuccess ? (
<motion.div
key="success"
initial={{ y: -32, opacity: 0, filter: 'blur(4px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
transition={{ type: 'spring', duration: 0.4, bounce: 0 }}
className="flex h-full flex-col items-center justify-center"
>
{successChild || <PopoverFormSuccess />}
</motion.div>
) : (
<motion.div
exit={{ y: 8, opacity: 0, filter: 'blur(4px)' }}
transition={{ type: 'spring', duration: 0.4, bounce: 0 }}
key="open-child"
style={{ borderRadius: 10 }}
className="h-full border bg-white dark:bg-[#121212] z-20 "
>
{openChild}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function PopoverFormButton({
loading,
text = 'submit',
}: {
loading: boolean
text: string
}) {
return (
<button
type="submit"
className="ml-auto flex h-6 w-26 items-center justify-center overflow-hidden rounded-md bg-gradient-to-b from-primary/90 to-primary px-3 text-xs font-semibold text-primary-foreground shadow-[0_0_1px_1px_rgba(255,255,255,0.08)_inset,0_1px_1.5px_0_rgba(0,0,0,0.32),0_0_0_0.5px_#1a94ff]"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${loading}`}
initial={{ opacity: 0, y: -25 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 25 }}
transition={{
type: 'spring',
duration: 0.3,
bounce: 0,
}}
className="flex w-full items-center justify-center"
>
{loading ? <Loader className="animate-spin size-3" /> : <span>{text}</span>}
</motion.span>
</AnimatePresence>
</button>
)
}
const useClickOutside = (
ref: RefObject<HTMLElement>,
handleOnClickOutside: (event: MouseEvent | TouchEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return
}
handleOnClickOutside(event)
}
document.addEventListener('mousedown', listener)
document.addEventListener('touchstart', listener)
return () => {
document.removeEventListener('mousedown', listener)
document.removeEventListener('touchstart', listener)
}
}, [ref, handleOnClickOutside])
}
export function PopoverFormSuccess({
title = 'Success',
description = 'Thank you for your submission',
}) {
return (
<>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="-mt-1"
>
<path
d="M27.6 16C27.6 17.5234 27.3 19.0318 26.717 20.4392C26.1341 21.8465 25.2796 23.1253 24.2025 24.2025C23.1253 25.2796 21.8465 26.1341 20.4392 26.717C19.0318 27.3 17.5234 27.6 16 27.6C14.4767 27.6 12.9683 27.3 11.5609 26.717C10.1535 26.1341 8.87475 25.2796 7.79759 24.2025C6.72043 23.1253 5.86598 21.8465 5.28302 20.4392C4.70007 19.0318 4.40002 17.5234 4.40002 16C4.40002 12.9235 5.62216 9.97301 7.79759 7.79759C9.97301 5.62216 12.9235 4.40002 16 4.40002C19.0765 4.40002 22.027 5.62216 24.2025 7.79759C26.3779 9.97301 27.6 12.9235 27.6 16Z"
fill="#2090FF"
fillOpacity="0.16"
/>
<path
d="M12.1334 16.9667L15.0334 19.8667L19.8667 13.1M27.6 16C27.6 17.5234 27.3 19.0318 26.717 20.4392C26.1341 21.8465 25.2796 23.1253 24.2025 24.2025C23.1253 25.2796 21.8465 26.1341 20.4392 26.717C19.0318 27.3 17.5234 27.6 16 27.6C14.4767 27.6 12.9683 27.3 11.5609 26.717C10.1535 26.1341 8.87475 25.2796 7.79759 24.2025C6.72043 23.1253 5.86598 21.8465 5.28302 20.4392C4.70007 19.0318 4.40002 17.5234 4.40002 16C4.40002 12.9235 5.62216 9.97301 7.79759 7.79759C9.97301 5.62216 12.9235 4.40002 16 4.40002C19.0765 4.40002 22.027 5.62216 24.2025 7.79759C26.3779 9.97301 27.6 12.9235 27.6 16Z"
stroke="#2090FF"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<h3 className="mb-1 mt-2 text-sm font-medium text-primary">{title}</h3>
<p className="text-sm text-muted-foreground max-w-xs text-pretty mx-auto text-center">
{description}
</p>
</>
)
}
export function PopoverFormSeparator({
width = 352,
height = 2,
}: {
width?: number | string
height?: number
}) {
return (
<svg
className="absolute left-0 right-0 top-[-1px]"
width={width}
height={height}
viewBox="0 0 352 2"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 1H352" className="stroke-border" strokeDasharray="4 4" />
</svg>
)
}
function PopoverFormCutOutTopIcon({
width = 44,
height = 30,
}: {
width?: number
height?: number
}) {
const aspectRatio = 6 / 12
const calculatedHeight = width * aspectRatio
const calculatedWidth = height / aspectRatio
const finalWidth = Math.min(width, calculatedWidth)
const finalHeight = Math.min(height, calculatedHeight)
return (
<svg
width={finalWidth}
height={finalHeight}
viewBox="0 0 6 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="rotate-90 mt-[1px]"
preserveAspectRatio="none"
>
<g clipPath="url(#clip0_2029_22)">
<path
d="M0 2C0.656613 2 1.30679 2.10346 1.91341 2.30448C2.52005 2.5055 3.07124 2.80014 3.53554 3.17157C3.99982 3.54301 4.36812 3.98396 4.6194 4.46927C4.87067 4.95457 5 5.47471 5 6C5 6.52529 4.87067 7.04543 4.6194 7.53073C4.36812 8.01604 3.99982 8.45699 3.53554 8.82843C3.07124 9.19986 2.52005 9.4945 1.91341 9.69552C1.30679 9.89654 0.656613 10 0 10V6V2Z"
className="fill-muted"
/>
<path
d="M1 12V10C2.06087 10 3.07828 9.57857 3.82843 8.82843C4.57857 8.07828 5 7.06087 5 6C5 4.93913 4.57857 3.92172 3.82843 3.17157C3.07828 2.42143 2.06087 2 1 2V0"
className="stroke-border"
strokeWidth={0.6}
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2029_22">
<rect width={finalWidth} height={finalHeight} fill="white" />
</clipPath>
</defs>
</svg>
)
}
export function PopoverFormCutOutLeftIcon() {
return (
<svg width="6" height="12" viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_2029_22)">
<path
d="M0 2C0.656613 2 1.30679 2.10346 1.91341 2.30448C2.52005 2.5055 3.07124 2.80014 3.53554 3.17157C3.99982 3.54301 4.36812 3.98396 4.6194 4.46927C4.87067 4.95457 5 5.47471 5 6C5 6.52529 4.87067 7.04543 4.6194 7.53073C4.36812 8.01604 3.99982 8.45699 3.53554 8.82843C3.07124 9.19986 2.52005 9.4945 1.91341 9.69552C1.30679 9.89654 0.656613 10 0 10V6V2Z"
className="fill-muted"
/>
<path
d="M1 12V10C2.06087 10 3.07828 9.57857 3.82843 8.82843C4.57857 8.07828 5 7.06087 5 6C5 4.93913 4.57857 3.92172 3.82843 3.17157C3.07828 2.42143 2.06087 2 1 2V0"
className="stroke-border"
strokeWidth="1"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2029_22">
<rect width="6" height="12" fill="white" />
</clipPath>
</defs>
</svg>
)
}
export function PopoverFormCutOutRightIcon() {
return (
<svg width="6" height="12" viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_2029_22)">
<path
d="M0 2C0.656613 2 1.30679 2.10346 1.91341 2.30448C2.52005 2.5055 3.07124 2.80014 3.53554 3.17157C3.99982 3.54301 4.36812 3.98396 4.6194 4.46927C4.87067 4.95457 5 5.47471 5 6C5 6.52529 4.87067 7.04543 4.6194 7.53073C4.36812 8.01604 3.99982 8.45699 3.53554 8.82843C3.07124 9.19986 2.52005 9.4945 1.91341 9.69552C1.30679 9.89654 0.656613 10 0 10V6V2Z"
className="fill-muted"
/>
<path
d="M1 12V10C2.06087 10 3.07828 9.57857 3.82843 8.82843C4.57857 8.07828 5 7.06087 5 6C5 4.93913 4.57857 3.92172 3.82843 3.17157C3.07828 2.42143 2.06087 2 1 2V0"
className="stroke-border"
strokeWidth="1"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2029_22">
<rect width="6" height="12" fill="white" />
</clipPath>
</defs>
</svg>
)
}

View File

@ -90,9 +90,9 @@ export function ProjectCard({
<CardContent className="mt-auto flex flex-col px-2">
{tags?.badges && tags.badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.badges.map((b, i) => (
{tags.badges.map((b) => (
<Badge
key={b.value || '' + i}
key={crypto.randomUUID()}
variant="secondary"
className={clsx(
'px-1 py-0 text-[10px]',

View File

@ -29,7 +29,7 @@ const BlurFade = ({
blur = '6px',
}: BlurFadeProps) => {
const ref = useRef(null)
// @ts-ignore
// @ts-expect-error cause this is from a shadcn import ant i dont care
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
const isInView = !inView || inViewResult
const defaultVariants: Variants = {

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -70,6 +70,8 @@ export interface Config {
users: User;
media: Media;
pages: Page;
forms: Form;
'form-submissions': FormSubmission;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@ -79,6 +81,8 @@ export interface Config {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
forms: FormsSelect<false> | FormsSelect<true>;
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@ -164,7 +168,9 @@ export interface Media {
export interface Page {
id: number;
slug?: string | null;
layout?: (SimpleListBlock | ShowcaseBlock | ProfileBriefBlock | SimpleBriefBlock | BadgeListBlock)[] | null;
layout?:
| (SimpleListBlock | ShowcaseBlock | ProfileBriefBlock | SimpleBriefBlock | BadgeListBlock | ContactFormBlock)[]
| null;
updatedAt: string;
createdAt: string;
}
@ -317,6 +323,207 @@ export interface SimpleBriefBlock {
blockName?: string | null;
blockType: 'simpleBrief';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContactFormBlock".
*/
export interface ContactFormBlock {
form: number | Form;
id?: string | null;
blockName?: string | null;
blockType: 'contactForm';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "forms".
*/
export interface Form {
id: number;
title: string;
fields?:
| (
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
defaultValue?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'checkbox';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'country';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'email';
}
| {
message?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'message';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'number';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
placeholder?: string | null;
options?:
| {
label: string;
value: string;
id?: string | null;
}[]
| null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'select';
}
| {
name: string;
label?: string | null;
width?: number | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'state';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'text';
}
| {
name: string;
label?: string | null;
width?: number | null;
defaultValue?: string | null;
required?: boolean | null;
id?: string | null;
blockName?: string | null;
blockType: 'textarea';
}
)[]
| null;
submitButtonLabel?: string | null;
/**
* Choose whether to display an on-page message or redirect to a different page after they submit the form.
*/
confirmationType?: ('message' | 'redirect') | null;
confirmationMessage?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
redirect?: {
url: string;
};
/**
* Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email.
*/
emails?:
| {
emailTo?: string | null;
cc?: string | null;
bcc?: string | null;
replyTo?: string | null;
emailFrom?: string | null;
subject: string;
/**
* Enter the message that should be sent in this email.
*/
message?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions".
*/
export interface FormSubmission {
id: number;
form: number | Form;
submissionData?:
| {
field: string;
value: string;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@ -335,6 +542,14 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'forms';
value: number | Form;
} | null)
| ({
relationTo: 'form-submissions';
value: number | FormSubmission;
} | null);
globalSlug?: string | null;
user: {
@ -425,6 +640,7 @@ export interface PagesSelect<T extends boolean = true> {
profileBrief?: T | ProfileBriefBlockSelect<T>;
simpleBrief?: T | SimpleBriefBlockSelect<T>;
badgeList?: T | BadgeListBlockSelect<T>;
contactForm?: T | ContactFormBlockSelect<T>;
};
updatedAt?: T;
createdAt?: T;
@ -536,6 +752,164 @@ export interface SimpleBriefBlockSelect<T extends boolean = true> {
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContactFormBlock_select".
*/
export interface ContactFormBlockSelect<T extends boolean = true> {
form?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "forms_select".
*/
export interface FormsSelect<T extends boolean = true> {
title?: T;
fields?:
| T
| {
checkbox?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
defaultValue?: T;
id?: T;
blockName?: T;
};
country?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
email?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
message?:
| T
| {
message?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
select?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
placeholder?: T;
options?:
| T
| {
label?: T;
value?: T;
id?: T;
};
required?: T;
id?: T;
blockName?: T;
};
state?:
| T
| {
name?: T;
label?: T;
width?: T;
required?: T;
id?: T;
blockName?: T;
};
text?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
textarea?:
| T
| {
name?: T;
label?: T;
width?: T;
defaultValue?: T;
required?: T;
id?: T;
blockName?: T;
};
};
submitButtonLabel?: T;
confirmationType?: T;
confirmationMessage?: T;
redirect?:
| T
| {
url?: T;
};
emails?:
| T
| {
emailTo?: T;
cc?: T;
bcc?: T;
replyTo?: T;
emailFrom?: T;
subject?: T;
message?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions_select".
*/
export interface FormSubmissionsSelect<T extends boolean = true> {
form?: T;
submissionData?:
| T
| {
field?: T;
value?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@ -1,4 +1,6 @@
import { s3Storage } from '@payloadcms/storage-s3'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
import path from 'path'
@ -35,6 +37,19 @@ export default buildConfig({
connectionString: process.env.DATABASE_URI || '',
},
}),
email: nodemailerAdapter({
defaultFromAddress: 'no-reply@beitzah.net',
defaultFromName: 'no-reply Beitzah',
transportOptions: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '', 10),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
sharp,
plugins: [
payloadCloudPlugin(),
@ -52,6 +67,27 @@ export default buildConfig({
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
}
}),
formBuilderPlugin({
fields: {
text: true,
textarea: true,
select: true,
email: true,
state: true,
country: true,
checkbox: true,
number: true,
message: true,
payment: false,
},
defaultToEmail: process.env.DEFAULT_EMAIL,
formOverrides: {
access: {
read: () => true, // !!user, // authenticated users only
update: () => true,
},
}
})
],
})