feat: client-side form validation + Sonner feedback (v0.4.4)

This commit is contained in:
null 2026-05-13 18:10:04 -05:00
parent 21b5418461
commit 931c9a9095
3 changed files with 140 additions and 5 deletions

View File

@ -1,7 +1,7 @@
{
"name": "queuenorth-website",
"private": true,
"version": "0.4.3",
"version": "0.4.4",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server/index.js\"",

View File

@ -18,6 +18,12 @@ const Contact = () => {
message: '',
service_interest: '',
})
const [errors, setErrors] = useState({
company: '',
name: '',
email: '',
message: '',
})
const mutation = useMutation({
mutationFn: (data) => api.post('/leads', data),
@ -32,20 +38,63 @@ const Contact = () => {
message: '',
service_interest: '',
})
setErrors({
company: '',
name: '',
email: '',
message: '',
})
},
onError: (error) => {
toast.error(error.message || 'Failed to submit form. Please try again.')
},
})
const validateForm = () => {
const newErrors = {
company: '',
name: '',
email: '',
message: '',
}
// Validate required fields
if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.message.trim()) newErrors.message = 'Message is required'
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formState.email.trim()) {
newErrors.email = 'Email is required'
} else if (!emailRegex.test(formState.email)) {
newErrors.email = 'Please enter a valid email address'
}
const hasErrors = Object.values(newErrors).some(error => error !== '')
setErrors(newErrors)
if (hasErrors) {
toast.error('Please fix the errors in the form')
return false
}
return true
}
const handleSubmit = (e) => {
e.preventDefault()
if (!validateForm()) return
mutation.mutate(formState)
}
const handleChange = (e) => {
const { name, value } = e.target
setFormState(prev => ({ ...prev, [name]: value }))
// Clear error for this field as user types
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }))
}
}
return (
@ -101,7 +150,7 @@ const Contact = () => {
{/* Right - Form */}
<div>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div>
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
Company Name <span className="text-red-600">*</span>
@ -114,7 +163,11 @@ const Contact = () => {
onChange={handleChange}
required
placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
)}
</div>
<div>
@ -129,7 +182,11 @@ const Contact = () => {
onChange={handleChange}
required
placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
)}
</div>
<div>
@ -144,7 +201,11 @@ const Contact = () => {
onChange={handleChange}
required
placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
)}
</div>
<div>
@ -200,7 +261,7 @@ const Contact = () => {
<label htmlFor="message" className="block text-sm font-medium text-text mb-2">
Message <span className="text-red-600">*</span>
</label>
<Textarea
<textarea
id="message"
name="message"
value={formState.message}
@ -208,7 +269,11 @@ const Contact = () => {
required
placeholder="Tell us about your needs..."
rows={5}
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.message ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/>
{errors.message && (
<p className="text-xs text-red-600 mt-1">{errors.message}</p>
)}
</div>
<Button

View File

@ -17,6 +17,12 @@ const Support = () => {
issue: '',
priority: 'medium',
})
const [errors, setErrors] = useState({
name: '',
company: '',
email: '',
issue: '',
})
const mutation = useMutation({
mutationFn: (data) => api.post('/support', data),
@ -30,20 +36,68 @@ const Support = () => {
issue: '',
priority: 'medium',
})
setErrors({
name: '',
company: '',
email: '',
issue: '',
})
},
onError: (error) => {
toast.error(error.message || 'Failed to submit form. Please try again.')
},
})
const validateForm = () => {
const newErrors = {
name: '',
company: '',
email: '',
issue: '',
}
// Validate required fields
if (!formState.name.trim()) newErrors.name = 'Name is required'
if (!formState.company.trim()) newErrors.company = 'Company name is required'
if (!formState.issue.trim()) newErrors.issue = 'Please describe your issue'
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formState.email.trim()) {
newErrors.email = 'Email is required'
} else if (!emailRegex.test(formState.email)) {
newErrors.email = 'Please enter a valid email address'
}
// Validate issue minimum length (10 chars matches server-side Zod rule)
if (formState.issue.trim().length < 10) {
newErrors.issue = 'Issue description must be at least 10 characters'
}
const hasErrors = Object.values(newErrors).some(error => error !== '')
setErrors(newErrors)
if (hasErrors) {
toast.error('Please fix the errors in the form')
return false
}
return true
}
const handleSubmit = (e) => {
e.preventDefault()
if (!validateForm()) return
mutation.mutate(formState)
}
const handleChange = (e) => {
const { name, value } = e.target
setFormState(prev => ({ ...prev, [name]: value }))
// Clear error for this field as user types
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }))
}
}
return (
@ -104,7 +158,7 @@ const Support = () => {
{/* Right - Form */}
<div>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
Name <span className="text-red-600">*</span>
@ -117,7 +171,11 @@ const Support = () => {
onChange={handleChange}
required
placeholder="Your full name"
className={errors.name ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name}</p>
)}
</div>
<div>
@ -132,7 +190,11 @@ const Support = () => {
onChange={handleChange}
required
placeholder="Your company name"
className={errors.company ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.company && (
<p className="text-xs text-red-600 mt-1">{errors.company}</p>
)}
</div>
<div>
@ -147,7 +209,11 @@ const Support = () => {
onChange={handleChange}
required
placeholder="your.email@example.com"
className={errors.email ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{errors.email && (
<p className="text-xs text-red-600 mt-1">{errors.email}</p>
)}
</div>
<div>
@ -184,7 +250,7 @@ const Support = () => {
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
Describe Your Issue <span className="text-red-600">*</span>
</label>
<Textarea
<textarea
id="issue"
name="issue"
value={formState.issue}
@ -192,7 +258,11 @@ const Support = () => {
required
placeholder="Please describe your issue in detail..."
rows={5}
className={`flex min-h-[80px] w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${errors.issue ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/>
{errors.issue && (
<p className="text-xs text-red-600 mt-1">{errors.issue}</p>
)}
</div>
<Button