diff --git a/templates/developer-portfolio/.env.example b/templates/developer-portfolio/.env.example new file mode 100644 index 0000000000..4ed93131ad --- /dev/null +++ b/templates/developer-portfolio/.env.example @@ -0,0 +1,9 @@ +MONGODB_URI=mongodb://127.0.0.1/payload-example-custom-server +PAYLOAD_SECRET=PAYLOAD_CUSTOM_SERVER_EXAMPLE_SECRET_KEY +PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 +PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET +COOKIE_DOMAIN=localhost +REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY +PAYLOAD_SEED=false +PAYLOAD_DROP_DATABASE=false +ENABLE_PAYLOAD_CLOUD=false \ No newline at end of file diff --git a/templates/developer-portfolio/.eslintrc.js b/templates/developer-portfolio/.eslintrc.js new file mode 100644 index 0000000000..85269d0699 --- /dev/null +++ b/templates/developer-portfolio/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + root: true, + extends: ['plugin:@next/next/recommended', '@payloadcms'], + ignorePatterns: ['**/payload-types.ts'], + plugins: ['prettier'], + rules: { + 'prettier/prettier': 'error', + }, +} diff --git a/templates/developer-portfolio/.gitignore b/templates/developer-portfolio/.gitignore new file mode 100644 index 0000000000..51414063d5 --- /dev/null +++ b/templates/developer-portfolio/.gitignore @@ -0,0 +1,9 @@ +build +dist +node_modules +package-lock.json +.env +.next +.vercel +src/media +.DS_Store \ No newline at end of file diff --git a/templates/developer-portfolio/.prettierignore b/templates/developer-portfolio/.prettierignore new file mode 100644 index 0000000000..e732bb4ea2 --- /dev/null +++ b/templates/developer-portfolio/.prettierignore @@ -0,0 +1 @@ +**/payload-types.ts diff --git a/templates/developer-portfolio/.prettierrc.js b/templates/developer-portfolio/.prettierrc.js new file mode 100644 index 0000000000..70c17c995f --- /dev/null +++ b/templates/developer-portfolio/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + printWidth: 100, + parser: "typescript", + semi: false, + singleQuote: true, + trailingComma: "all", + arrowParens: "avoid", +}; diff --git a/templates/developer-portfolio/README.md b/templates/developer-portfolio/README.md new file mode 100644 index 0000000000..a0eae2f001 --- /dev/null +++ b/templates/developer-portfolio/README.md @@ -0,0 +1,76 @@ +# Payload Developer Portfolio Example + +This example demonstrates a complete professional portfolio application using [Payload](https://github.com/payloadcms/payload) and NextJS in a single Express server. + +## Highlights + +- 💪 **Batteries-Included** + - Design beautiful pages, describe portfolio projects, and build forms dynamically without writing code +- 🔎 **SEO-Friendly** + - Includes [SEO plugin](https://github.com/payloadcms/plugin-seo) integration to author and preview page metadata +- 🪭 **Customization-Friendly** + - Light/dark mode, [@shadcn/ui](https://ui.shadcn.com/) integration, prebuilt animations, and modular CMS "blocks" encourage extension and re-use +- 🏎️ **Performance-Focused** + - Uses React Server Components, App Router, and `next/image` to optimize Web Vitals metrics +- 🦯 **Accessibility-Minded** + - Navigation, contrast, dialogs, and forms built with [WCAG 2](https://www.w3.org/WAI/standards-guidelines/wcag/) in mind + +## Quick Start + +### Prerequisites + +- [Node](https://nodejs.org/en) 18.x or newer +- [MongoDB](https://www.mongodb.com/try/download/community) + +### Setup + +1. First, clone the repo +1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env` +1. Next `yarn && yarn seed` to start the app and seed it with example data +1. Now `open http://localhost:3000` to view the site + +That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details. + +### Editing Content + +To access the Admin interface, where you can edit content: + +1. Go to `http://localhost:3000/admin` +1. Login with `dev@payloadcms.com` / `test` + +## How it works + +When you use Payload, you plug it into _**your**_ Express server. That's a fundamental difference between Payload and other application frameworks. It means that when you use Payload, you're technically _adding_ Payload to _your_ app, and not building a "Payload app". + +One of the strengths of this pattern is that it lets you do powerful things like integrate your Payload instance directly with your front-end. This will allow you to host Payload alongside a fully dynamic, CMS-integrated website or app on a single, combined server—while still getting all of the benefits of a headless CMS. + +## Development + +To spin up this example locally, follow the [Quick Start](#quick-start). + +### Seed + +On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates: + +- An admin user with email `dev@payloadcms.com`, password `test`, +- A `home` page with Profile CTA, project grid, and contact form +- Example header and profile data +- Example media assets +- Example portfolio projects + +> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data. + +## Production + +To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps: + +1. First, invoke the `payload build` script by running `yarn build` in your project root. This creates a `./build` directory with a production-ready admin bundle. +1. Then, run `yarn serve` to run Node in production and serve Payload from the `./build` directory. + +### Deployment + +The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details. + +## Questions + +If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions). diff --git a/templates/developer-portfolio/components.json b/templates/developer-portfolio/components.json new file mode 100644 index 0000000000..61f11c6f70 --- /dev/null +++ b/templates/developer-portfolio/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/globals.css", + "baseColor": "stone", + "cssVariables": true + }, + "aliases": { + "components": "src/app/_components", + "utils": "src/utilities" + } +} \ No newline at end of file diff --git a/templates/developer-portfolio/eject.ts b/templates/developer-portfolio/eject.ts new file mode 100644 index 0000000000..cc91361df2 --- /dev/null +++ b/templates/developer-portfolio/eject.ts @@ -0,0 +1,36 @@ +import fs from 'fs' +import path from 'path' + +// Run this script to eject the front-end from this template +// This will remove all template-specific files and directories +// See `yarn eject` in `package.json` for the exact command +// See `./README.md#eject` for more information + +const files = ['./next.config.js', './next-env.d.ts'] +const directories = ['./src/app'] + +const eject = async (): Promise => { + files.forEach(file => { + fs.unlinkSync(path.join(__dirname, file)) + }) + + directories.forEach(directory => { + fs.rm(path.join(__dirname, directory), { recursive: true }, err => { + if (err) throw err + }) + }) + + // create a new `./src/server.ts` file + // use contents from `./src/server.default.ts` + const serverFile = path.join(__dirname, './src/server.ts') + const serverDefaultFile = path.join(__dirname, './src/server.default.ts') + fs.copyFileSync(serverDefaultFile, serverFile) + + // remove `'plugin:@next/next/recommended', ` from `./.eslintrc.js` + const eslintConfigFile = path.join(__dirname, './.eslintrc.js') + const eslintConfig = fs.readFileSync(eslintConfigFile, 'utf8') + const updatedEslintConfig = eslintConfig.replace(`'plugin:@next/next/recommended', `, '') + fs.writeFileSync(eslintConfigFile, updatedEslintConfig, 'utf8') +} + +eject() diff --git a/templates/developer-portfolio/next-env.d.ts b/templates/developer-portfolio/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/templates/developer-portfolio/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/templates/developer-portfolio/next.config.js b/templates/developer-portfolio/next.config.js new file mode 100644 index 0000000000..ff5bd3af77 --- /dev/null +++ b/templates/developer-portfolio/next.config.js @@ -0,0 +1,18 @@ +require('dotenv').config() + +const imageDomains = ['localhost'] +if (process.env.PAYLOAD_PUBLIC_SERVER_URL) { + const { hostname } = new URL(process.env.PAYLOAD_PUBLIC_SERVER_URL) + imageDomains.push(hostname) +} + +// eslint-disable-next-line no-console +console.log('allowed image domains:', imageDomains.join(', ')) + +module.exports = { + reactStrictMode: true, + swcMinify: true, + images: { + domains: imageDomains, + }, +} diff --git a/templates/developer-portfolio/nodemon.json b/templates/developer-portfolio/nodemon.json new file mode 100644 index 0000000000..02523c6130 --- /dev/null +++ b/templates/developer-portfolio/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["server.ts"], + "exec": "ts-node --project tsconfig.server.json src/server.ts", + "ext": "js ts" +} diff --git a/templates/developer-portfolio/package.json b/templates/developer-portfolio/package.json new file mode 100644 index 0000000000..69b1704ca4 --- /dev/null +++ b/templates/developer-portfolio/package.json @@ -0,0 +1,75 @@ +{ + "name": "payload-developer-portfolio-template", + "description": "Payload developer portfolio template.", + "version": "1.0.0", + "main": "dist/server.js", + "license": "MIT", + "scripts": { + "dev": "cross-env PAYLOAD_SEED=false PAYLOAD_DROP_DATABASE=false PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon -V", + "seed": "rm -rf src/media .next && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts", + "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", + "build:server": "tsc --project tsconfig.server.json", + "build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NEXT_BUILD=true node dist/server.js", + "build": "cross-env NODE_ENV=production yarn build:payload && yarn build:server && yarn copyfiles && yarn build:next", + "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", + "eject": "yarn remove next react react-dom @next/eslint-plugin-next && ts-node eject.ts", + "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", + "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", + "generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema", + "lint": "eslint src", + "lint:fix": "eslint --fix --ext .ts,.tsx src" + }, + "dependencies": { + "@hookform/resolvers": "^3.2.0", + "@payloadcms/plugin-form-builder": "^1.0.15", + "@payloadcms/plugin-seo": "^1.0.14-canary.0", + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.5", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "dotenv": "^8.2.0", + "escape-html": "^1.0.3", + "express": "^4.17.1", + "lucide-react": "^0.263.1", + "next": "^13.4.19", + "next-themes": "^0.2.1", + "nodemailer": "^6.9.4", + "payload": "1.15.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.45.4", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.6", + "zod": "^3.21.4" + }, + "devDependencies": { + "@next/eslint-plugin-next": "^13.1.6", + "@payloadcms/eslint-config": "^0.0.2", + "@types/escape-html": "^1.0.2", + "@types/express": "^4.17.9", + "@types/node": "18.11.3", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "@typescript-eslint/eslint-plugin": "^5.51.0", + "@typescript-eslint/parser": "^5.51.0", + "autoprefixer": "^10.4.14", + "copyfiles": "^2.4.1", + "cross-env": "^7.0.3", + "eslint": "^8.19.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-filenames": "^1.3.2", + "eslint-plugin-import": "2.25.4", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-simple-import-sort": "^10.0.0", + "nodemon": "^2.0.22", + "postcss": "^8.4.27", + "prettier": "^2.7.1", + "tailwindcss": "^3.3.3", + "ts-node": "^10.9.1", + "typescript": "^4.8.4" + } +} \ No newline at end of file diff --git a/templates/developer-portfolio/postcss.config.js b/templates/developer-portfolio/postcss.config.js new file mode 100644 index 0000000000..33ad091d26 --- /dev/null +++ b/templates/developer-portfolio/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/templates/developer-portfolio/public/favicon.ico b/templates/developer-portfolio/public/favicon.ico new file mode 100644 index 0000000000..601b77718c Binary files /dev/null and b/templates/developer-portfolio/public/favicon.ico differ diff --git a/templates/developer-portfolio/public/favicon.svg b/templates/developer-portfolio/public/favicon.svg new file mode 100644 index 0000000000..86dc2defc4 --- /dev/null +++ b/templates/developer-portfolio/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/templates/developer-portfolio/public/outside-app-2.png b/templates/developer-portfolio/public/outside-app-2.png new file mode 100644 index 0000000000..b9a450a993 Binary files /dev/null and b/templates/developer-portfolio/public/outside-app-2.png differ diff --git a/templates/developer-portfolio/public/outside-app-3.png b/templates/developer-portfolio/public/outside-app-3.png new file mode 100644 index 0000000000..a9a3ef919e Binary files /dev/null and b/templates/developer-portfolio/public/outside-app-3.png differ diff --git a/templates/developer-portfolio/public/outside-app-4.png b/templates/developer-portfolio/public/outside-app-4.png new file mode 100644 index 0000000000..bd1b729de9 Binary files /dev/null and b/templates/developer-portfolio/public/outside-app-4.png differ diff --git a/templates/developer-portfolio/public/payload-logo.png b/templates/developer-portfolio/public/payload-logo.png new file mode 100644 index 0000000000..6337c3d375 Binary files /dev/null and b/templates/developer-portfolio/public/payload-logo.png differ diff --git a/templates/developer-portfolio/public/project-1.png b/templates/developer-portfolio/public/project-1.png new file mode 100644 index 0000000000..5b86bd3ddf Binary files /dev/null and b/templates/developer-portfolio/public/project-1.png differ diff --git a/templates/developer-portfolio/public/project-2.png b/templates/developer-portfolio/public/project-2.png new file mode 100644 index 0000000000..163d8ea556 Binary files /dev/null and b/templates/developer-portfolio/public/project-2.png differ diff --git a/templates/developer-portfolio/public/project-3.png b/templates/developer-portfolio/public/project-3.png new file mode 100644 index 0000000000..7b17201f4d Binary files /dev/null and b/templates/developer-portfolio/public/project-3.png differ diff --git a/templates/developer-portfolio/public/project-4.png b/templates/developer-portfolio/public/project-4.png new file mode 100644 index 0000000000..442d0e6262 Binary files /dev/null and b/templates/developer-portfolio/public/project-4.png differ diff --git a/templates/developer-portfolio/src/access/loggedIn.ts b/templates/developer-portfolio/src/access/loggedIn.ts new file mode 100644 index 0000000000..ce4167ff7a --- /dev/null +++ b/templates/developer-portfolio/src/access/loggedIn.ts @@ -0,0 +1,5 @@ +import type { Access } from 'payload/config' + +export const loggedIn: Access = ({ req: { user } }) => { + return Boolean(user) +} diff --git a/templates/developer-portfolio/src/access/publishedOrLoggedIn.ts b/templates/developer-portfolio/src/access/publishedOrLoggedIn.ts new file mode 100644 index 0000000000..c4b3d549b7 --- /dev/null +++ b/templates/developer-portfolio/src/access/publishedOrLoggedIn.ts @@ -0,0 +1,17 @@ +import type { Access } from 'payload/config' + +export const publishedOrLoggedIn: Access = ({ req: { user } }) => { + if (user) { + return true + } + + return { + or: [ + { + _status: { + equals: 'published', + }, + }, + ], + } +} diff --git a/templates/developer-portfolio/src/app/[slug]/page.tsx b/templates/developer-portfolio/src/app/[slug]/page.tsx new file mode 100644 index 0000000000..f3842e429f --- /dev/null +++ b/templates/developer-portfolio/src/app/[slug]/page.tsx @@ -0,0 +1,54 @@ +import { Metadata, ResolvingMetadata } from 'next' +import { notFound, redirect } from 'next/navigation' + +import { Media } from '../../payload-types' +import { ContentLayout } from '../_components/content/contentLayout' +import { fetchPage } from '../_utils/api' +import { parsePreviewOptions } from '../_utils/preview' + +interface LandingPageProps { + params: { + slug: string + } + searchParams: Record +} + +export async function generateMetadata( + { searchParams, params }: LandingPageProps, + parent?: ResolvingMetadata, +): Promise { + const defaultTitle = (await parent)?.title?.absolute + const options = parsePreviewOptions(searchParams) + const page = await fetchPage(params.slug, options) + + const title = page?.meta?.title || defaultTitle + const description = page?.meta?.description || 'A portfolio of work by a digital professional.' + const images = [] + if (page?.meta?.image) { + images.push((page.meta.image as Media).url) + } + + return { + title, + description, + openGraph: { + title, + description, + images, + }, + } +} + +const LandingPage = async ({ params, searchParams }: LandingPageProps) => { + const { slug } = params + const options = parsePreviewOptions(searchParams) + const page = await fetchPage(slug, options) + + if (!page?.layout) { + notFound() + } + + return +} + +export default LandingPage diff --git a/templates/developer-portfolio/src/app/_assets/payloadLogo.tsx b/templates/developer-portfolio/src/app/_assets/payloadLogo.tsx new file mode 100644 index 0000000000..da1bc9b282 --- /dev/null +++ b/templates/developer-portfolio/src/app/_assets/payloadLogo.tsx @@ -0,0 +1,17 @@ +import { cn } from '../../utilities' + +export const PayloadLogo = ({ className = '' }: { className?: string }) => ( + + + +) diff --git a/templates/developer-portfolio/src/app/_components/content/contentBlock.tsx b/templates/developer-portfolio/src/app/_components/content/contentBlock.tsx new file mode 100644 index 0000000000..c297751927 --- /dev/null +++ b/templates/developer-portfolio/src/app/_components/content/contentBlock.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react' + +import { Block, BlockProps } from '../ui/block' +import { PayloadLink, PayloadLinkType } from './link' +import { RichText } from './richText' + +interface ContentBlockFields extends BlockProps { + richText?: unknown + enableLink?: boolean + link?: PayloadLinkType +} + +interface ContentBlockProps { + contentFields: ContentBlockFields[] +} + +export const ContentBlock: FC = ({ contentFields }) => { + return ( + <> + {contentFields.map(({ richText, size, id, enableLink, link }) => { + let content = + + if (enableLink) { + content = {content} + } + + return ( + + {content} + + ) + })} + + ) +} diff --git a/templates/developer-portfolio/src/app/_components/content/contentLayout.tsx b/templates/developer-portfolio/src/app/_components/content/contentLayout.tsx new file mode 100644 index 0000000000..48eb46b6a4 --- /dev/null +++ b/templates/developer-portfolio/src/app/_components/content/contentLayout.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react' + +import { Form, Page, Profile, Project } from '../../../payload-types' +import { cn } from '../../../utilities' +import { ContentBlock } from './contentBlock' +import { FormBlock } from './formBlock' +import { MediaBlock } from './mediaBlock' +import { MediaContentBlock } from './mediaContentBlock' +import { ProfileCTABlock } from './profileCTABlock' +import { ProjectGridBlock } from './projectGridBlock' + +interface ContentLayoutProps { + layout?: Page['layout'] + profile?: Profile + className?: string +} + +export const ContentLayout: FC = ({ layout, profile, className }) => { + let hasMedia = false + return ( +
+ {layout?.map((block, index) => { + let element = null + switch (block.blockType) { + case 'content': + element = + break + case 'mediaBlock': + element = ( + + ) + + hasMedia = true + break + case 'profile-cta': + element = + break + case 'projectGrid': + element = ( + + ) + break + case 'form': + element = + break + case 'mediaContent': + element = + hasMedia = true + break + } + + return element + })} +
+ ) +} diff --git a/templates/developer-portfolio/src/app/_components/content/formBlock.tsx b/templates/developer-portfolio/src/app/_components/content/formBlock.tsx new file mode 100644 index 0000000000..9dcc2cb5bb --- /dev/null +++ b/templates/developer-portfolio/src/app/_components/content/formBlock.tsx @@ -0,0 +1,225 @@ +'use client' +import React, { FC, useState } from 'react' +import { useForm } from 'react-hook-form' +import { Data } from 'payload/dist/admin/components/forms/Form/types' + +import { Form as FormTypes } from '../../../payload-types' +import { serverUrl } from '../../_utils/api' +import { Block } from '../ui/block' +import { Button } from '../ui/button' +import { Dialog, DialogContent } from '../ui/dialog' +import { Input } from '../ui/input' +import { Textarea } from '../ui/textarea' +import { RichText } from './richText' + +type ErrorType = { + status: string + message: string +} + +export type FormBlockProps = { + form: FormTypes + intro?: unknown +} + +export interface FormField { + name: string + label?: string + width?: number + defaultValue?: string + required?: boolean + id?: string + blockName?: string + blockType: 'text' | 'textarea' | 'email' | 'message' + message?: { + [k: string]: unknown + }[] +} + +function groupFieldsByRow(form: FormTypes): FormTypes['fields'][] { + const rows: FormTypes['fields'][] = [] + let currentRow: FormTypes['fields'] = [] + let currentRowWidth = 0 + + for (const field of form.fields || []) { + const fieldWidth = field.blockType !== 'message' ? field.width || 100 : 100 // Assuming a default width of 100% if not specified + + // Check if adding this field to the current row would exceed 100% + if (currentRowWidth + fieldWidth > 100) { + // End the current row and start a new one + rows.push(currentRow) + currentRow = [] + currentRowWidth = 0 + } + + // Add the field to the current row and update the width + currentRow.push(field) + currentRowWidth += fieldWidth + } + + // If there are any remaining fields in the current row, add them to the rows + if (currentRow.length > 0) { + rows.push(currentRow) + } + + return rows +} + +export const FormBlock: FC = props => { + const [dialogOpen, setDialogOpen] = useState(false) + const { + form: formFromProps, + form: { id: formID, submitButtonLabel }, + intro, + } = props + + const formMethods = useForm() + const { + register, + handleSubmit, + formState: { isDirty }, + } = formMethods + + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(false) + + const onSubmit = async (data: Data) => { + setIsLoading(true) // Set loading state when submitting + + const dataToSend = Object.entries(data).map(([name, value]) => ({ + field: name, + value, + })) + + try { + const req = await fetch(`${serverUrl}/api/form-submissions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', // Correct typo: "Content-Types" to "Content-Type" + }, + body: JSON.stringify({ + form: formID, + submissionData: dataToSend, + }), + }) + + const res = await req.json() + + if (req.status >= 400) { + setError({ + status: res.status, + message: res.errors?.[0] || 'Internal Server Error', // Use optional chaining + }) + } + + setIsLoading(false) // Clear loading state + if (props.form.confirmationType === 'message') { + setDialogOpen(true) + } else if (props.form.redirect) { + window.location.href = props.form.redirect.url + } else { + // eslint-disable-next-line no-console + console.error('No post-submit action defined for form') + } + } catch (error) { + setError({ + status: 'Error', + message: 'An error occurred while submitting the form.', + }) + setIsLoading(false) // Clear loading state + } + } + + return ( + + {intro && } +
+ {groupFieldsByRow(formFromProps).map((row, index) => ( +
+ {row.map((field, index) => { + if (field.blockType === 'message') { + return + } + + let pattern + if (field.blockType === 'email') { + pattern = { + value: /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, + message: 'Please enter a valid email address.', + } + } + + const props = { + id: `${formID}-${field.name}`, + ...register(field.name, { required: field.required, pattern }), + } + + let content + + switch (field.blockType) { + case 'text': + case 'email': + content = + break + case 'textarea': + content =