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'
7+ import Link from 'next/link'
58import { Combobox , 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,22 @@ 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 [ website , setWebsite ] = useState ( '' )
100+ const [ widgetReady , setWidgetReady ] = useState ( false )
101+ const [ turnstileSiteKey , setTurnstileSiteKey ] = useState < string | undefined > ( )
102+
103+ useEffect ( ( ) => {
104+ setTurnstileSiteKey ( getEnv ( 'NEXT_PUBLIC_TURNSTILE_SITE_KEY' ) )
105+ } , [ ] )
106+
76107 function updateField < TField extends keyof ContactFormState > (
77108 field : TField ,
78109 value : ContactFormState [ TField ]
@@ -91,7 +122,7 @@ export function ContactForm() {
91122 }
92123 }
93124
94- function handleSubmit ( event : React . FormEvent < HTMLFormElement > ) {
125+ async function handleSubmit ( event : React . FormEvent < HTMLFormElement > ) {
95126 event . preventDefault ( )
96127 if ( contactMutation . isPending ) return
97128
@@ -113,32 +144,48 @@ export function ContactForm() {
113144 return
114145 }
115146
116- contactMutation . mutate ( parsed . data )
147+ let captchaToken : string | undefined
148+ let captchaUnavailable : boolean | undefined
149+ const widget = turnstileRef . current
150+
151+ if ( turnstileSiteKey ) {
152+ if ( widgetReady && widget ) {
153+ try {
154+ widget . reset ( )
155+ widget . execute ( )
156+ captchaToken = await widget . getResponsePromise ( 30_000 )
157+ } catch {
158+ captchaUnavailable = true
159+ }
160+ } else {
161+ captchaUnavailable = true
162+ }
163+ }
164+
165+ contactMutation . mutate ( { ...parsed . data , website, captchaToken, captchaUnavailable } )
117166 }
118167
119168 const submitError = contactMutation . isError
120- ? contactMutation . error instanceof Error
121- ? contactMutation . error . message
122- : 'Failed to send message. Please try again.'
169+ ? toError ( contactMutation . error ) . message || 'Failed to send message. Please try again.'
123170 : null
124171
125172 if ( submitSuccess ) {
126173 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 )]' >
174+ < div className = 'flex min-h-[460px] flex-col items-center justify-center px-8 py-16 text-center' >
175+ < 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 )]' >
129176 < Check className = 'h-8 w-8' />
130177 </ div >
131- < h2 className = 'mt-6 font-[430] font-season text-[24px] text-[var(--text-primary )] leading-[1.2] tracking-[-0.02em]' >
178+ < h2 className = 'mt-6 font-[430] font-season text-[24px] text-[var(--landing-text )] leading-[1.2] tracking-[-0.02em]' >
132179 Message received
133180 </ h2 >
134- < p className = 'mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary )] leading-[1.6]' >
181+ < p className = 'mt-3 max-w-sm font-season text-[14px] text-[var(--landing- text-body )] leading-[1.6]' >
135182 Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
136183 shortly.
137184 </ p >
138185 < button
139186 type = 'button'
140187 onClick = { ( ) => setSubmitSuccess ( false ) }
141- className = 'mt-6 font-season text-[13px] text-[var(--text-primary )] underline underline-offset-2 transition-opacity hover:opacity-80'
188+ className = 'mt-6 font-season text-[13px] text-[var(--landing-text )] underline underline-offset-2 transition-opacity hover:opacity-80'
142189 >
143190 Send another message
144191 </ button >
@@ -147,12 +194,33 @@ export function ContactForm() {
147194 }
148195
149196 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 } >
197+ < form onSubmit = { handleSubmit } className = 'flex flex-col gap-5' >
198+ { /* Honeypot */ }
199+ < div
200+ aria-hidden = 'true'
201+ className = 'pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
202+ >
203+ < label htmlFor = 'contact-website' > Website</ label >
204+ < input
205+ id = 'contact-website'
206+ name = 'website'
207+ type = 'text'
208+ tabIndex = { - 1 }
209+ autoComplete = 'off'
210+ value = { website }
211+ onChange = { ( event ) => setWebsite ( event . target . value ) }
212+ data-lpignore = 'true'
213+ data-1p-ignore = 'true'
214+ />
215+ </ div >
216+
217+ < div className = 'grid gap-5 sm:grid-cols-2' >
218+ < LandingField
219+ htmlFor = 'contact-name'
220+ label = 'Name'
221+ error = { errors . name }
222+ labelClassName = { LANDING_LABEL }
223+ >
156224 < Input
157225 id = 'contact-name'
158226 value = { form . name }
@@ -161,7 +229,12 @@ export function ContactForm() {
161229 className = { LANDING_INPUT }
162230 />
163231 </ LandingField >
164- < LandingField htmlFor = 'contact-email' label = 'Email' error = { errors . email } >
232+ < LandingField
233+ htmlFor = 'contact-email'
234+ label = 'Email'
235+ error = { errors . email }
236+ labelClassName = { LANDING_LABEL }
237+ >
165238 < Input
166239 id = 'contact-email'
167240 type = 'email'
@@ -173,8 +246,14 @@ export function ContactForm() {
173246 </ LandingField >
174247 </ div >
175248
176- < div className = 'grid gap-4 sm:grid-cols-2' >
177- < LandingField htmlFor = 'contact-company' label = 'Company' optional error = { errors . company } >
249+ < div className = 'grid gap-5 sm:grid-cols-2' >
250+ < LandingField
251+ htmlFor = 'contact-company'
252+ label = 'Company'
253+ optional
254+ error = { errors . company }
255+ labelClassName = { LANDING_LABEL }
256+ >
178257 < Input
179258 id = 'contact-company'
180259 value = { form . company }
@@ -183,21 +262,31 @@ export function ContactForm() {
183262 className = { LANDING_INPUT }
184263 />
185264 </ LandingField >
186- < LandingField htmlFor = 'contact-topic' label = 'Topic' error = { errors . topic } >
265+ < LandingField
266+ htmlFor = 'contact-topic'
267+ label = 'Topic'
268+ error = { errors . topic }
269+ labelClassName = { LANDING_LABEL }
270+ >
187271 < Combobox
188- options = { COMBOBOX_TOPICS }
272+ options = { CONTACT_TOPIC_OPTIONS }
189273 value = { form . topic }
190274 selectedValue = { form . topic }
191275 onChange = { ( value ) => updateField ( 'topic' , value as ContactRequestPayload [ 'topic' ] ) }
192276 placeholder = 'Select a topic'
193277 editable = { false }
194278 filterOptions = { false }
195- className = 'h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
279+ className = { LANDING_COMBOBOX }
196280 />
197281 </ LandingField >
198282 </ div >
199283
200- < LandingField htmlFor = 'contact-subject' label = 'Subject' error = { errors . subject } >
284+ < LandingField
285+ htmlFor = 'contact-subject'
286+ label = 'Subject'
287+ error = { errors . subject }
288+ labelClassName = { LANDING_LABEL }
289+ >
201290 < Input
202291 id = 'contact-subject'
203292 value = { form . subject }
@@ -207,33 +296,53 @@ export function ContactForm() {
207296 />
208297 </ LandingField >
209298
210- < LandingField htmlFor = 'contact-message' label = 'Message' error = { errors . message } >
299+ < LandingField
300+ htmlFor = 'contact-message'
301+ label = 'Message'
302+ error = { errors . message }
303+ labelClassName = { LANDING_LABEL }
304+ >
211305 < Textarea
212306 id = 'contact-message'
213307 value = { form . message }
214308 onChange = { ( event ) => updateField ( 'message' , event . target . value ) }
215309 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)]'
310+ className = { LANDING_TEXTAREA }
217311 />
218312 </ LandingField >
219313
314+ { turnstileSiteKey ? (
315+ < Turnstile
316+ ref = { turnstileRef }
317+ siteKey = { turnstileSiteKey }
318+ options = { { execution : 'execute' , appearance : 'execute' , size : 'invisible' } }
319+ onWidgetLoad = { ( ) => setWidgetReady ( true ) }
320+ onExpire = { ( ) => setWidgetReady ( false ) }
321+ onError = { ( ) => setWidgetReady ( false ) }
322+ onUnsupported = { ( ) => setWidgetReady ( false ) }
323+ />
324+ ) : null }
325+
220326 { submitError ? (
221327 < p role = 'alert' className = 'font-season text-[13px] text-[var(--text-error)]' >
222328 { submitError }
223329 </ p >
224330 ) : null }
225331
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- >
332+ < button type = 'submit' disabled = { contactMutation . isPending } className = { LANDING_SUBMIT } >
235333 { contactMutation . isPending ? 'Sending...' : 'Send message' }
236334 </ button >
335+
336+ < p className = 'text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]' >
337+ By submitting, you agree to our{ ' ' }
338+ < Link
339+ href = '/privacy'
340+ className = 'text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
341+ >
342+ Privacy Policy
343+ </ Link >
344+ .
345+ </ p >
237346 </ form >
238347 )
239348}
0 commit comments