From cc57ed59bda1d654d2bc4534e634846423ee339d Mon Sep 17 00:00:00 2001 From: Igor Kowalczyk Date: Fri, 13 Dec 2024 23:19:10 +0100 Subject: [PATCH] Add custom hooks and validation schemas; refactor button components --- app/api/contact/route.ts | 90 ++----------- app/contact/page.tsx | 6 +- app/not-found.tsx | 6 +- app/page.tsx | 22 +-- app/work/page.tsx | 6 +- components/Button.tsx | 100 ++++---------- components/ProjectCard.tsx | 10 +- components/client/ContactForm.tsx | 216 ++++++++++++------------------ components/client/MobileNav.tsx | 8 +- components/client/Settings.tsx | 11 +- lib/hooks.ts | 17 +++ lib/validator.ts | 13 ++ package.json | 4 +- pnpm-lock.yaml | 21 ++- 14 files changed, 214 insertions(+), 316 deletions(-) create mode 100644 lib/hooks.ts create mode 100644 lib/validator.ts diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index b82f587c0..fe3c833d4 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,89 +1,23 @@ -import isEmail from "validator/lib/isEmail"; +import { contactFormSchema } from "@/lib/validator"; export const runtime = "edge"; export async function POST(request: Request) { - const { email, name, message } = await request.clone().json(); + const body = await request.json(); - if (!email) { - return new Response( - JSON.stringify({ - error: true, - type: "email", - message: "Please enter your email address!", - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - if (typeof email !== "string" || !isEmail(email)) { - return new Response( - JSON.stringify({ - error: true, - type: "email", - message: "Please enter a valid email address!", - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - }, - } - ); - } + const result = contactFormSchema.safeParse(body); - if (email.trim().length < 5 || email.trim().length > 50) { - return new Response( - JSON.stringify({ - error: true, - type: "email", - message: "Email must be between 5 and 50 characters!", - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - }, - } - ); - } - - if (!name || typeof name !== "string" || !name.trim() || name.trim().length < 3 || name.trim().length > 20) { - return new Response( - JSON.stringify({ - error: true, - type: "name", - message: "Name must be between 3 and 20 characters!", - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - }, - } - ); + if (!result.success) { + const errors = result.error.flatten().fieldErrors; + return new Response(JSON.stringify({ error: true, message: Object.values(errors).flat().join(" ") }), { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }); } - if (!message || typeof message !== "string" || !message.trim() || message.trim().length < 10 || message.trim().length > 500) { - return new Response( - JSON.stringify({ - error: true, - type: "message", - message: "Message must be between 10 and 500 characters!", - }), - { - status: 400, - headers: { - "Content-Type": "application/json", - }, - } - ); - } + const { name, email, message } = result.data; const embed = { title: "šŸ“© New message from igorkowalczyk.dev", diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 0d977ad4a..1c8c0f7b2 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,4 +1,4 @@ -import { ButtonTertiary } from "@/components/Button"; +import { Button } from "@/components/Button"; import { ContactForm } from "@/components/client/ContactForm"; import { Description, Header2 } from "@/components/Headers"; import { contact } from "@/config"; @@ -21,9 +21,9 @@ export default function Page() { Or contact me with...
{contact.links.map((element) => ( - + ))}
diff --git a/app/not-found.tsx b/app/not-found.tsx index e34ff2eb2..00bd7cf93 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,11 +1,13 @@ -import { ButtonSecondary } from "@/components/Button"; +import { Button } from "@/components/Button"; export default function NotFound() { return (

404 - Page not found

We're sorry ā€” we can't find the page you're looking for.

- Go home +
); } diff --git a/app/page.tsx b/app/page.tsx index 099c797e3..ab9b2b0e3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { ButtonPrimary, ButtonSecondary, ButtonTertiary } from "../components/Button"; +import { Button } from "../components/Button"; import { Icons } from "../components/Icons"; import { ContactForm } from "@/components/client/ContactForm"; import { Description, Header2 } from "@/components/Headers"; @@ -25,8 +25,12 @@ export default async function HomePage() {

Hey, Iā€™m {header.title}

{header.description}

- Contact me - More about me + +
@@ -58,11 +62,13 @@ export default async function HomePage() {
- + +
@@ -102,9 +108,9 @@ export default async function HomePage() { Or contact me with...
{contact.links.map((element) => ( - + ))}
diff --git a/app/work/page.tsx b/app/work/page.tsx index 3b28dab73..3c7012524 100644 --- a/app/work/page.tsx +++ b/app/work/page.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { ButtonPrimary } from "@/components/Button"; +import { Button } from "@/components/Button"; import { Description, Header1 } from "@/components/Headers"; import { Icons } from "@/components/Icons"; import { ProjectCard } from "@/components/ProjectCard"; @@ -26,10 +26,10 @@ export default function Page() { Want to see more? Check out my GitHub profile for more projects and contributions. - + ); } diff --git a/components/Button.tsx b/components/Button.tsx index a4136a225..fbee13a50 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -1,92 +1,42 @@ +import { cva, VariantProps } from "class-variance-authority"; import Link from "next/link"; +import React from "react"; +import { Icons } from "@/components/Icons"; import { cn } from "@/lib/utils"; -export interface ButtonProps extends React.HTMLAttributes { +export const buttonVariants = cva("group flex w-fit items-center rounded-md px-4 py-2 font-medium duration-200 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none", { + variants: { + variant: { + primary: "bg-blue-500 text-white hover:bg-blue-600", + secondary: "bg-neutral-200 text-neutral-700 hover:bg-neutral-300 dark:bg-white/10 dark:text-white dark:hover:bg-white/15", + tertiary: "border text-neutral-700 hover:bg-[#f2f3f5] dark:border-neutral-800 dark:bg-[#161617] dark:text-white dark:hover:border-neutral-700 dark:hover:bg-[#202021]", + }, + }, + defaultVariants: { + variant: "primary", + }, +}); + +export interface ButtonProps extends React.HTMLAttributes, VariantProps { href?: string; - children: React.ReactNode; icon?: boolean; - type?: "button" | "submit" | "reset"; - disabled?: boolean; -} - -export const buttonPrimaryStyles = "group flex w-fit items-center rounded-md bg-blue-500 px-4 py-2 font-medium text-white duration-200 hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"; - -export function ButtonPrimary({ href, children, icon = true, ...props }: ButtonProps) { - if (href) { - return ( - - {children} - {icon && ( - - - - )} - - ); - } - return ( - - ); + type?: HTMLButtonElement["type"]; + disabled?: HTMLButtonElement["disabled"]; } -export const buttonSecondaryStyles = "group flex w-fit items-center rounded-md bg-neutral-200 px-4 py-2 font-medium text-neutral-700 duration-200 hover:bg-neutral-300 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none dark:bg-white/10 dark:text-white dark:hover:bg-white/15"; - -export function ButtonSecondary({ href, children, icon = true, ...props }: ButtonProps) { - if (href) { - return ( - - {children} - {icon && ( - - - - )} - - ); - } else { - return ( - - ); - } -} - -export const buttonTertiaryStyles = "group flex w-fit items-center rounded-md border px-4 py-2 font-medium text-neutral-700 duration-200 hover:bg-[#f2f3f5] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none dark:border-neutral-800 dark:bg-[#161617] dark:text-white dark:hover:border-neutral-700 dark:hover:bg-[#202021]"; - -export function ButtonTertiary({ href, children, icon = true, ...props }: ButtonProps) { +export const Button = React.forwardRef(({ href, children, icon = true, variant, ...props }, ref) => { if (href) { return ( - + } className={cn(buttonVariants({ variant }), props.className || "")}> {children} - {icon && ( - - - - )} + {icon && } ); } return ( - ); -} +}); diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index 282c81274..ccd934622 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import Link from "next/link"; import { Icons } from "./Icons"; -import { ButtonSecondary, ButtonPrimary } from "@/components/Button"; +import { Button } from "@/components/Button"; import { type Project } from "@/config"; import { parseISO } from "@/lib/utils"; @@ -35,16 +35,16 @@ export function ProjectCard({ project }: ProjectCardProps) {
{project.website && ( - + )} {project.github && ( - + )}
diff --git a/components/client/ContactForm.tsx b/components/client/ContactForm.tsx index bb41f6a3f..984ad753d 100644 --- a/components/client/ContactForm.tsx +++ b/components/client/ContactForm.tsx @@ -1,31 +1,26 @@ "use client"; -import { useState, ChangeEvent, FormEvent, MouseEvent } from "react"; -import isEmail from "validator/lib/isEmail"; -import { Icons } from "../Icons"; -import { ButtonSecondary } from "@/components/Button"; +import { useState, ChangeEvent, FormEvent, useEffect } from "react"; +import { Button } from "@/components/Button"; +import { Icons } from "@/components/Icons"; +import { useDebounce } from "@/lib/hooks"; import { cn } from "@/lib/utils"; - -interface FormData { - email: string; - name: string; - message: string; -} - -interface InvalidData { - email: boolean; - name: boolean; - message: boolean; -} +import { contactFormSchema, ContactFormSchema } from "@/lib/validator"; export function ContactForm() { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ email: "", name: "", message: "", }); - const [invalid, setInvalid] = useState({ + const [invalid, setInvalid] = useState({ + email: false, + name: false, + message: false, + }); + + const [touched, setTouched] = useState({ email: false, name: false, message: false, @@ -35,137 +30,102 @@ export function ContactForm() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const debouncedName = useDebounce(formData.name, 500); + const debouncedEmail = useDebounce(formData.email, 500); + const debouncedMessage = useDebounce(formData.message, 500); + + const validateField = (field: string, value: string) => { + const result = contactFormSchema.safeParse({ ...formData, [field]: value }); + + if (!result.success) { + const errors = result.error.flatten().fieldErrors; + setInvalid((prev) => ({ + ...prev, + [field]: !!errors[field] && value !== "", + })); + } else { + setInvalid((prev) => ({ + ...prev, + [field]: false, + })); + } + }; + + useEffect(() => { + if (touched.name) validateField("name", debouncedName); + }, [debouncedName, touched.name]); + + useEffect(() => { + if (touched.email) validateField("email", debouncedEmail); + }, [debouncedEmail, touched.email]); + + useEffect(() => { + if (touched.message) validateField("message", debouncedMessage); + }, [debouncedMessage, touched.message]); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setSuccess(""); setError(""); - const { email, name, message } = formData; + const result = contactFormSchema.safeParse(formData); - if (!email || !isEmail(email)) { + if (!result.success) { + const errors = result.error.flatten().fieldErrors; setInvalid({ - ...invalid, - email: true, + email: !!errors.email && formData.email !== "", + name: !!errors.name && formData.name !== "", + message: !!errors.message && formData.message !== "", }); - - return setError("Please enter a valid email address!"); - } else if (email.trim().length < 5 || email.trim().length > 50) { - setInvalid({ - ...invalid, - email: true, - }); - return setError("Email address must be between 5 and 50 characters!"); - } else if (!name || name.trim().length === 0) { - setInvalid({ - ...invalid, - name: true, - }); - return setError("Please enter your name!"); - } else if (!name.trim() || name.trim().length < 3 || name.trim().length > 20) { - setInvalid({ - ...invalid, - name: true, - }); - return setError("Name must be between 3 and 20 characters!"); - } else if (!message || message.trim().length === 0) { - setInvalid({ - ...invalid, - message: true, - }); - return setError("Please enter a message!"); - } else if (!message.trim() || message.trim().length < 10 || message.trim().length > 500) { - setInvalid({ - ...invalid, - message: true, - }); - return setError("Message must be between 10 and 500 characters!"); + return setError(Object.values(errors).flat().join(" ")); } - const data = { email, name, message }; + const { data } = result; setLoading(true); - await fetch("/api/contact", { + const request = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), - }) - .then((res) => res.json()) - .then((data) => { - setLoading(false); - if (data.error) { - setInvalid({ - ...invalid, - [data.type]: true, - }); - setError(data.message); - } else { - setFormData({ email: "", name: "", message: "" }); - setInvalid({ - email: false, - name: false, - message: false, - }); - setSuccess(data.message); - } - }); - }; + }); - const handleClick = (e: MouseEvent) => { - e.preventDefault(); - handleSubmit(e as unknown as FormEvent); - }; + const response = await request.json(); - const handleChange = (e: ChangeEvent, type: keyof FormData) => { - setSuccess(""); - setError(""); - - if (type === "email" && (e.target.value.trim().length === 0 || isEmail(e.target.value))) { + setLoading(false); + if (response.error) { setInvalid({ ...invalid, - email: false, + [response.error.field]: true, }); - } else if (type === "email" && (!isEmail(e.target.value) || e.target.value.trim().length < 5 || e.target.value.trim().length > 50)) { + setError(response.error.message); + } else { + setFormData({ email: "", name: "", message: "" }); setInvalid({ - ...invalid, - email: true, - }); - } - - if ((type === "name" && e.target.value.trim().length === 0) || (e.target.value.trim().length >= 3 && e.target.value.trim().length <= 20)) { - setInvalid({ - ...invalid, + email: false, name: false, + message: false, }); - } else if (type === "name" && (e.target.value.trim().length < 3 || e.target.value.trim().length > 20)) { - setInvalid({ - ...invalid, - name: true, - }); + setSuccess(response.message); } + }; - if (type === "message" && e.target.value.trim().length > 500) { - setInvalid({ - ...invalid, - message: true, - }); + const handleChange = (e: ChangeEvent) => { + setSuccess(""); + setError(""); - return setFormData({ - ...formData, - message: e.target.value.trim().slice(0, 500), - }); - } else if (type === "message" && e.target.value.trim().length >= 10 && e.target.value.trim().length <= 500) { - setInvalid({ - ...invalid, - message: false, - }); - } + const { value } = e.target; setFormData({ ...formData, - [e.target.name]: e.target.value, + [e.target.name]: value, + }); + + setTouched({ + ...touched, + [e.target.name]: true, }); }; @@ -180,7 +140,7 @@ export function ContactForm() { handleChange(e, "name")} + onChange={handleChange} id="name" className={cn( { @@ -201,7 +161,7 @@ export function ContactForm() { handleChange(e, "email")} + onChange={handleChange} id="email" className={cn( { @@ -224,7 +184,7 @@ export function ContactForm() {