Last active
March 15, 2025 00:51
-
-
Save ptcampbell/b485ae8e6fa3307f8fd054e474f9154e to your computer and use it in GitHub Desktop.
Resend Form with Turnstile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useState, useEffect } from 'react' | |
import type { FormEvent } from 'react' | |
interface ContactFormProps { | |
recipientName?: string | |
recipientEmail: string | |
} | |
export default function ContactForm({ | |
recipientName, | |
recipientEmail, | |
}: ContactFormProps) { | |
const [formData, setFormData] = useState({ | |
name: import.meta.env.DEV ? 'John Doe' : '', | |
email: import.meta.env.DEV ? '[email protected]' : '', | |
phone: '', | |
subject: import.meta.env.DEV ? 'Test Contact Form' : '', | |
message: import.meta.env.DEV | |
? 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc ultricies aliquam.' | |
: '', | |
privacyPolicy: false, | |
}) | |
const [errors, setErrors] = useState<Record<string, string>>({}) | |
const [submitStatus, setSubmitStatus] = useState<{ | |
type: 'success' | 'error' | null | |
message: string | |
}>({ type: null, message: '' }) | |
const [isLoading, setIsLoading] = useState(false) | |
const [showForm, setShowForm] = useState(true) | |
const validate = () => { | |
const newErrors: Record<string, string> = {} | |
if (formData.name.length < 2) { | |
newErrors.name = 'Name must be at least 2 characters' | |
} | |
if (!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { | |
newErrors.email = 'Invalid email address' | |
} | |
if (formData.subject.length < 2) { | |
newErrors.subject = 'Subject must be at least 2 characters' | |
} | |
if (formData.message.length < 10) { | |
newErrors.message = 'Message must be at least 10 characters' | |
} | |
if (!formData.privacyPolicy) { | |
newErrors.privacyPolicy = 'You must accept the privacy policy' | |
} | |
setErrors(newErrors) | |
return Object.keys(newErrors).length === 0 | |
} | |
const handleSubmit = async (e: FormEvent) => { | |
e.preventDefault() | |
if (validate()) { | |
try { | |
setIsLoading(true) | |
setSubmitStatus({ type: null, message: '' }) | |
// Get turnstile token | |
// @ts-ignore - Turnstile is added by the script | |
const turnstileToken = window.turnstile.getResponse() | |
if (!turnstileToken) { | |
setSubmitStatus({ | |
type: 'error', | |
message: 'Please complete the security check', | |
}) | |
return | |
} | |
const formDataToSend = new FormData() | |
formDataToSend.append('name', formData.name) | |
formDataToSend.append('email', formData.email) | |
formDataToSend.append('phone', formData.phone) | |
formDataToSend.append('recipientEmail', recipientEmail) | |
formDataToSend.append('recipientName', recipientName || 'Contact Form') | |
formDataToSend.append('subject', formData.subject) | |
formDataToSend.append('message', formData.message) | |
formDataToSend.append('cf-turnstile-response', turnstileToken) | |
const response = await fetch('/api/mail', { | |
method: 'POST', | |
body: formDataToSend, | |
}) | |
if (response.ok) { | |
setSubmitStatus({ | |
type: 'success', | |
message: 'Message sent successfully! We will get back to you soon.', | |
}) | |
setShowForm(false) | |
setFormData({ | |
name: '', | |
email: import.meta.env.DEV ? '[email protected]' : '', | |
phone: '', | |
subject: '', | |
message: '', | |
privacyPolicy: false, | |
}) | |
} else { | |
const error = await response.json() | |
setSubmitStatus({ | |
type: 'error', | |
message: error.error || 'Failed to send message. Please try again.', | |
}) | |
} | |
} catch (error) { | |
setSubmitStatus({ | |
type: 'error', | |
message: 'An error occurred. Please try again later.', | |
}) | |
} finally { | |
setIsLoading(false) | |
} | |
} | |
} | |
useEffect(() => { | |
if (submitStatus.type === 'success') { | |
// @ts-ignore - Turnstile is added by the script | |
window.turnstile?.reset() | |
} | |
}, [submitStatus]) | |
const handleInputChange = ( | |
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | |
) => { | |
setFormData({ ...formData, [e.target.id]: e.target.value }) | |
window.dispatchEvent(new Event('formChanged')) | |
} | |
return ( | |
<div> | |
<div className={showForm ? 'block' : 'hidden'}> | |
<div className="@container"> | |
<div className="flex flex-col-reverse items-start @[760px]:flex-row gap-4 @[760px]:gap-8 w-full"> | |
<div className="relative flex-1"> | |
<form | |
onSubmit={handleSubmit} | |
className="space-y-6 mb-8" | |
autoComplete="off" | |
> | |
{submitStatus.type === 'error' && ( | |
<div className="p-4 rounded-lg bg-red/5 border border-red/10 text-red mb-4"> | |
{submitStatus.message} | |
</div> | |
)} | |
<div className="relative"> | |
<input | |
type="text" | |
id="name" | |
value={formData.name} | |
onChange={handleInputChange} | |
className="w-full p-4 py-3 border rounded-lg mt-1 border-cascades/20 focus:border-cascades/75 focus:outline-none peer placeholder-transparent" | |
placeholder="Your name" | |
data-autocomplete="off" | |
/> | |
<label | |
htmlFor="name" | |
className="absolute left-4 -top-[0.4rem] bg-white px-1 text-sm transition-all peer-placeholder-shown:text-base peer-placeholder-shown:top-[1.1rem] peer-placeholder-shown:text-gray-400 peer-focus:-top-[0.4rem] peer-focus:text-sm font-medium" | |
> | |
Name | |
</label> | |
{errors.name && ( | |
<span className="text-red text-xs font-medium"> | |
{errors.name} | |
</span> | |
)} | |
</div> | |
<div className="relative"> | |
<input | |
type="email" | |
id="email" | |
value={formData.email} | |
onChange={handleInputChange} | |
className="w-full p-4 py-3 border rounded-lg mt-1 border-cascades/20 focus:border-cascades/75 focus:outline-none peer placeholder-transparent" | |
placeholder="[email protected]" | |
data-autocomplete="off" | |
/> | |
<label | |
htmlFor="email" | |
className="absolute left-4 -top-[0.4rem] bg-white px-1 text-sm transition-all peer-placeholder-shown:text-base peer-placeholder-shown:top-[1.1rem] peer-placeholder-shown:text-gray-400 peer-focus:-top-[0.4rem] peer-focus:text-sm font-medium" | |
> | |
Email{import.meta.env.DEV ? ' (Test mode)' : ''} | |
</label> | |
{errors.email && ( | |
<span className="text-red text-xs font-medium"> | |
{errors.email} | |
</span> | |
)} | |
</div> | |
<div className="relative"> | |
<input | |
type="tel" | |
id="phone" | |
value={formData.phone} | |
onChange={handleInputChange} | |
className="w-full p-4 py-3 border rounded-lg mt-1 border-cascades/20 focus:border-cascades/75 focus:outline-none peer placeholder-transparent" | |
placeholder="Your phone number (Optional)" | |
data-autocomplete="off" | |
/> | |
<label | |
htmlFor="phone" | |
className="absolute left-4 -top-[0.4rem] bg-white px-1 text-sm transition-all peer-placeholder-shown:text-base peer-placeholder-shown:top-[1.1rem] peer-placeholder-shown:text-gray-400 peer-focus:-top-[0.4rem] peer-focus:text-sm font-medium" | |
> | |
Phone <span className="opacity-50">(Optional)</span> | |
</label> | |
</div> | |
<div className="relative"> | |
<input | |
type="text" | |
id="subject" | |
value={formData.subject} | |
onChange={handleInputChange} | |
className="w-full p-4 py-3 border rounded-lg mt-1 border-cascades/20 focus:border-cascades/75 focus:outline-none peer placeholder-transparent font-medium" | |
placeholder="Subject of your message" | |
data-autocomplete="off" | |
/> | |
<label | |
htmlFor="subject" | |
className="absolute left-4 -top-[0.4rem] bg-white px-1 text-sm transition-all peer-placeholder-shown:text-base peer-placeholder-shown:top-[1.1rem] peer-placeholder-shown:text-gray-400 peer-focus:-top-[0.4rem] peer-focus:text-sm font-medium" | |
> | |
Subject | |
</label> | |
{errors.subject && ( | |
<span className="text-red text-xs font-medium"> | |
{errors.subject} | |
</span> | |
)} | |
</div> | |
<div className="relative"> | |
<textarea | |
id="message" | |
value={formData.message} | |
onChange={handleInputChange} | |
className="w-full p-4 py-3 border rounded-lg mt-1 border-cascades/20 focus:border-cascades/75 focus:outline-none peer placeholder-transparent min-h-[120px]" | |
placeholder="Your message" | |
data-autocomplete="off" | |
/> | |
<label | |
htmlFor="message" | |
className="absolute left-4 -top-[0.4rem] bg-white px-1 text-sm transition-all peer-placeholder-shown:text-base peer-placeholder-shown:top-[1.1rem] peer-placeholder-shown:text-gray-400 peer-focus:-top-[0.4rem] peer-focus:text-sm font-medium" | |
> | |
Message | |
</label> | |
{errors.message && ( | |
<span className="text-red text-xs font-medium"> | |
{errors.message} | |
</span> | |
)} | |
</div> | |
<div> | |
<label className="flex items-center gap-2 mb-2"> | |
<input | |
type="checkbox" | |
checked={formData.privacyPolicy} | |
onChange={(e) => | |
setFormData({ | |
...formData, | |
privacyPolicy: e.target.checked, | |
}) | |
} | |
/> | |
<span>I acknowledge and accept the privacy policy</span> | |
</label> | |
{errors.privacyPolicy && ( | |
<span className="text-red text-xs font-medium block"> | |
{errors.privacyPolicy} | |
</span> | |
)} | |
</div> | |
<div> | |
<div | |
className="cf-turnstile" | |
data-theme="light" | |
data-sitekey={import.meta.env.TURNSTILE_SITE_KEY} | |
></div> | |
</div> | |
<button | |
type="submit" | |
disabled={isLoading} | |
className={`bg-cascades text-white py-2 px-4 rounded-full transition-all font-medium flex items-center justify-center gap-2 min-w-[140px] ${ | |
isLoading | |
? 'opacity-50 cursor-not-allowed' | |
: 'hover:bg-blue-600' | |
}`} | |
> | |
{isLoading ? ( | |
<> | |
<span className="animate-spin"> | |
<svg className="w-5 h-5" viewBox="0 0 24 24"> | |
<circle | |
className="opacity-25" | |
cx="12" | |
cy="12" | |
r="10" | |
stroke="currentColor" | |
strokeWidth="4" | |
fill="none" | |
/> | |
<path | |
className="opacity-75" | |
fill="currentColor" | |
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" | |
/> | |
</svg> | |
</span> | |
<span>Sending...</span> | |
</> | |
) : ( | |
<> | |
<svg | |
className="w-4 h-4" | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
strokeWidth={2} | |
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" | |
/> | |
</svg> | |
<span>Send Message</span> | |
</> | |
)} | |
</button> | |
<div className="text-xs text-cascades/75"> | |
<p className="mb-2"> | |
Your message will be sent securely. You'll receive a | |
confirmation email. | |
</p> | |
<p className="mb-2"> | |
We aim to respond within 24 hours. For urgent matters, | |
please include your phone number. | |
</p> | |
<p> | |
If you're experiencing an emergency, do not use this form. | |
Call emergency services immediately. | |
</p> | |
</div> | |
{import.meta.env.DEV && ( | |
<div className="border-t border-dashed border-gray-200 mt-8 pt-4 space-y-2"> | |
<label className="flex items-center gap-2 text-xs opacity-50"> | |
<input | |
type="checkbox" | |
checked={submitStatus.type === 'error'} | |
onChange={() => | |
setSubmitStatus( | |
submitStatus.type === 'error' | |
? { type: null, message: '' } | |
: { | |
type: 'error', | |
message: 'Debug error message', | |
} | |
) | |
} | |
/> | |
<span>Simulate Error</span> | |
</label> | |
<label className="flex items-center gap-2 text-xs opacity-50"> | |
<input | |
type="checkbox" | |
checked={submitStatus.type === 'success'} | |
onChange={() => { | |
setSubmitStatus( | |
submitStatus.type === 'success' | |
? { type: null, message: '' } | |
: { | |
type: 'success', | |
message: 'Debug success message', | |
} | |
) | |
setShowForm(submitStatus.type === 'success') | |
}} | |
/> | |
<span>Simulate Success</span> | |
</label> | |
</div> | |
)} | |
</form> | |
</div> | |
<div className="bg-[#D9E4EF]/25 rounded-lg font-medium p-4 mb-4 @[760px]:w-[300px] w-full text-cascades/85"> | |
<div className="space-y-5"> | |
<div> | |
<h3 className="uppercase text-xs font-semibold tracking-wide text-left opacity-50 mb-2 mt-1"> | |
Getting Started | |
</h3> | |
<ul className="list-disc ml-4 space-y-1 text-sm"> | |
<li>Tell us about yourself</li> | |
<li>Share your reason for contact</li> | |
<li>Preferred availability</li> | |
<li>Contact preference</li> | |
</ul> | |
</div> | |
<div> | |
<h3 className="uppercase text-xs font-semibold tracking-wide text-left opacity-50 mb-2 mt-1"> | |
Your Request | |
</h3> | |
<ul className="list-disc ml-4 text-sm"> | |
<li> | |
Let us know if you'd like to: | |
<ul className="list-disc ml-6 mt-1 space-y-1"> | |
<li>Schedule a consultation</li> | |
<li>Learn about our services</li> | |
<li>Discuss pricing</li> | |
<li>Ask other questions</li> | |
</ul> | |
</li> | |
</ul> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
{submitStatus.type === 'success' && ( | |
<div className={showForm ? 'hidden' : 'block'}> | |
<div className="text-center p-8 bg-green/10 rounded-lg mx-auto"> | |
<svg | |
className="w-16 h-16 mx-auto text-green mb-4" | |
fill="none" | |
stroke="currentColor" | |
viewBox="0 0 24 24" | |
> | |
<path | |
strokeLinecap="round" | |
strokeLinejoin="round" | |
strokeWidth={2} | |
d="M5 13l4 4L19 7" | |
/> | |
</svg> | |
<p>{submitStatus.message}</p> | |
</div> | |
</div> | |
)} | |
</div> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment