-
-
Security verification
-
Google reCAPTCHA placeholder
-
-
-
+import { useEffect, useRef, useState } from 'react'
+
+const siteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY
+
+let recaptchaScriptPromise
+
+const loadRecaptchaScript = () => {
+ if (window.grecaptcha?.render) return Promise.resolve(window.grecaptcha)
+ if (recaptchaScriptPromise) return recaptchaScriptPromise
+
+ recaptchaScriptPromise = new Promise((resolve, reject) => {
+ const existingScript = document.querySelector('script[src^="https://www.google.com/recaptcha/api.js"]')
+ if (existingScript) {
+ existingScript.addEventListener('load', () => resolve(window.grecaptcha), { once: true })
+ existingScript.addEventListener('error', reject, { once: true })
+ return
+ }
+
+ const script = document.createElement('script')
+ script.src = 'https://www.google.com/recaptcha/api.js?render=explicit'
+ script.async = true
+ script.defer = true
+ script.onload = () => resolve(window.grecaptcha)
+ script.onerror = reject
+ document.head.appendChild(script)
+ })
+
+ return recaptchaScriptPromise
+}
+
+const RecaptchaPlaceholder = ({ error = '', onVerify, onExpired, resetKey = 0 }) => {
+ const containerRef = useRef(null)
+ const widgetIdRef = useRef(null)
+ const [isReady, setIsReady] = useState(false)
+ const [loadError, setLoadError] = useState('')
+
+ useEffect(() => {
+ if (!siteKey || !containerRef.current) return undefined
+
+ let isMounted = true
+
+ loadRecaptchaScript()
+ .then((grecaptcha) => {
+ grecaptcha.ready(() => {
+ if (!isMounted || !containerRef.current || widgetIdRef.current !== null) return
+
+ widgetIdRef.current = grecaptcha.render(containerRef.current, {
+ sitekey: siteKey,
+ callback: (token) => {
+ onVerify?.(token)
+ },
+ 'expired-callback': () => {
+ onExpired?.()
+ },
+ 'error-callback': () => {
+ onExpired?.()
+ setLoadError('Security verification could not be completed. Please try again.')
+ },
+ })
+ setIsReady(true)
+ })
+ })
+ .catch(() => {
+ if (isMounted) {
+ setLoadError('Security verification could not load. Please refresh and try again.')
+ }
+ })
+
+ return () => {
+ isMounted = false
+ }
+ }, [onExpired, onVerify])
+
+ useEffect(() => {
+ if (widgetIdRef.current === null || !window.grecaptcha?.reset) return
+ window.grecaptcha.reset(widgetIdRef.current)
+ }, [resetKey])
+
+ if (!siteKey) {
+ return (
+
+
Security verification is not configured.
+ )
+ }
+
+ return (
+
+
+ {!isReady && !loadError &&
Loading security verification...
}
+ {(error || loadError) &&
{error || loadError}
}
- {error &&
{error}
}
-
-)
+ )
+}
export default RecaptchaPlaceholder
diff --git a/src/lib/api.js b/src/lib/api.js
index c916b28..02741ff 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -1,4 +1,4 @@
-const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
+const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
export async function submitLead(data) {
const response = await fetch(`${API_BASE_URL}/leads`, {
diff --git a/src/pages/Contact.jsx b/src/pages/Contact.jsx
index 126e909..80f9171 100644
--- a/src/pages/Contact.jsx
+++ b/src/pages/Contact.jsx
@@ -1,14 +1,16 @@
import SEO from '@/components/SEO'
-import { useState, useRef, useEffect } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder'
import { ArrowRight } from 'lucide-react'
+import { submitLead } from '@/lib/api'
+
+const isRecaptchaConfigured = Boolean(import.meta.env.VITE_RECAPTCHA_SITE_KEY)
const Contact = () => {
- const formRef = useRef(null)
const [formState, setFormState] = useState({
'Last Name': '',
Company: '',
@@ -16,6 +18,7 @@ const Contact = () => {
Phone: '',
'Zip Code': '',
Description: '',
+ company_website: '',
})
const [errors, setErrors] = useState({
'Last Name': '',
@@ -27,47 +30,14 @@ const Contact = () => {
})
const [debouncedErrors, setDebouncedErrors] = useState(errors)
const [isSubmitting, setIsSubmitting] = useState(false)
+ const [recaptchaToken, setRecaptchaToken] = useState('')
+ const [recaptchaResetKey, setRecaptchaResetKey] = useState(0)
useEffect(() => {
const t = setTimeout(() => setDebouncedErrors(errors), 300)
return () => clearTimeout(t)
}, [errors])
- useEffect(() => {
- const iframe = document.getElementById('zoho_webform_iframe')
- if (!iframe) return
-
- let fallbackTimer = null
-
- const handleSuccess = () => {
- if (fallbackTimer) clearTimeout(fallbackTimer)
- setIsSubmitting(false)
- toast.success("Thanks! We'll be in touch shortly.")
- setFormState({ 'Last Name': '', Company: '', Email: '', Phone: '', 'Zip Code': '', Description: '' })
- setErrors({ 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' })
- }
-
- const handleLoad = () => { if (isSubmitting) handleSuccess() }
-
- if (isSubmitting) {
- fallbackTimer = setTimeout(handleSuccess, 3500)
- }
-
- iframe.addEventListener('load', handleLoad)
- return () => {
- iframe.removeEventListener('load', handleLoad)
- if (fallbackTimer) clearTimeout(fallbackTimer)
- }
- }, [isSubmitting])
-
- useEffect(() => {
- const script = document.createElement('script')
- script.id = 'wf_anal'
- script.src = 'https://crm.zohopublic.com/crm/WebFormAnalyticsServeServlet?rid=e44e9662530fc5bd9cdd3c43501fc243f89ba03759e7946c4b5e5016795b606b59b54d0e73c68671b2140fac5c8e788agid3b907524e85f9cba94899d77d7200771ee5d0ea567c43ec341d7b2ce40324d40gid26922a9cd1e8191a5f58ecb2524e0d22b8dd027eb943658ee681ab6890436af2gidefa1b1002d15951a0a2ac36cb33cdb4b5c6aeb110e6f4ac68b764345b9429653&tw=e048253ca680b107993ed5922e00cc1ebab3de97e797fce56fc6ad6af0dfc0bc'
- document.body.appendChild(script)
- return () => { document.getElementById('wf_anal')?.remove() }
- }, [])
-
const validateForm = () => {
const newErrors = { 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' }
if (!formState.Company.trim()) newErrors.Company = 'Company name is required'
@@ -80,6 +50,9 @@ const Contact = () => {
} else if (!emailRegex.test(formState.Email)) {
newErrors.Email = 'Please enter a valid email address'
}
+ if (isRecaptchaConfigured && !recaptchaToken) {
+ newErrors.recaptcha_token = 'Security verification is required'
+ }
const hasErrors = Object.values(newErrors).some(error => error !== '')
setErrors(newErrors)
if (hasErrors) {
@@ -89,14 +62,61 @@ const Contact = () => {
return true
}
- const handleSubmit = (e) => {
+ const resetForm = () => {
+ setFormState({
+ 'Last Name': '',
+ Company: '',
+ Email: '',
+ Phone: '',
+ 'Zip Code': '',
+ Description: '',
+ company_website: '',
+ })
+ setErrors({ 'Last Name': '', Company: '', Email: '', 'Zip Code': '', Description: '', recaptcha_token: '' })
+ setRecaptchaToken('')
+ setRecaptchaResetKey(prev => prev + 1)
+ }
+
+ const mapApiErrors = (fields = {}) => ({
+ 'Last Name': fields.name || '',
+ Company: fields.company || '',
+ Email: fields.email || '',
+ 'Zip Code': fields.zip || '',
+ Description: fields.message || '',
+ recaptcha_token: fields.recaptcha_token || '',
+ })
+
+ const handleSubmit = async (e) => {
e.preventDefault()
if (!validateForm()) return
setIsSubmitting(true)
- if (typeof _wfa_track !== 'undefined' && _wfa_track.wfa_submit) {
- _wfa_track.wfa_submit(e)
+
+ try {
+ const result = await submitLead({
+ company: formState.Company,
+ name: formState['Last Name'],
+ email: formState.Email,
+ phone: formState.Phone,
+ zip: formState['Zip Code'],
+ message: formState.Description,
+ recaptcha_token: recaptchaToken,
+ company_website: formState.company_website,
+ })
+
+ toast.success(result.message || "Thanks! We'll be in touch shortly.")
+ resetForm()
+ } catch (err) {
+ if (err.fields) {
+ setErrors(mapApiErrors(err.fields))
+ if (err.fields.recaptcha_token) {
+ setRecaptchaToken('')
+ setRecaptchaResetKey(prev => prev + 1)
+ }
+ }
+ toast.error(err.message || 'Failed to submit lead')
+ } finally {
+ setIsSubmitting(false)
}
- formRef.current.submit()
}
const handleChange = (e) => {
@@ -105,6 +125,16 @@ const Contact = () => {
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }))
}
+ const handleRecaptchaVerify = useCallback((token) => {
+ setRecaptchaToken(token)
+ setErrors(prev => ({ ...prev, recaptcha_token: '' }))
+ }, [])
+
+ const handleRecaptchaExpired = useCallback(() => {
+ setRecaptchaToken('')
+ setErrors(prev => ({ ...prev, recaptcha_token: 'Security verification expired. Please try again.' }))
+ }, [])
+
const contactDetails = [
{
label: 'Phone',
@@ -240,26 +270,22 @@ const Contact = () => {
We typically respond within one business day.
-
-