Skip to content

Commit 3db0bde

Browse files
committed
feat: error handling and form validation in docs
1 parent f924e4b commit 3db0bde

File tree

2 files changed

+131
-6
lines changed

2 files changed

+131
-6
lines changed

apps/docs/app/(home)/ambassadors/ambassador-form.tsx

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { CheckIcon, PaperPlaneIcon, SpinnerIcon } from '@phosphor-icons/react';
44
import { useState } from 'react';
5+
import { toast } from 'sonner';
56
import { SciFiButton } from '@/components/landing/scifi-btn';
67
import { Input } from '@/components/ui/input';
78
import { 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}

apps/docs/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Head from 'next/head';
66
import Script from 'next/script';
77
import { ThemeProvider } from 'next-themes';
88
import type { ReactNode } from 'react';
9+
import { Toaster } from '@/components/ui/sonner';
910
import { SITE_URL } from './util/constants';
1011

1112
const geist = Geist({
@@ -111,6 +112,7 @@ export default function Layout({ children }: { children: ReactNode }) {
111112
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
112113
<RootProvider>
113114
<main>{children}</main>
115+
<Toaster closeButton duration={1500} position="top-center" richColors />
114116
</RootProvider>
115117
</ThemeProvider>
116118
</body>

0 commit comments

Comments
 (0)