From b357188b2da71bfdbe70672c54f91f11befdb1e4 Mon Sep 17 00:00:00 2001 From: Dulapah Vibulsanti Date: Sat, 10 Aug 2024 16:18:08 +0100 Subject: [PATCH] feat: use react-hook-form and yup for validation --- package.json | 8 +- pnpm-lock.yaml | 49 ++++++++ src/ui/contact-form/contact-form.tsx | 170 ++++++++++----------------- src/ui/contact-form/validator.ts | 40 +++++++ 4 files changed, 160 insertions(+), 107 deletions(-) create mode 100644 src/ui/contact-form/validator.ts diff --git a/package.json b/package.json index 67d37ee..72cfddc 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,15 @@ "dev": "next dev --turbo", "build": "prisma generate --no-engine && prisma migrate deploy && next build", "start": "next start", - "lint": "next lint", + "lint:check": "prettier . --check", + "lint:format": "prettier . --write", "db:generate": "prisma generate --no-engine", "db:migrate": "prisma migrate deploy", "db:studio": "prisma studio", "test:e2e": "playwright test" }, "dependencies": { + "@hookform/resolvers": "^3.9.0", "@marsidev/react-turnstile": "^0.7.2", "@mdxeditor/editor": "^3.11.0", "@nextui-org/react": "^2.4.6", @@ -39,6 +41,7 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.3.1", "react-email": "2.1.4", + "react-hook-form": "^7.52.2", "react-markdown": "^9.0.1", "rehype-autolink-headings": "^7.1.0", "rehype-highlight": "^7.0.0", @@ -52,7 +55,8 @@ "schema-dts": "^1.1.2", "sonner": "^1.5.0", "tailwind-merge": "^2.4.0", - "use-debounce": "^10.0.2" + "use-debounce": "^10.0.2", + "yup": "^1.4.0" }, "devDependencies": { "@cloudflare/next-on-pages": "^1.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6d2555..3be30b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.9.0(react-hook-form@7.52.2(react@18.3.1)) '@marsidev/react-turnstile': specifier: ^0.7.2 version: 0.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -97,6 +100,9 @@ importers: react-email: specifier: 2.1.4 version: 2.1.4(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.12)(bufferutil@4.0.8)(eslint@9.9.0(jiti@1.21.6))(ts-node@10.9.2(@swc/core@1.7.10(@swc/helpers@0.5.12))(@types/node@20.14.15)(typescript@5.5.4))(utf-8-validate@6.0.3) + react-hook-form: + specifier: ^7.52.2 + version: 7.52.2(react@18.3.1) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.3.3)(react@18.3.1) @@ -139,6 +145,9 @@ importers: use-debounce: specifier: ^10.0.2 version: 10.0.2(react@18.3.1) + yup: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: '@cloudflare/next-on-pages': specifier: ^1.13.2 @@ -843,6 +852,11 @@ packages: '@formatjs/intl-localematcher@0.5.4': resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + '@hookform/resolvers@3.9.0': + resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} @@ -6850,6 +6864,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -7479,6 +7496,9 @@ packages: resolution: {integrity: sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==} engines: {node: '>=10'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -7491,6 +7511,9 @@ packages: resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==} engines: {node: '>=0.6'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -7561,6 +7584,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -7947,6 +7974,9 @@ packages: youch@3.3.3: resolution: {integrity: sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==} + yup@1.4.0: + resolution: {integrity: sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==} + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -8722,6 +8752,10 @@ snapshots: dependencies: tslib: 2.6.3 + '@hookform/resolvers@3.9.0(react-hook-form@7.52.2(react@18.3.1))': + dependencies: + react-hook-form: 7.52.2(react@18.3.1) + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.3.0': {} @@ -16433,6 +16467,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-expr@2.0.6: {} + property-information@6.5.0: {} proto-list@1.2.4: {} @@ -17288,6 +17324,8 @@ snapshots: dependencies: convert-hrtime: 3.0.0 + tiny-case@1.0.3: {} + to-fast-properties@2.0.0: {} to-regex-range@5.0.1: @@ -17296,6 +17334,8 @@ snapshots: toidentifier@1.0.0: {} + toposort@2.0.2: {} + tr46@0.0.3: {} tree-kill@1.2.2: {} @@ -17373,6 +17413,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@2.19.0: {} + type@2.7.3: {} typed-array-buffer@1.0.2: @@ -17822,6 +17864,13 @@ snapshots: mustache: 4.2.0 stacktracey: 2.1.8 + yup@1.4.0: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zod@3.23.8: {} zwitch@2.0.4: {} diff --git a/src/ui/contact-form/contact-form.tsx b/src/ui/contact-form/contact-form.tsx index 7242c46..fc07bbf 100644 --- a/src/ui/contact-form/contact-form.tsx +++ b/src/ui/contact-form/contact-form.tsx @@ -1,23 +1,26 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import { - Button, - Input, - Select, - Selection, - SelectItem, - Textarea, -} from "@nextui-org/react"; +import { useState } from "react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Button, Input, Select, SelectItem, Textarea } from "@nextui-org/react"; import { ChevronsUpDown, Send } from "lucide-react"; import { isMobile } from "react-device-detect"; +import { useForm } from "react-hook-form"; import { ErrorResponse } from "resend"; import { toast } from "sonner"; import { useOnLeavePageConfirmation } from "@/hooks/use-on-leave-page-confirmation"; -import { contactTypeOptions } from "@/lib/constants"; +import { + contactTypeOptions, + EMAIL_MAX_LENGTH, + MESSAGE_MAX_LENGTH, + NAME_MAX_LENGTH, +} from "@/lib/constants"; +import { EmailTemplateProps } from "@/types/types"; import { Captcha } from "@/ui/captcha"; +import { schema } from "./validator"; + interface ContactFormProps { name: string; email: string; @@ -25,71 +28,44 @@ interface ContactFormProps { message: string; } -const NAME_MAX_LENGTH = 180; -const EMAIL_MAX_LENGTH = 180; -const MESSAGE_MAX_LENGTH = 1000; - export function ContactForm({ name: _name, email: _email, type: _type, message: _message, }: ContactFormProps) { - const [name, setName] = useState(_name); - const [email, setEmail] = useState(_email); - const [type, setType] = useState(new Set([_type])); - const [message, setMessage] = useState(_message); - - const [isNameInvalid, setIsNameInvalid] = useState(false); - const [isEmailInvalid, setIsEmailInvalid] = useState(false); - const [isMessageInvalid, setIsMessageInvalid] = useState(false); + const { + register, + handleSubmit, + formState: { errors, isDirty, isSubmitting }, + watch, + reset, + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: _name, + email: _email, + type: _type, + message: _message, + }, + }); const [isCaptchaSuccess, setIsCaptchaSuccess] = useState(false); - const [isDirty, setIsDirty] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - useMemo(() => { - if (name !== "" || email !== "" || message !== "") { - setIsDirty(true); - } else { - setIsDirty(false); - } - }, [name, email, message]); - useOnLeavePageConfirmation(isDirty); - const isEmailCorrect = useMemo(() => { - if (!email) return false; - - return email.match(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,4}$/i) - ? false - : true; - }, [email]); - - const handleSend = useCallback(async () => { - if (!name) setIsNameInvalid(true); - if (!email) setIsEmailInvalid(true); - if (!message) setIsMessageInvalid(true); - - if (!name || !email || !message) { - toast.error("Please fill in all required fields"); - return; - } - + async function onSubmitHandler(data: EmailTemplateProps) { if (!isCaptchaSuccess) { toast.error("Please complete the captcha"); return; } - setIsLoading(true); - - const typeLabel: string = - contactTypeOptions.find((item) => item.key === Array.from(type)[0]) - ?.label || contactTypeOptions[0].label; + const type = contactTypeOptions.find( + (option) => option.key === data.type, + )?.label; const response = await fetch( - `api/send?name=${name}&email=${email}&type=${typeLabel}&message=${message}`, + `api/send?name=${data.name.trim()}&email=${data.email.trim()}&type=${type}&message=${data.message.trim()}`, { method: "POST", }, @@ -100,55 +76,41 @@ export function ContactForm({ toast.error( `An error occurred while sending your message.\nStatus: ${response.status}\nReason: ${error.name} - ${error.message}`, ); - setIsLoading(false); return; } toast.success( "Your message has been sent successfully!\nI'll get back to you as soon as possible.", ); - setIsLoading(false); - }, [name, email, type, message, isCaptchaSuccess]); - const handleNameChange = useCallback((value: string) => { - if (value.length <= NAME_MAX_LENGTH) { - setName(value); - setIsNameInvalid(false); - } - }, []); - - const handleEmailChange = useCallback((value: string) => { - if (value.length <= EMAIL_MAX_LENGTH) { - setEmail(value); - setIsEmailInvalid(false); - } - }, []); + reset(); + } - const handleMessageChange = useCallback((value: string) => { - if (value.length <= MESSAGE_MAX_LENGTH) { - setMessage(value); - setIsMessageInvalid(false); - } - }, []); + function onSubmitErrorHandler() { + toast.error("Please complete the form correctly"); + return; + } return ( -
+

Full Name

- {name.length}/{NAME_MAX_LENGTH} + {watch("name")?.length || 0}/{NAME_MAX_LENGTH}

} - value={name} - onValueChange={handleNameChange} - isInvalid={isNameInvalid} - color={isNameInvalid ? "danger" : "default"} - errorMessage="Please enter your full name" + isInvalid={!!errors.name} + color={errors.name ? "danger" : "default"} + errorMessage={errors.name?.message} placeholder="Dulapah Vibulsanti" radius="sm" labelPlacement="outside" @@ -158,22 +120,21 @@ export function ContactForm({ }} />

Email

- {email.length}/{EMAIL_MAX_LENGTH} + {watch("email")?.length || 0}/{EMAIL_MAX_LENGTH}

} type="email" - value={email} - onValueChange={handleEmailChange} - isInvalid={isEmailCorrect || isEmailInvalid} - color={isEmailCorrect ? "danger" : "default"} - errorMessage="Please enter a valid email" + isInvalid={!!errors.email} + color={errors.email ? "danger" : "default"} + errorMessage={errors.email?.message} placeholder="dulapah@example.com" radius="sm" labelPlacement="outside" @@ -183,9 +144,9 @@ export function ContactForm({ }} />