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:
@@ -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" />
|
||||
|
||||
@@ -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
11
lib/validation/contact.ts
Normal 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>;
|
||||
24
package.json
24
package.json
@@ -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
501
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user