1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-10-30 03:36:03 -04:00

Enhance contact form with client-side validation and error handling. Import validation schema from shared module and improve user experience by tracking input touch state for error display.

This commit is contained in:
2025-09-02 18:39:54 -04:00
parent a05357bc90
commit 6a0ff897da
5 changed files with 311 additions and 289 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useActionState, useState } from "react";
import { useActionState, useEffect, useState } from "react";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
import Link from "@/components/link";
import Input from "@/components/ui/input";
@@ -8,7 +8,8 @@ import Textarea from "@/components/ui/textarea";
import Button from "@/components/ui/button";
import { MarkdownIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import { send, type ContactState, type ContactInput } from "@/lib/server/resend";
import { send, type ContactState } from "@/lib/server/resend";
import { ContactSchema, type ContactInput } from "@/lib/validation/contact";
const ContactForm = () => {
const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, {
@@ -17,12 +18,34 @@ const ContactForm = () => {
});
// keep track of input so we can repopulate the fields if the form fails
const [formFields, setFormFields] = useState<Partial<ContactInput>>({
const [formFields, setFormFields] = useState<ContactInput>({
name: "",
email: "",
message: "",
});
// client-side validation using shared schema
const [clientErrors, setClientErrors] = useState<Partial<Record<keyof ContactInput, string[]>>>({});
const [touched, setTouched] = useState<{ name: boolean; email: boolean; message: boolean }>({
name: false,
email: false,
message: false,
});
useEffect(() => {
const id = setTimeout(() => {
const result = ContactSchema.safeParse(formFields);
setClientErrors(result.success ? {} : result.error.flatten().fieldErrors);
}, 200);
return () => clearTimeout(id);
}, [formFields]);
const hasClientErrors = Object.values(clientErrors).some((errs) => (errs?.length || 0) > 0);
const nameError = (touched.name ? clientErrors.name?.[0] : undefined) ?? formState.errors?.name?.[0];
const emailError = (touched.email ? clientErrors.email?.[0] : undefined) ?? formState.errors?.email?.[0];
const messageError = (touched.message ? clientErrors.message?.[0] : undefined) ?? formState.errors?.message?.[0];
return (
<form action={formAction} className="my-6 space-y-4">
<div>
@@ -34,12 +57,11 @@ const ContactForm = () => {
onChange={(e) => {
setFormFields({ ...formFields, name: e.target.value });
}}
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
disabled={pending || formState.success}
aria-invalid={formState.errors?.name ? "true" : undefined}
aria-invalid={nameError ? "true" : undefined}
/>
{formState.errors?.name && (
<span className="text-destructive text-[0.8rem] font-semibold">{formState.errors.name[0]}</span>
)}
{nameError && <span className="text-destructive text-[0.8rem] font-semibold">{nameError}</span>}
</div>
<div>
@@ -52,12 +74,11 @@ const ContactForm = () => {
onChange={(e) => {
setFormFields({ ...formFields, email: e.target.value });
}}
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
disabled={pending || formState.success}
aria-invalid={formState.errors?.email ? "true" : undefined}
aria-invalid={emailError ? "true" : undefined}
/>
{formState.errors?.email && (
<span className="text-destructive text-[0.8rem] font-semibold">{formState.errors.email[0]}</span>
)}
{emailError && <span className="text-destructive text-[0.8rem] font-semibold">{emailError}</span>}
</div>
<div>
@@ -68,13 +89,12 @@ const ContactForm = () => {
onChange={(e) => {
setFormFields({ ...formFields, message: e.target.value });
}}
onBlur={() => setTouched((t) => ({ ...t, message: true }))}
disabled={pending || formState.success}
aria-invalid={formState.errors?.message ? "true" : undefined}
aria-invalid={messageError ? "true" : undefined}
className="min-h-[6lh] resize-y"
/>
{formState.errors?.message && (
<span className="text-destructive text-[0.8rem] font-semibold">{formState.errors.message[0]}</span>
)}
{messageError && <span className="text-destructive text-[0.8rem] font-semibold">{messageError}</span>}
<div className="text-foreground/85 my-2 text-[0.8rem] leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" /> Basic{" "}
@@ -91,7 +111,7 @@ const ContactForm = () => {
<div className="flex min-h-16 items-center space-x-4">
{!formState.success && (
<Button type="submit" size="lg" disabled={pending}>
<Button type="submit" size="lg" disabled={pending || hasClientErrors}>
{pending ? (
<>
<Loader2Icon className="animate-spin" />

View File

@@ -2,19 +2,11 @@
import { env } from "@/lib/env";
import { Resend } from "resend";
import { z } from "zod";
import { ContactSchema } from "@/lib/validation/contact";
import siteConfig from "@/lib/config/site";
import { checkBotId } from "botid/server";
const ContactSchema = z
.object({
name: z.string().trim().min(1, { message: "Your name is required." }),
email: z.string().email({ message: "Your email address is required." }),
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
})
.readonly();
export type ContactInput = z.infer<typeof ContactSchema>;
// Schema and type now imported from shared validation module
export type ContactState = {
success: boolean;

11
lib/validation/contact.ts Normal file
View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const ContactSchema = z
.object({
name: z.string().trim().min(1, { message: "Your name is required." }),
email: z.string().email({ message: "Your email address is required." }),
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
})
.readonly();
export type ContactInput = z.infer<typeof ContactSchema>;

View File

@@ -22,11 +22,11 @@
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@date-fns/utc": "^2.1.1",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.0.1",
"@next/bundle-analyzer": "15.5.1-canary.19",
"@next/mdx": "15.5.1-canary.19",
"@next/bundle-analyzer": "15.5.1-canary.23",
"@next/mdx": "15.5.1-canary.23",
"@octokit/graphql": "^9.0.1",
"@octokit/graphql-schema": "^15.26.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -57,7 +57,7 @@
"geist": "^1.4.2",
"html-entities": "^2.6.0",
"lucide-react": "0.542.0",
"next": "15.5.1-canary.19",
"next": "15.5.1-canary.23",
"react": "19.1.1",
"react-activity-calendar": "^2.7.13",
"react-countup": "^6.5.3",
@@ -79,15 +79,15 @@
"rehype-wrapper": "^1.1.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.0",
"remark-mdx": "^3.1.1",
"remark-mdx-frontmatter": "^5.2.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.0.1",
"resend": "^6.0.2",
"server-only": "0.0.1",
"shiki": "^3.12.0",
"shiki": "^3.12.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
@@ -106,10 +106,10 @@
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.0.0",
"dotenv": "^17.2.1",
"dotenv": "^17.2.2",
"drizzle-kit": "^0.31.4",
"eslint": "^9.34.0",
"eslint-config-next": "15.5.1-canary.19",
"eslint-config-next": "15.5.1-canary.23",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3",
@@ -121,7 +121,7 @@
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.5",
"lint-staged": "^16.1.6",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
@@ -134,7 +134,7 @@
"engines": {
"node": ">=22.x"
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b",
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"cacheDirectories": [
"node_modules",
".next/cache"

501
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff