From a85fba4882abf30c939504db976d49404dd0e970 Mon Sep 17 00:00:00 2001 From: RizqiSyahrendra Date: Thu, 24 Apr 2025 20:01:01 +0700 Subject: [PATCH] feat: form submission components blocks and styling --- package.json | 2 + public/js/script.js | 266 +++++------ src/blocks/Content.ts | 15 + src/blocks/Form.ts | 33 ++ src/collections/Pages.ts | 92 ++++ src/components/ContactFormBox.tsx | 69 +-- src/components/ContactFormSection.tsx | 69 +-- src/components/HeroSlider.tsx | 6 +- src/components/blocks/Content/index.tsx | 26 ++ src/components/blocks/Form/Button/index.tsx | 54 +++ src/components/blocks/Form/Checkbox/index.tsx | 34 ++ src/components/blocks/Form/Email/index.tsx | 35 ++ src/components/blocks/Form/Error/index.scss | 4 + src/components/blocks/Form/Error/index.tsx | 5 + src/components/blocks/Form/Link/index.tsx | 72 +++ src/components/blocks/Form/Number/index.tsx | 30 ++ src/components/blocks/Form/RichText/index.tsx | 24 + .../blocks/Form/RichText/nodeFormat.tsx | 118 +++++ .../blocks/Form/RichText/serialize.tsx | 173 +++++++ src/components/blocks/Form/Select/index.tsx | 54 +++ src/components/blocks/Form/Text/index.tsx | 30 ++ src/components/blocks/Form/Textarea/index.tsx | 31 ++ src/components/blocks/Form/Width/index.tsx | 13 + .../blocks/Form/buildInitialFormState.tsx | 43 ++ src/components/blocks/Form/fields.tsx | 15 + src/components/blocks/Form/index.tsx | 180 ++++++++ src/components/blocks/Form/shared.scss | 42 ++ src/components/blocks/RenderBlocks.tsx | 56 +++ src/components/homes/HomeTopSection.tsx | 4 +- src/payload-types.ts | 427 ++++++++++++++++++ src/payload.config.ts | 54 ++- src/services/hooks/form.ts | 26 ++ src/services/payload/form.ts | 12 + src/services/rest/form.ts | 11 + yarn.lock | 27 +- 35 files changed, 1900 insertions(+), 252 deletions(-) create mode 100644 src/blocks/Content.ts create mode 100644 src/blocks/Form.ts create mode 100644 src/collections/Pages.ts create mode 100644 src/components/blocks/Content/index.tsx create mode 100644 src/components/blocks/Form/Button/index.tsx create mode 100644 src/components/blocks/Form/Checkbox/index.tsx create mode 100644 src/components/blocks/Form/Email/index.tsx create mode 100644 src/components/blocks/Form/Error/index.scss create mode 100644 src/components/blocks/Form/Error/index.tsx create mode 100644 src/components/blocks/Form/Link/index.tsx create mode 100644 src/components/blocks/Form/Number/index.tsx create mode 100644 src/components/blocks/Form/RichText/index.tsx create mode 100644 src/components/blocks/Form/RichText/nodeFormat.tsx create mode 100644 src/components/blocks/Form/RichText/serialize.tsx create mode 100644 src/components/blocks/Form/Select/index.tsx create mode 100644 src/components/blocks/Form/Text/index.tsx create mode 100644 src/components/blocks/Form/Textarea/index.tsx create mode 100644 src/components/blocks/Form/Width/index.tsx create mode 100644 src/components/blocks/Form/buildInitialFormState.tsx create mode 100644 src/components/blocks/Form/fields.tsx create mode 100644 src/components/blocks/Form/index.tsx create mode 100644 src/components/blocks/Form/shared.scss create mode 100644 src/components/blocks/RenderBlocks.tsx create mode 100644 src/services/hooks/form.ts create mode 100644 src/services/payload/form.ts create mode 100644 src/services/rest/form.ts diff --git a/package.json b/package.json index d477a0c..2fc68ab 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@payloadcms/db-postgres": "^3.35.1", "@payloadcms/next": "^3.35.1", "@payloadcms/payload-cloud": "^3.35.1", + "@payloadcms/plugin-form-builder": "^3.35.1", "@payloadcms/richtext-lexical": "^3.35.1", "@payloadcms/storage-s3": "^3.35.1", "country-state-city": "^3.2.1", @@ -25,6 +26,7 @@ "qs-esm": "^7.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.56.1", "react-select": "^5.10.1", "swiper": "^11.2.6" }, diff --git a/public/js/script.js b/public/js/script.js index 54c4c26..3e0ad30 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -1482,168 +1482,168 @@ $(function () { } // Select2 - if (plugins.selectFilter.length) { - var i; - for (i = 0; i < plugins.selectFilter.length; i++) { - var select = $(plugins.selectFilter[i]); + // if (plugins.selectFilter.length) { + // var i; + // for (i = 0; i < plugins.selectFilter.length; i++) { + // var select = $(plugins.selectFilter[i]); - select.select2({ - placeholder: select.attr("data-placeholder") ? select.attr("data-placeholder") : false, - minimumResultsForSearch: select.attr("data-minimum-results-search") ? select.attr("data-minimum-results-search") : -1, - maximumSelectionSize: 3, - dropdownCssClass: select.attr("data-class") ? select.attr("data-class") : '' - }); - } - } + // select.select2({ + // placeholder: select.attr("data-placeholder") ? select.attr("data-placeholder") : false, + // minimumResultsForSearch: select.attr("data-minimum-results-search") ? select.attr("data-minimum-results-search") : -1, + // maximumSelectionSize: 3, + // dropdownCssClass: select.attr("data-class") ? select.attr("data-class") : '' + // }); + // } + // } // RD Mailform - if (plugins.rdMailForm.length) { - var i, j, k, - msg = { - 'MF000': 'Successfully sent!', - 'MF001': 'Recipients are not set!', - 'MF002': 'Form will not work locally!', - 'MF003': 'Please, define email field in your form!', - 'MF004': 'Please, define type of your form!', - 'MF254': 'Something went wrong with PHPMailer!', - 'MF255': 'Aw, snap! Something went wrong.' - }; + // if (plugins.rdMailForm.length) { + // var i, j, k, + // msg = { + // 'MF000': 'Successfully sent!', + // 'MF001': 'Recipients are not set!', + // 'MF002': 'Form will not work locally!', + // 'MF003': 'Please, define email field in your form!', + // 'MF004': 'Please, define type of your form!', + // 'MF254': 'Something went wrong with PHPMailer!', + // 'MF255': 'Aw, snap! Something went wrong.' + // }; - for (i = 0; i < plugins.rdMailForm.length; i++) { - var $form = $(plugins.rdMailForm[i]), - formHasCaptcha = false; + // for (i = 0; i < plugins.rdMailForm.length; i++) { + // var $form = $(plugins.rdMailForm[i]), + // formHasCaptcha = false; - $form.attr('novalidate', 'novalidate').ajaxForm({ - data: { - "form-type": $form.attr("data-form-type") || "contact", - "counter": i - }, - beforeSubmit: function (arr, $form, options) { - if (isNoviBuilder) - return; + // $form.attr('novalidate', 'novalidate').ajaxForm({ + // data: { + // "form-type": $form.attr("data-form-type") || "contact", + // "counter": i + // }, + // beforeSubmit: function (arr, $form, options) { + // if (isNoviBuilder) + // return; - var form = $(plugins.rdMailForm[this.extraData.counter]), - inputs = form.find("[data-constraints]"), - output = $("#" + form.attr("data-form-output")), - captcha = form.find('.recaptcha'), - captchaFlag = true; + // var form = $(plugins.rdMailForm[this.extraData.counter]), + // inputs = form.find("[data-constraints]"), + // output = $("#" + form.attr("data-form-output")), + // captcha = form.find('.recaptcha'), + // captchaFlag = true; - output.removeClass("active error success"); + // output.removeClass("active error success"); - if (isValidated(inputs, captcha)) { + // if (isValidated(inputs, captcha)) { - // veify reCaptcha - if (captcha.length) { - var captchaToken = captcha.find('.g-recaptcha-response').val(), - captchaMsg = { - 'CPT001': 'Please, setup you "site key" and "secret key" of reCaptcha', - 'CPT002': 'Something wrong with google reCaptcha' - }; + // // veify reCaptcha + // if (captcha.length) { + // var captchaToken = captcha.find('.g-recaptcha-response').val(), + // captchaMsg = { + // 'CPT001': 'Please, setup you "site key" and "secret key" of reCaptcha', + // 'CPT002': 'Something wrong with google reCaptcha' + // }; - formHasCaptcha = true; + // formHasCaptcha = true; - $.ajax({ - method: "POST", - url: "bat/reCaptcha.php", - data: { 'g-recaptcha-response': captchaToken }, - async: false - }) - .done(function (responceCode) { - if (responceCode !== 'CPT000') { - if (output.hasClass("snackbars")) { - output.html('

' + captchaMsg[responceCode] + '

') + // $.ajax({ + // method: "POST", + // url: "bat/reCaptcha.php", + // data: { 'g-recaptcha-response': captchaToken }, + // async: false + // }) + // .done(function (responceCode) { + // if (responceCode !== 'CPT000') { + // if (output.hasClass("snackbars")) { + // output.html('

' + captchaMsg[responceCode] + '

') - setTimeout(function () { - output.removeClass("active"); - }, 3500); + // setTimeout(function () { + // output.removeClass("active"); + // }, 3500); - captchaFlag = false; - } else { - output.html(captchaMsg[responceCode]); - } + // captchaFlag = false; + // } else { + // output.html(captchaMsg[responceCode]); + // } - output.addClass("active"); - } - }); - } + // output.addClass("active"); + // } + // }); + // } - if (!captchaFlag) { - return false; - } + // if (!captchaFlag) { + // return false; + // } - form.addClass('form-in-process'); + // form.addClass('form-in-process'); - if (output.hasClass("snackbars")) { - output.html('

Sending

'); - output.addClass("active"); - } - } else { - return false; - } - }, - error: function (result) { - if (isNoviBuilder) - return; + // if (output.hasClass("snackbars")) { + // output.html('

Sending

'); + // output.addClass("active"); + // } + // } else { + // return false; + // } + // }, + // error: function (result) { + // if (isNoviBuilder) + // return; - var output = $("#" + $(plugins.rdMailForm[this.extraData.counter]).attr("data-form-output")), - form = $(plugins.rdMailForm[this.extraData.counter]); + // var output = $("#" + $(plugins.rdMailForm[this.extraData.counter]).attr("data-form-output")), + // form = $(plugins.rdMailForm[this.extraData.counter]); - output.text(msg[result]); - form.removeClass('form-in-process'); + // output.text(msg[result]); + // form.removeClass('form-in-process'); - if (formHasCaptcha) { - grecaptcha.reset(); - } - }, - success: function (result) { - if (isNoviBuilder) - return; + // if (formHasCaptcha) { + // grecaptcha.reset(); + // } + // }, + // success: function (result) { + // if (isNoviBuilder) + // return; - var form = $(plugins.rdMailForm[this.extraData.counter]), - output = $("#" + form.attr("data-form-output")), - select = form.find('select'); + // var form = $(plugins.rdMailForm[this.extraData.counter]), + // output = $("#" + form.attr("data-form-output")), + // select = form.find('select'); - form - .addClass('success') - .removeClass('form-in-process'); + // form + // .addClass('success') + // .removeClass('form-in-process'); - if (formHasCaptcha) { - grecaptcha.reset(); - } + // if (formHasCaptcha) { + // grecaptcha.reset(); + // } - result = result.length === 5 ? result : 'MF255'; - output.text(msg[result]); + // result = result.length === 5 ? result : 'MF255'; + // output.text(msg[result]); - if (result === "MF000") { - if (output.hasClass("snackbars")) { - output.html('

' + msg[result] + '

'); - } else { - output.addClass("active success"); - } - } else { - if (output.hasClass("snackbars")) { - output.html('

' + msg[result] + '

'); - } else { - output.addClass("active error"); - } - } + // if (result === "MF000") { + // if (output.hasClass("snackbars")) { + // output.html('

' + msg[result] + '

'); + // } else { + // output.addClass("active success"); + // } + // } else { + // if (output.hasClass("snackbars")) { + // output.html('

' + msg[result] + '

'); + // } else { + // output.addClass("active error"); + // } + // } - form.clearForm(); + // form.clearForm(); - if (select.length) { - select.select2("val", ""); - } + // if (select.length) { + // select.select2("val", ""); + // } - form.find('input, textarea').trigger('blur'); + // form.find('input, textarea').trigger('blur'); - setTimeout(function () { - output.removeClass("active error success"); - form.removeClass('success'); - }, 3500); - } - }); - } - } + // setTimeout(function () { + // output.removeClass("active error success"); + // form.removeClass('success'); + // }, 3500); + // } + // }); + // } + // } // RD Filepicker if ((plugins.filePicker.length || plugins.fileDrop.length) && !isNoviBuilder) { diff --git a/src/blocks/Content.ts b/src/blocks/Content.ts new file mode 100644 index 0000000..fed61d9 --- /dev/null +++ b/src/blocks/Content.ts @@ -0,0 +1,15 @@ +import { lexicalEditor } from "@payloadcms/richtext-lexical"; +import { Block } from "payload"; + +export const ContentBlock: Block = { + slug: "contentBlock", + labels: { plural: "Contents", singular: "Content" }, + fields: [ + { + name: "content", + type: "richText", + editor: lexicalEditor({}), + required: true, + }, + ], +}; diff --git a/src/blocks/Form.ts b/src/blocks/Form.ts new file mode 100644 index 0000000..dc687b6 --- /dev/null +++ b/src/blocks/Form.ts @@ -0,0 +1,33 @@ +import type { Block } from "payload"; + +export const FormBlock: Block = { + slug: "formBlock", + fields: [ + { + name: "form", + type: "relationship", + relationTo: "forms", + required: true, + }, + { + name: "enableIntro", + type: "checkbox", + label: "Enable Intro Content", + }, + { + name: "introContent", + type: "richText", + admin: { + condition: (_, { enableIntro }) => Boolean(enableIntro), + }, + label: "Intro Content", + }, + ], + graphQL: { + singularName: "FormBlock", + }, + labels: { + plural: "Form Blocks", + singular: "Form Block", + }, +}; diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts new file mode 100644 index 0000000..ee23f1e --- /dev/null +++ b/src/collections/Pages.ts @@ -0,0 +1,92 @@ +import { ContentBlock } from "@/blocks/Content"; +import { FormBlock } from "@/blocks/Form"; +import formatSlug from "@/utils/payload/formatSlug"; +import setAuthor from "@/utils/payload/setAuthor"; +import { CollectionConfig } from "payload"; + +export const Pages: CollectionConfig = { + slug: "pages", + versions: { + drafts: { + validate: true, + }, + }, + fields: [ + { + name: "title", + label: "Page Title", + type: "text", + required: true, + }, + { + name: "hero_img", + label: "Hero Image", + type: "upload", + relationTo: "media", + }, + { + name: "slug", + label: "Page Slug", + type: "text", + hooks: { + beforeValidate: [formatSlug("title")], + }, + }, + { + name: "layout", + label: "Page Layout", + type: "blocks", + minRows: 1, + blocks: [ContentBlock, FormBlock], + }, + { + name: "meta", + label: "Page Meta", + type: "group", + fields: [ + { + name: "title", + label: "Title", + type: "text", + }, + { + name: "description", + label: "Description", + type: "textarea", + }, + { + name: "canonical_url", + label: "Canonical Url", + type: "text", + }, + ], + }, + { + name: "createdBy", + type: "relationship", + relationTo: "users", + hooks: { + beforeChange: [setAuthor], + }, + admin: { + hidden: true, + }, + }, + { + name: "updatedBy", + type: "relationship", + relationTo: "users", + hooks: { + beforeChange: [setAuthor], + }, + admin: { + hidden: true, + }, + }, + ], + admin: { + hideAPIURL: true, + group: "General", + useAsTitle: "title", + }, +}; diff --git a/src/components/ContactFormBox.tsx b/src/components/ContactFormBox.tsx index 9fbc71b..af79ac3 100644 --- a/src/components/ContactFormBox.tsx +++ b/src/components/ContactFormBox.tsx @@ -1,66 +1,23 @@ +"use client"; + +import { useFormQuery } from "@/services/hooks/form"; +import { useEffect } from "react"; +import { FormBlock } from "@/components/blocks/Form"; + export default function ContactFormBox() { + const form = useFormQuery(); + + useEffect(() => { + form._fetch(1); + }, []); + return (
-
-

Get in Touch with Us Today!

-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- - -
-
-
-
- -
-
- -
-
-
+
{!form.isFetching && !!form.data && }
); diff --git a/src/components/ContactFormSection.tsx b/src/components/ContactFormSection.tsx index ce1963b..059cda4 100644 --- a/src/components/ContactFormSection.tsx +++ b/src/components/ContactFormSection.tsx @@ -1,66 +1,23 @@ +"use client"; + +import { FormBlock } from "@/components/blocks/Form"; +import { useFormQuery } from "@/services/hooks/form"; +import { useEffect } from "react"; + export default function ContactFormSection() { + const form = useFormQuery(); + + useEffect(() => { + form._fetch(1); + }, []); + return (
-
-

Get in Touch with Us Today!

-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- - -
-
-
-
- -
-
- -
-
-
+
{!form.isFetching && !!form.data && }
); diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx index 50575e2..d4326d5 100644 --- a/src/components/HeroSlider.tsx +++ b/src/components/HeroSlider.tsx @@ -1,10 +1,10 @@ "use client"; +import Image from "next/image"; +import { useState } from "react"; +import { Autoplay } from "swiper/modules"; import { Swiper, SwiperSlide } from "swiper/react"; import ContactFormBox from "./ContactFormBox"; -import { Autoplay } from "swiper/modules"; -import { useState } from "react"; -import Image from "next/image"; type HeroSliderProps = { onClickBook?: () => void; diff --git a/src/components/blocks/Content/index.tsx b/src/components/blocks/Content/index.tsx new file mode 100644 index 0000000..38ac7a9 --- /dev/null +++ b/src/components/blocks/Content/index.tsx @@ -0,0 +1,26 @@ +import { RichText } from "@payloadcms/richtext-lexical/react"; + +// type Props = extract + +export function ContentBlock(props: any) { + return ( +
+
+ {/* Content */} +
+ {/* Post */} +
+
+
+ {/* @ts-ignore */} + +
+
+
+ {/* End Post */} +
+ {/* End Content */} +
+
+ ); +} diff --git a/src/components/blocks/Form/Button/index.tsx b/src/components/blocks/Form/Button/index.tsx new file mode 100644 index 0000000..643ed1b --- /dev/null +++ b/src/components/blocks/Form/Button/index.tsx @@ -0,0 +1,54 @@ +import type { ElementType } from "react"; + +import Link from "next/link"; +import React from "react"; + +export type Props = { + appearance?: "default" | "primary" | "secondary"; + className?: string; + el?: "a" | "button" | "link"; + form?: string; + href?: string; + label?: string; + newTab?: boolean | null; + onClick?: () => void; + isLoading?: boolean; +}; + +export const Button: React.FC = ({ + className: classNameFromProps, + el = "button", + form, + href, + label, + newTab, + isLoading, +}) => { + const newTabProps = newTab ? { rel: "noopener noreferrer", target: "_blank" } : {}; + const Element: ElementType = el; + const className = [classNameFromProps].filter(Boolean).join(" "); + + const elementProps = { + ...newTabProps, + className, + form, + href, + }; + + return ( + + + {(el === "link" || el === "a") && ( + + {label} + + )} + {el === "button" && ( + <> + {label} {isLoading && } + + )} + + + ); +}; diff --git a/src/components/blocks/Form/Checkbox/index.tsx b/src/components/blocks/Form/Checkbox/index.tsx new file mode 100644 index 0000000..938335c --- /dev/null +++ b/src/components/blocks/Form/Checkbox/index.tsx @@ -0,0 +1,34 @@ +import type { CheckboxField } from "@payloadcms/plugin-form-builder/types"; +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from "react-hook-form"; + +import React from "react"; + +import { Error } from "../Error"; +import { Width } from "../Width"; + +export const Checkbox: React.FC< + { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any; + }> + >; + getValues: any; + register: UseFormRegister; + setValue: any; + } & CheckboxField +> = ({ name, errors, label, register, required: requiredFromProps, width }) => { + return ( + +
+
+ + +
+ {requiredFromProps && errors[name] && } +
+
+ ); +}; diff --git a/src/components/blocks/Form/Email/index.tsx b/src/components/blocks/Form/Email/index.tsx new file mode 100644 index 0000000..841ee92 --- /dev/null +++ b/src/components/blocks/Form/Email/index.tsx @@ -0,0 +1,35 @@ +import type { EmailField } from "@payloadcms/plugin-form-builder/types"; +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from "react-hook-form"; + +import React from "react"; + +import { Error } from "../Error"; +import { Width } from "../Width"; + +export const Email: React.FC< + { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any; + }> + >; + register: UseFormRegister; + } & EmailField +> = ({ name, errors, label, register, required: requiredFromProps, width }) => { + return ( + +
+ + + {requiredFromProps && errors[name] && } +
+
+ ); +}; diff --git a/src/components/blocks/Form/Error/index.scss b/src/components/blocks/Form/Error/index.scss new file mode 100644 index 0000000..38792cc --- /dev/null +++ b/src/components/blocks/Form/Error/index.scss @@ -0,0 +1,4 @@ +.error { + margin-top: 5px; + color: var(--color-red); +} diff --git a/src/components/blocks/Form/Error/index.tsx b/src/components/blocks/Form/Error/index.tsx new file mode 100644 index 0000000..1324ba9 --- /dev/null +++ b/src/components/blocks/Form/Error/index.tsx @@ -0,0 +1,5 @@ +import * as React from "react"; + +export const Error: React.FC = () => { + return
This field is required
; +}; diff --git a/src/components/blocks/Form/Link/index.tsx b/src/components/blocks/Form/Link/index.tsx new file mode 100644 index 0000000..ab255a2 --- /dev/null +++ b/src/components/blocks/Form/Link/index.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import React from "react"; + +// @ts-ignore +import type { Page } from "@/payload-types"; + +import { Button } from "../Button"; + +export type CMSLinkType = { + appearance?: "default" | "primary" | "secondary"; + children?: React.ReactNode; + className?: string; + label?: string; + newTab?: boolean | null; + reference?: { + relationTo: "pages"; + value: number | Page | string; + } | null; + type?: "custom" | "reference" | null; + url?: null | string; +}; + +export const CMSLink: React.FC = ({ + type, + appearance, + children, + className, + label, + newTab, + reference, + url, +}) => { + const href = + type === "reference" && typeof reference?.value === "object" && reference.value.slug + ? `${reference?.relationTo !== "pages" ? `/${reference?.relationTo}` : ""}/${reference.value.slug}` + : url; + + if (!href) { + return null; + } + + if (!appearance) { + const newTabProps = newTab ? { rel: "noopener noreferrer", target: "_blank" } : {}; + + if (type === "custom") { + return ( + + {label && label} + {children ? <>{children} : null} + + ); + } + + if (href) { + return ( + + {label && label} + {children ? <>{children} : null} + + ); + } + } + + const buttonProps = { + appearance, + href, + label, + newTab, + }; + + return