feat: client-side form validation + Sonner feedback (v0.4.4)
This commit is contained in:
parent
21b5418461
commit
931c9a9095
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue