Skip to content

Instantly share code, notes, and snippets.

@ptcampbell
Last active March 15, 2025 00:51
Show Gist options
  • Save ptcampbell/b485ae8e6fa3307f8fd054e474f9154e to your computer and use it in GitHub Desktop.
Save ptcampbell/b485ae8e6fa3307f8fd054e474f9154e to your computer and use it in GitHub Desktop.
Resend Form with Turnstile
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