11'use client'
22
3- import { useState } from 'react'
3+ import { useEffect , useRef , useState } from 'react'
4+ import { Turnstile , type TurnstileInstance } from '@marsidev/react-turnstile'
5+ import { toError } from '@sim/utils/errors'
46import { useMutation } from '@tanstack/react-query'
5- import { Combobox , Input , Textarea } from '@/components/emcn'
7+ import Link from 'next/link'
8+ import { Combobox , type ComboboxOption , Input , Textarea } from '@/components/emcn'
69import { Check } from '@/components/emcn/icons'
7- import { cn } from '@/lib/core/utils/cn '
10+ import { getEnv } from '@/lib/core/config/env '
811import { captureClientEvent } from '@/lib/posthog/client'
912import {
1013 CONTACT_TOPIC_OPTIONS ,
@@ -34,12 +37,28 @@ const INITIAL_FORM_STATE: ContactFormState = {
3437 message : '' ,
3538}
3639
37- const COMBOBOX_TOPICS = [ ...CONTACT_TOPIC_OPTIONS ]
38-
3940const LANDING_INPUT =
40- 'h-[36px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
41+ 'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
42+
43+ const LANDING_TEXTAREA =
44+ 'min-h-[140px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]'
45+
46+ const LANDING_COMBOBOX =
47+ 'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] hover:bg-[var(--landing-bg-surface)] focus-within:border-[var(--landing-border-strong)]'
48+
49+ const LANDING_SUBMIT =
50+ 'flex h-[40px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-text-subtle)] bg-[var(--landing-text-subtle)] font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[var(--landing-bg-hover)] hover:bg-[var(--landing-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60'
51+
52+ const LANDING_LABEL =
53+ 'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'
4154
42- async function submitContactRequest ( payload : ContactRequestPayload ) {
55+ interface SubmitContactRequestInput extends ContactRequestPayload {
56+ website : string
57+ captchaToken ?: string
58+ captchaUnavailable ?: boolean
59+ }
60+
61+ async function submitContactRequest ( payload : SubmitContactRequestInput ) {
4362 const response = await fetch ( '/api/contact' , {
4463 method : 'POST' ,
4564 headers : { 'Content-Type' : 'application/json' } ,
@@ -59,9 +78,7 @@ async function submitContactRequest(payload: ContactRequestPayload) {
5978}
6079
6180export function ContactForm ( ) {
62- const [ form , setForm ] = useState < ContactFormState > ( INITIAL_FORM_STATE )
63- const [ errors , setErrors ] = useState < ContactErrors > ( { } )
64- const [ submitSuccess , setSubmitSuccess ] = useState ( false )
81+ const turnstileRef = useRef < TurnstileInstance > ( null )
6582
6683 const contactMutation = useMutation ( {
6784 mutationFn : submitContactRequest ,
@@ -71,8 +88,23 @@ export function ContactForm() {
7188 setErrors ( { } )
7289 setSubmitSuccess ( true )
7390 } ,
91+ onError : ( ) => {
92+ turnstileRef . current ?. reset ( )
93+ } ,
7494 } )
7595
96+ const [ form , setForm ] = useState < ContactFormState > ( INITIAL_FORM_STATE )
97+ const [ errors , setErrors ] = useState < ContactErrors > ( { } )
98+ const [ submitSuccess , setSubmitSuccess ] = useState ( false )
99+ const [ isSubmitting , setIsSubmitting ] = useState ( false )
100+ const [ website , setWebsite ] = useState ( '' )
101+ const [ widgetReady , setWidgetReady ] = useState ( false )
102+ const [ turnstileSiteKey , setTurnstileSiteKey ] = useState < string | undefined > ( )
103+
104+ useEffect ( ( ) => {
105+ setTurnstileSiteKey ( getEnv ( 'NEXT_PUBLIC_TURNSTILE_SITE_KEY' ) )
106+ } , [ ] )
107+
76108 function updateField < TField extends keyof ContactFormState > (
77109 field : TField ,
78110 value : ContactFormState [ TField ]
@@ -91,9 +123,10 @@ export function ContactForm() {
91123 }
92124 }
93125
94- function handleSubmit ( event : React . FormEvent < HTMLFormElement > ) {
126+ async function handleSubmit ( event : React . FormEvent < HTMLFormElement > ) {
95127 event . preventDefault ( )
96- if ( contactMutation . isPending ) return
128+ if ( contactMutation . isPending || isSubmitting ) return
129+ setIsSubmitting ( true )
97130
98131 const parsed = contactRequestSchema . safeParse ( {
99132 ...form ,
@@ -110,35 +143,55 @@ export function ContactForm() {
110143 subject : fieldErrors . subject ?. [ 0 ] ,
111144 message : fieldErrors . message ?. [ 0 ] ,
112145 } )
146+ setIsSubmitting ( false )
113147 return
114148 }
115149
116- contactMutation . mutate ( parsed . data )
150+ let captchaToken : string | undefined
151+ let captchaUnavailable : boolean | undefined
152+ const widget = turnstileRef . current
153+
154+ if ( turnstileSiteKey ) {
155+ if ( widgetReady && widget ) {
156+ try {
157+ widget . reset ( )
158+ widget . execute ( )
159+ captchaToken = await widget . getResponsePromise ( 30_000 )
160+ } catch {
161+ captchaUnavailable = true
162+ }
163+ } else {
164+ captchaUnavailable = true
165+ }
166+ }
167+
168+ contactMutation . mutate ( { ...parsed . data , website, captchaToken, captchaUnavailable } )
169+ setIsSubmitting ( false )
117170 }
118171
172+ const isBusy = contactMutation . isPending || isSubmitting
173+
119174 const submitError = contactMutation . isError
120- ? contactMutation . error instanceof Error
121- ? contactMutation . error . message
122- : 'Failed to send message. Please try again.'
175+ ? toError ( contactMutation . error ) . message || 'Failed to send message. Please try again.'
123176 : null
124177
125178 if ( submitSuccess ) {
126179 return (
127- < div className = 'flex min-h-[460px] flex-col items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] px-8 py-16 text-center' >
128- < div className = 'flex h-16 w-16 items-center justify-center rounded-full border border-[var(--border )] bg-[var(--bg-subtle )] text-[var(--text-primary )]' >
180+ < div className = 'flex flex-col items-center px-8 py-16 text-center' >
181+ < div className = 'flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated )] bg-[var(--landing- bg-surface )] text-[var(--landing-text )]' >
129182 < Check className = 'h-8 w-8' />
130183 </ div >
131- < h2 className = 'mt-6 font-[430] font-season text-[24px] text-[var(--text-primary )] leading-[1.2] tracking-[-0.02em]' >
184+ < h2 className = 'mt-6 font-[430] font-season text-[24px] text-[var(--landing-text )] leading-[1.2] tracking-[-0.02em]' >
132185 Message received
133186 </ h2 >
134- < p className = 'mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary )] leading-[1.6]' >
187+ < p className = 'mt-3 max-w-sm font-season text-[14px] text-[var(--landing- text-body )] leading-[1.6]' >
135188 Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
136189 shortly.
137190 </ p >
138191 < button
139192 type = 'button'
140193 onClick = { ( ) => setSubmitSuccess ( false ) }
141- className = 'mt-6 font-season text-[13px] text-[var(--text-primary )] underline underline-offset-2 transition-opacity hover:opacity-80'
194+ className = 'mt-6 font-season text-[13px] text-[var(--landing-text )] underline underline-offset-2 transition-opacity hover:opacity-80'
142195 >
143196 Send another message
144197 </ button >
@@ -147,12 +200,33 @@ export function ContactForm() {
147200 }
148201
149202 return (
150- < form
151- onSubmit = { handleSubmit }
152- className = 'flex flex-col gap-4 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-6 sm:p-8'
153- >
154- < div className = 'grid gap-4 sm:grid-cols-2' >
155- < LandingField htmlFor = 'contact-name' label = 'Name' error = { errors . name } >
203+ < form onSubmit = { handleSubmit } className = 'relative flex flex-col gap-5' >
204+ { /* Honeypot */ }
205+ < div
206+ aria-hidden = 'true'
207+ className = 'pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
208+ >
209+ < label htmlFor = 'contact-website' > Website</ label >
210+ < input
211+ id = 'contact-website'
212+ name = 'website'
213+ type = 'text'
214+ tabIndex = { - 1 }
215+ autoComplete = 'off'
216+ value = { website }
217+ onChange = { ( event ) => setWebsite ( event . target . value ) }
218+ data-lpignore = 'true'
219+ data-1p-ignore = 'true'
220+ />
221+ </ div >
222+
223+ < div className = 'grid gap-5 sm:grid-cols-2' >
224+ < LandingField
225+ htmlFor = 'contact-name'
226+ label = 'Name'
227+ error = { errors . name }
228+ labelClassName = { LANDING_LABEL }
229+ >
156230 < Input
157231 id = 'contact-name'
158232 value = { form . name }
@@ -161,7 +235,12 @@ export function ContactForm() {
161235 className = { LANDING_INPUT }
162236 />
163237 </ LandingField >
164- < LandingField htmlFor = 'contact-email' label = 'Email' error = { errors . email } >
238+ < LandingField
239+ htmlFor = 'contact-email'
240+ label = 'Email'
241+ error = { errors . email }
242+ labelClassName = { LANDING_LABEL }
243+ >
165244 < Input
166245 id = 'contact-email'
167246 type = 'email'
@@ -173,8 +252,14 @@ export function ContactForm() {
173252 </ LandingField >
174253 </ div >
175254
176- < div className = 'grid gap-4 sm:grid-cols-2' >
177- < LandingField htmlFor = 'contact-company' label = 'Company' optional error = { errors . company } >
255+ < div className = 'grid gap-5 sm:grid-cols-2' >
256+ < LandingField
257+ htmlFor = 'contact-company'
258+ label = 'Company'
259+ optional
260+ error = { errors . company }
261+ labelClassName = { LANDING_LABEL }
262+ >
178263 < Input
179264 id = 'contact-company'
180265 value = { form . company }
@@ -183,21 +268,31 @@ export function ContactForm() {
183268 className = { LANDING_INPUT }
184269 />
185270 </ LandingField >
186- < LandingField htmlFor = 'contact-topic' label = 'Topic' error = { errors . topic } >
271+ < LandingField
272+ htmlFor = 'contact-topic'
273+ label = 'Topic'
274+ error = { errors . topic }
275+ labelClassName = { LANDING_LABEL }
276+ >
187277 < Combobox
188- options = { COMBOBOX_TOPICS }
278+ options = { CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption [ ] }
189279 value = { form . topic }
190280 selectedValue = { form . topic }
191281 onChange = { ( value ) => updateField ( 'topic' , value as ContactRequestPayload [ 'topic' ] ) }
192282 placeholder = 'Select a topic'
193283 editable = { false }
194284 filterOptions = { false }
195- className = 'h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
285+ className = { LANDING_COMBOBOX }
196286 />
197287 </ LandingField >
198288 </ div >
199289
200- < LandingField htmlFor = 'contact-subject' label = 'Subject' error = { errors . subject } >
290+ < LandingField
291+ htmlFor = 'contact-subject'
292+ label = 'Subject'
293+ error = { errors . subject }
294+ labelClassName = { LANDING_LABEL }
295+ >
201296 < Input
202297 id = 'contact-subject'
203298 value = { form . subject }
@@ -207,33 +302,53 @@ export function ContactForm() {
207302 />
208303 </ LandingField >
209304
210- < LandingField htmlFor = 'contact-message' label = 'Message' error = { errors . message } >
305+ < LandingField
306+ htmlFor = 'contact-message'
307+ label = 'Message'
308+ error = { errors . message }
309+ labelClassName = { LANDING_LABEL }
310+ >
211311 < Textarea
212312 id = 'contact-message'
213313 value = { form . message }
214314 onChange = { ( event ) => updateField ( 'message' , event . target . value ) }
215315 placeholder = 'Share details so we can help as quickly as possible'
216- className = 'min-h-[140px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
316+ className = { LANDING_TEXTAREA }
217317 />
218318 </ LandingField >
219319
320+ { turnstileSiteKey ? (
321+ < Turnstile
322+ ref = { turnstileRef }
323+ siteKey = { turnstileSiteKey }
324+ options = { { execution : 'execute' , appearance : 'execute' , size : 'invisible' } }
325+ onWidgetLoad = { ( ) => setWidgetReady ( true ) }
326+ onExpire = { ( ) => setWidgetReady ( false ) }
327+ onError = { ( ) => setWidgetReady ( false ) }
328+ onUnsupported = { ( ) => setWidgetReady ( false ) }
329+ />
330+ ) : null }
331+
220332 { submitError ? (
221333 < p role = 'alert' className = 'font-season text-[13px] text-[var(--text-error)]' >
222334 { submitError }
223335 </ p >
224336 ) : null }
225337
226- < button
227- type = 'submit'
228- disabled = { contactMutation . isPending }
229- className = { cn (
230- 'flex h-[40px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)]' ,
231- 'font-[430] font-season text-[14px] text-[var(--bg)] transition-opacity' ,
232- 'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60'
233- ) }
234- >
235- { contactMutation . isPending ? 'Sending...' : 'Send message' }
338+ < button type = 'submit' disabled = { isBusy } className = { LANDING_SUBMIT } >
339+ { isBusy ? 'Sending...' : 'Send message' }
236340 </ button >
341+
342+ < p className = 'text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]' >
343+ By submitting, you agree to our{ ' ' }
344+ < Link
345+ href = '/privacy'
346+ className = 'text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
347+ >
348+ Privacy Policy
349+ </ Link >
350+ .
351+ </ p >
237352 </ form >
238353 )
239354}
0 commit comments