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",
|
"name": "queuenorth-website",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
"dev": "concurrently \"vite\" \"node server/index.js\"",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ const Contact = () => {
|
||||||
message: '',
|
message: '',
|
||||||
service_interest: '',
|
service_interest: '',
|
||||||
})
|
})
|
||||||
|
const [errors, setErrors] = useState({
|
||||||
|
company: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data) => api.post('/leads', data),
|
mutationFn: (data) => api.post('/leads', data),
|
||||||
|
|
@ -32,20 +38,63 @@ const Contact = () => {
|
||||||
message: '',
|
message: '',
|
||||||
service_interest: '',
|
service_interest: '',
|
||||||
})
|
})
|
||||||
|
setErrors({
|
||||||
|
company: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!validateForm()) return
|
||||||
mutation.mutate(formState)
|
mutation.mutate(formState)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
setFormState(prev => ({ ...prev, [name]: value }))
|
setFormState(prev => ({ ...prev, [name]: value }))
|
||||||
|
// Clear error for this field as user types
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -101,7 +150,7 @@ const Contact = () => {
|
||||||
|
|
||||||
{/* Right - Form */}
|
{/* Right - Form */}
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="company" className="block text-sm font-medium text-text mb-2">
|
||||||
Company Name <span className="text-red-600">*</span>
|
Company Name <span className="text-red-600">*</span>
|
||||||
|
|
@ -114,7 +163,11 @@ const Contact = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your company name"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -129,7 +182,11 @@ const Contact = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your full name"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -144,7 +201,11 @@ const Contact = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="your.email@example.com"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -200,7 +261,7 @@ const Contact = () => {
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="message" className="block text-sm font-medium text-text mb-2">
|
||||||
Message <span className="text-red-600">*</span>
|
Message <span className="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
value={formState.message}
|
value={formState.message}
|
||||||
|
|
@ -208,7 +269,11 @@ const Contact = () => {
|
||||||
required
|
required
|
||||||
placeholder="Tell us about your needs..."
|
placeholder="Tell us about your needs..."
|
||||||
rows={5}
|
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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ const Support = () => {
|
||||||
issue: '',
|
issue: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
})
|
})
|
||||||
|
const [errors, setErrors] = useState({
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
email: '',
|
||||||
|
issue: '',
|
||||||
|
})
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data) => api.post('/support', data),
|
mutationFn: (data) => api.post('/support', data),
|
||||||
|
|
@ -30,20 +36,68 @@ const Support = () => {
|
||||||
issue: '',
|
issue: '',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
})
|
})
|
||||||
|
setErrors({
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
email: '',
|
||||||
|
issue: '',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to submit form. Please try again.')
|
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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!validateForm()) return
|
||||||
mutation.mutate(formState)
|
mutation.mutate(formState)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
setFormState(prev => ({ ...prev, [name]: value }))
|
setFormState(prev => ({ ...prev, [name]: value }))
|
||||||
|
// Clear error for this field as user types
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -104,7 +158,7 @@ const Support = () => {
|
||||||
|
|
||||||
{/* Right - Form */}
|
{/* Right - Form */}
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className={`space-y-6 ${mutation.isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="name" className="block text-sm font-medium text-text mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
|
|
@ -117,7 +171,11 @@ const Support = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your full name"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -132,7 +190,11 @@ const Support = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Your company name"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -147,7 +209,11 @@ const Support = () => {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="your.email@example.com"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -184,7 +250,7 @@ const Support = () => {
|
||||||
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
|
<label htmlFor="issue" className="block text-sm font-medium text-text mb-2">
|
||||||
Describe Your Issue <span className="text-red-600">*</span>
|
Describe Your Issue <span className="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<textarea
|
||||||
id="issue"
|
id="issue"
|
||||||
name="issue"
|
name="issue"
|
||||||
value={formState.issue}
|
value={formState.issue}
|
||||||
|
|
@ -192,7 +258,11 @@ const Support = () => {
|
||||||
required
|
required
|
||||||
placeholder="Please describe your issue in detail..."
|
placeholder="Please describe your issue in detail..."
|
||||||
rows={5}
|
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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue