22
33import { CheckIcon , PaperPlaneIcon , SpinnerIcon } from '@phosphor-icons/react' ;
44import { useState } from 'react' ;
5+ import { toast } from 'sonner' ;
56import { SciFiButton } from '@/components/landing/scifi-btn' ;
67import { Input } from '@/components/ui/input' ;
78import { Label } from '@/components/ui/label' ;
@@ -34,11 +35,13 @@ function FormField({
3435 required = false ,
3536 children,
3637 description,
38+ error,
3739} : {
3840 label : string ;
3941 required ?: boolean ;
4042 children : React . ReactNode ;
4143 description ?: string ;
44+ error ?: string ;
4245} ) {
4346 return (
4447 < div className = "space-y-2" >
@@ -47,7 +50,10 @@ function FormField({
4750 { required && < span className = "ml-1 text-destructive" > *</ span > }
4851 </ Label >
4952 { children }
50- { description && (
53+ { error && (
54+ < p className = "text-destructive text-xs" > { error } </ p >
55+ ) }
56+ { description && ! error && (
5157 < p className = "text-muted-foreground text-xs" > { description } </ p >
5258 ) }
5359 </ div >
@@ -58,37 +64,138 @@ export default function AmbassadorForm() {
5864 const [ formData , setFormData ] = useState < FormData > ( initialFormData ) ;
5965 const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
6066 const [ isSubmitted , setIsSubmitted ] = useState ( false ) ;
67+ const [ errors , setErrors ] = useState < Partial < Record < keyof FormData , string > > > ( { } ) ;
68+
69+ const validateForm = ( ) : boolean => {
70+ const newErrors : Partial < Record < keyof FormData , string > > = { } ;
71+
72+ // Required field validations
73+ if ( ! formData . name . trim ( ) ) {
74+ newErrors . name = 'Name is required' ;
75+ } else if ( formData . name . trim ( ) . length < 2 ) {
76+ newErrors . name = 'Name must be at least 2 characters' ;
77+ }
78+
79+ if ( ! formData . email . trim ( ) ) {
80+ newErrors . email = 'Email is required' ;
81+ } else if ( ! formData . email . includes ( '@' ) || ! formData . email . includes ( '.' ) ) {
82+ newErrors . email = 'Please enter a valid email address' ;
83+ }
84+
85+ if ( ! formData . whyAmbassador . trim ( ) ) {
86+ newErrors . whyAmbassador = 'Please explain why you want to be an ambassador' ;
87+ } else if ( formData . whyAmbassador . trim ( ) . length < 10 ) {
88+ newErrors . whyAmbassador = 'Please provide more details (minimum 10 characters)' ;
89+ }
90+
91+ // Optional field validations
92+ if ( formData . xHandle && ( formData . xHandle . includes ( '@' ) || formData . xHandle . includes ( 'http' ) ) ) {
93+ newErrors . xHandle = 'X handle should not include @ or URLs' ;
94+ }
95+
96+ if ( formData . website && formData . website . trim ( ) ) {
97+ try {
98+ new URL ( formData . website ) ;
99+ } catch {
100+ newErrors . website = 'Please enter a valid URL' ;
101+ }
102+ }
103+
104+ setErrors ( newErrors ) ;
105+ return Object . keys ( newErrors ) . length === 0 ;
106+ } ;
61107
62108 const handleInputChange = (
63109 e : React . ChangeEvent < HTMLInputElement | HTMLTextAreaElement >
64110 ) => {
65111 const { name, value } = e . target ;
66112 setFormData ( ( prev ) => ( { ...prev , [ name ] : value } ) ) ;
113+
114+ // Clear error when user starts typing
115+ if ( errors [ name as keyof FormData ] ) {
116+ setErrors ( ( prev ) => ( { ...prev , [ name ] : undefined } ) ) ;
117+ }
67118 } ;
68119
69120 const handleSubmit = async ( e : React . FormEvent ) => {
70121 e . preventDefault ( ) ;
122+
123+ // Client-side validation
124+ if ( ! validateForm ( ) ) {
125+ toast . error ( 'Please fix the validation errors before submitting.' ) ;
126+ return ;
127+ }
128+
71129 setIsSubmitting ( true ) ;
72130
73131 try {
132+ const controller = new AbortController ( ) ;
133+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , 30000 ) ; // 30 second timeout
134+
74135 const response = await fetch ( '/api/ambassador/submit' , {
75136 method : 'POST' ,
76137 headers : {
77138 'Content-Type' : 'application/json' ,
78139 } ,
79140 body : JSON . stringify ( formData ) ,
141+ signal : controller . signal ,
80142 } ) ;
81143
82- const data = await response . json ( ) ;
144+ clearTimeout ( timeoutId ) ;
145+
146+ let data ;
147+ try {
148+ data = await response . json ( ) ;
149+ } catch ( parseError ) {
150+ throw new Error ( 'Invalid response from server. Please try again.' ) ;
151+ }
83152
84153 if ( ! response . ok ) {
85- throw new Error ( data . error || 'Submission failed' ) ;
154+ // Handle specific error cases
155+ if ( response . status === 429 ) {
156+ const resetTime = data . resetTime ? new Date ( data . resetTime ) . toLocaleTimeString ( ) : 'soon' ;
157+ throw new Error ( `Too many submissions. Please try again after ${ resetTime } .` ) ;
158+ }
159+
160+ if ( response . status === 400 && data . details ) {
161+ // Show validation errors
162+ const errorMessage = Array . isArray ( data . details )
163+ ? data . details . join ( '\n• ' )
164+ : data . error || 'Validation failed' ;
165+ throw new Error ( `Please fix the following issues:\n• ${ errorMessage } ` ) ;
166+ }
167+
168+ throw new Error ( data . error || 'Submission failed. Please try again.' ) ;
86169 }
87170
171+ toast . success ( 'Application submitted successfully!' , {
172+ description : 'We\'ll review your application and get back to you within 3-5 business days.' ,
173+ duration : 5000 ,
174+ } ) ;
88175 setIsSubmitted ( true ) ;
89176 } catch ( error ) {
90177 console . error ( 'Form submission error:' , error ) ;
91- alert ( 'Failed to submit application. Please try again.' ) ;
178+
179+ if ( error instanceof Error ) {
180+ // Handle specific error types
181+ if ( error . name === 'AbortError' ) {
182+ toast . error ( 'Request timed out. Please check your connection and try again.' ) ;
183+ } else {
184+ // Handle multi-line error messages
185+ const errorLines = error . message . split ( '\n' ) ;
186+ if ( errorLines . length > 1 ) {
187+ // For validation errors with multiple lines, show as error toast
188+ toast . error ( errorLines [ 0 ] , {
189+ description : errorLines . slice ( 1 ) . join ( '\n' ) ,
190+ duration : 5000 ,
191+ } ) ;
192+ } else {
193+ toast . error ( error . message ) ;
194+ }
195+ }
196+ } else {
197+ toast . error ( 'Failed to submit application. Please try again.' ) ;
198+ }
92199 } finally {
93200 setIsSubmitting ( false ) ;
94201 }
@@ -155,8 +262,13 @@ export default function AmbassadorForm() {
155262 < form className = "space-y-6" onSubmit = { handleSubmit } >
156263 { /* Personal Information */ }
157264 < div className = "grid grid-cols-1 gap-6 md:grid-cols-2" >
158- < FormField label = "Full Name" required >
265+ < FormField
266+ error = { errors . name }
267+ label = "Full Name"
268+ required
269+ >
159270 < Input
271+ className = { errors . name ? 'border-destructive' : '' }
160272 maxLength = { 100 }
161273 name = "name"
162274 onChange = { handleInputChange }
@@ -167,8 +279,13 @@ export default function AmbassadorForm() {
167279 />
168280 </ FormField >
169281
170- < FormField label = "Email Address" required >
282+ < FormField
283+ error = { errors . email }
284+ label = "Email Address"
285+ required
286+ >
171287 < Input
288+ className = { errors . email ? 'border-destructive' : '' }
172289 maxLength = { 255 }
173290 name = "email"
174291 onChange = { handleInputChange }
@@ -184,9 +301,11 @@ export default function AmbassadorForm() {
184301 < div className = "grid grid-cols-1 gap-6 md:grid-cols-2" >
185302 < FormField
186303 description = "Enter your X (Twitter) handle without the @"
304+ error = { errors . xHandle }
187305 label = "X (Twitter) Handle"
188306 >
189307 < Input
308+ className = { errors . xHandle ? 'border-destructive' : '' }
190309 maxLength = { 50 }
191310 name = "xHandle"
192311 onChange = { handleInputChange }
@@ -198,9 +317,11 @@ export default function AmbassadorForm() {
198317
199318 < FormField
200319 description = "Your personal website, blog, or portfolio"
320+ error = { errors . website }
201321 label = "Website"
202322 >
203323 < Input
324+ className = { errors . website ? 'border-destructive' : '' }
204325 maxLength = { 500 }
205326 name = "website"
206327 onChange = { handleInputChange }
@@ -232,10 +353,12 @@ export default function AmbassadorForm() {
232353 { /* Motivation */ }
233354 < FormField
234355 description = "Required field (max 1000 characters)"
356+ error = { errors . whyAmbassador }
235357 label = "Why do you want to be a Databuddy ambassador?"
236358 required
237359 >
238360 < Textarea
361+ className = { errors . whyAmbassador ? 'border-destructive' : '' }
239362 maxLength = { 1000 }
240363 name = "whyAmbassador"
241364 onChange = { handleInputChange }
0 commit comments