Skip to content

Commit af53eda

Browse files
authored
feat(landing): reintroduce /contact page styled like /demo (#5315)
* feat(landing): reintroduce /contact page styled like /demo - Restore the /contact page (removed in #5181) with a two-column layout mirroring /demo: value prop + trusted-by logos on the left, a message form card on the right, on the platform light tokens and chip components - Restore the contact contract, /api/contact route (rate-limit, honeypot, Turnstile, help-inbox notification + visitor confirmation), now fully contract-bound via parseRequest - Add a useSubmitContact React Query mutation hook - Link Contact from the footer Resources column and add it to the sitemap * fix(contact): server-authoritative captcha + review fixes - Make captcha server-authoritative: drop the client-trusted captchaUnavailable flag; a valid Turnstile token is the only way past the stricter fallback bucket, so callers can't opt out of the challenge - Re-execute the Turnstile widget on every submit (incl. after expiry) instead of falling into the no-captcha path once the token expires - Reset the pre-submit gate on mutation settle so rapid double-clicks can't fire a duplicate /api/contact request - Map only feature_request to its email type; every other topic resolves to a General Inquiry confirmation so support requests aren't labeled bug reports - Drop the confirmation-email promise from the success copy (it's best-effort) - Collapse the duplicated no-captcha rate-limit branch; hoist shared response constants; read the Turnstile site key as a module constant * fix(contact): drop redundant Turnstile hostname pin The Turnstile site key is already domain-bound in Cloudflare, so pinning expectedHostname to the marketing SITE_URL (www.sim.ai) only rejected valid tokens issued on self-hosted, preview, and apex-vs-www hosts. Remove the pin and rely on Cloudflare's own domain binding. * fix(contact): fail closed on the no-captcha rate-limit backstop checkRateLimitDirect fails open on limiter-storage errors so a limiter outage never takes down normal traffic. But the contact route's no-captcha bucket is the only throttle on token-less submits, so a fail-open there let uncaptcha'd requests reach the email path unthrottled during an outage. - Add an opt-in { failClosed } option to checkRateLimitDirect; default behavior (fail open) is unchanged - Use failClosed on the contact no-captcha backstop so an unenforceable limit rejects instead of admitting - Cover both fail-open and fail-closed paths with tests * refactor(contact): TSDoc over inline comments Move the captcha-design rationale into the route handler's TSDoc and drop the inline body/JSX comments, per the project's TSDoc-only comment convention.
1 parent 43ed80c commit af53eda

12 files changed

Lines changed: 818 additions & 9 deletions

File tree

apps/sim/app/(landing)/components/footer/footer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const RESOURCES_LINKS: FooterItem[] = [
4343
{ label: 'Partners', href: '/partners' },
4444
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
4545
{ label: 'Changelog', href: '/changelog' },
46+
{ label: 'Contact', href: '/contact' },
4647
]
4748

4849
/** Top model providers, sourced from the catalog so labels/hrefs never drift. */
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
'use client'
2+
3+
import { type ReactNode, useId, useRef, useState } from 'react'
4+
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
5+
import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn'
6+
import { Check } from '@sim/emcn/icons'
7+
import { toError } from '@sim/utils/errors'
8+
import {
9+
CONTACT_TOPIC_OPTIONS,
10+
type ContactRequestPayload,
11+
contactRequestSchema,
12+
} from '@/lib/api/contracts/contact'
13+
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
14+
import { getEnv } from '@/lib/core/config/env'
15+
import { captureClientEvent } from '@/lib/posthog/client'
16+
import { useSubmitContact } from '@/hooks/queries/contact'
17+
18+
/**
19+
* Field control height — slightly taller than the 30px in-app chip default and
20+
* just under the 36px auth field, so the form reads as a roomy landing surface.
21+
* Applied to each control's `className`, the sanctioned way to own only a chip
22+
* field's height (mirrors the demo form).
23+
*/
24+
const FIELD_HEIGHT = 'h-[34px]'
25+
26+
/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */
27+
const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')
28+
29+
type ContactField = keyof ContactRequestPayload
30+
type ContactErrors = Partial<Record<ContactField, string>>
31+
32+
interface ContactFormState {
33+
name: string
34+
email: string
35+
company: string
36+
topic: ContactRequestPayload['topic'] | ''
37+
subject: string
38+
message: string
39+
}
40+
41+
const INITIAL_STATE: ContactFormState = {
42+
name: '',
43+
email: '',
44+
company: '',
45+
topic: '',
46+
subject: '',
47+
message: '',
48+
}
49+
50+
interface ContactFieldProps {
51+
label: string
52+
/** Set for native controls (inputs/textarea) to associate the label by `id`. */
53+
htmlFor?: string
54+
required?: boolean
55+
error?: string
56+
/** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */
57+
children: ReactNode
58+
}
59+
60+
/**
61+
* A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label,
62+
* caption-sized error). Native controls associate via `htmlFor`/`id`; controls
63+
* that can't take a label `id` (the dropdown) become a `role='group'` named by
64+
* the label instead, so every field has an accessible name.
65+
*/
66+
function ContactField({ label, htmlFor, required, error, children }: ContactFieldProps) {
67+
const labelId = useId()
68+
const isGroup = htmlFor === undefined
69+
return (
70+
<div
71+
className='flex flex-col gap-[9px]'
72+
role={isGroup ? 'group' : undefined}
73+
aria-labelledby={isGroup ? labelId : undefined}
74+
>
75+
<Label id={labelId} htmlFor={htmlFor} className='pl-0.5 font-normal text-[var(--text-muted)]'>
76+
{label}
77+
{required ? (
78+
<span aria-hidden className='ml-0.5 text-[var(--text-error)]'>
79+
*
80+
</span>
81+
) : null}
82+
</Label>
83+
{children}
84+
{error ? <p className='pl-0.5 text-[var(--text-error)] text-caption'>{error}</p> : null}
85+
</div>
86+
)
87+
}
88+
89+
/**
90+
* The `/contact` form — rendered inside the card chrome owned by the page, so it
91+
* returns just its heading and fields. Fields are hand-composed at the slightly
92+
* taller {@link FIELD_HEIGHT}, stacked at the platform `gap-4` rhythm with no
93+
* divider lines, mirroring the demo booking form.
94+
*
95+
* On submit it validates against the shared {@link contactRequestSchema}, runs an
96+
* invisible Turnstile challenge (falling back gracefully when the widget is
97+
* unavailable), and posts through {@link useSubmitContact}, which emails the help
98+
* inbox and sends the visitor a confirmation. A honeypot `website` field and the
99+
* captcha token ride along on the payload. A successful submit swaps the card to a
100+
* confirmation state.
101+
*/
102+
export function ContactForm() {
103+
const turnstileRef = useRef<TurnstileInstance>(null)
104+
105+
const contactMutation = useSubmitContact()
106+
107+
const [form, setForm] = useState<ContactFormState>(INITIAL_STATE)
108+
const [errors, setErrors] = useState<ContactErrors>({})
109+
const [isSubmitting, setIsSubmitting] = useState(false)
110+
const [website, setWebsite] = useState('')
111+
const [widgetLoaded, setWidgetLoaded] = useState(false)
112+
113+
function updateField<TField extends keyof ContactFormState>(
114+
field: TField,
115+
value: ContactFormState[TField]
116+
) {
117+
setForm((prev) => ({ ...prev, [field]: value }))
118+
setErrors((prev) => {
119+
if (!prev[field as ContactField]) {
120+
return prev
121+
}
122+
const nextErrors = { ...prev }
123+
delete nextErrors[field as ContactField]
124+
return nextErrors
125+
})
126+
if (contactMutation.isError) {
127+
contactMutation.reset()
128+
}
129+
}
130+
131+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
132+
event.preventDefault()
133+
if (contactMutation.isPending || isSubmitting) return
134+
setIsSubmitting(true)
135+
136+
const parsed = contactRequestSchema.safeParse({
137+
...form,
138+
company: form.company || undefined,
139+
})
140+
141+
if (!parsed.success) {
142+
setErrors(flattenFieldErrors<ContactField>(parsed.error))
143+
setIsSubmitting(false)
144+
return
145+
}
146+
147+
let captchaToken: string | undefined
148+
const widget = turnstileRef.current
149+
150+
if (TURNSTILE_SITE_KEY && widgetLoaded && widget) {
151+
try {
152+
widget.reset()
153+
widget.execute()
154+
captchaToken = await widget.getResponsePromise(30_000)
155+
} catch {
156+
captchaToken = undefined
157+
}
158+
}
159+
160+
contactMutation.mutate(
161+
{ ...parsed.data, website, captchaToken },
162+
{
163+
onSuccess: () => {
164+
captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic })
165+
setForm(INITIAL_STATE)
166+
setErrors({})
167+
},
168+
onError: () => {
169+
turnstileRef.current?.reset()
170+
},
171+
onSettled: () => {
172+
setIsSubmitting(false)
173+
},
174+
}
175+
)
176+
}
177+
178+
const isBusy = contactMutation.isPending || isSubmitting
179+
180+
const submitError = contactMutation.isError
181+
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
182+
: null
183+
184+
if (contactMutation.isSuccess) {
185+
return (
186+
<div className='flex flex-col items-center px-4 py-12 text-center'>
187+
<div className='flex size-14 items-center justify-center rounded-full border border-[var(--border-1)] bg-[var(--surface-1)] text-[var(--text-primary)]'>
188+
<Check className='size-7' />
189+
</div>
190+
<h2 className='mt-5 text-[var(--text-primary)] text-xl leading-[1.2]'>Message received</h2>
191+
<p className='mt-2 max-w-sm text-[var(--text-muted)] text-sm leading-[1.6]'>
192+
Thanks for reaching out. Our team will get back to you shortly.
193+
</p>
194+
<button
195+
type='button'
196+
onClick={() => contactMutation.reset()}
197+
className='mt-5 text-[var(--text-primary)] text-small underline underline-offset-2 transition-opacity hover:opacity-80'
198+
>
199+
Send another message
200+
</button>
201+
</div>
202+
)
203+
}
204+
205+
return (
206+
<>
207+
<h2 id='contact-form-heading' className='text-[var(--text-primary)] text-xl leading-[1.2]'>
208+
Send us a message
209+
</h2>
210+
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
211+
Ask a question, request an integration, or get help — we'll get back to you shortly.
212+
</p>
213+
214+
<form
215+
onSubmit={handleSubmit}
216+
aria-labelledby='contact-form-heading'
217+
className='relative mt-5 flex flex-col gap-4'
218+
noValidate
219+
>
220+
<div
221+
aria-hidden='true'
222+
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
223+
>
224+
<label htmlFor='contact-website'>Website</label>
225+
<input
226+
id='contact-website'
227+
name='website'
228+
type='text'
229+
tabIndex={-1}
230+
autoComplete='off'
231+
value={website}
232+
onChange={(event) => setWebsite(event.target.value)}
233+
data-lpignore='true'
234+
data-1p-ignore='true'
235+
/>
236+
</div>
237+
238+
<div className='grid grid-cols-2 gap-3 max-sm:grid-cols-1'>
239+
<ContactField label='Name' htmlFor='contact-name' required error={errors.name}>
240+
<ChipInput
241+
id='contact-name'
242+
className={FIELD_HEIGHT}
243+
value={form.name}
244+
onChange={(event) => updateField('name', event.target.value)}
245+
error={Boolean(errors.name)}
246+
placeholder='Jane Doe'
247+
autoComplete='name'
248+
/>
249+
</ContactField>
250+
<ContactField label='Email' htmlFor='contact-email' required error={errors.email}>
251+
<ChipInput
252+
id='contact-email'
253+
type='email'
254+
className={FIELD_HEIGHT}
255+
value={form.email}
256+
onChange={(event) => updateField('email', event.target.value)}
257+
error={Boolean(errors.email)}
258+
placeholder='jane@acme.co'
259+
autoComplete='email'
260+
/>
261+
</ContactField>
262+
</div>
263+
264+
<div className='grid grid-cols-2 gap-3 max-sm:grid-cols-1'>
265+
<ContactField label='Company (optional)' htmlFor='contact-company' error={errors.company}>
266+
<ChipInput
267+
id='contact-company'
268+
className={FIELD_HEIGHT}
269+
value={form.company}
270+
onChange={(event) => updateField('company', event.target.value)}
271+
error={Boolean(errors.company)}
272+
placeholder='Acme Inc.'
273+
autoComplete='organization'
274+
/>
275+
</ContactField>
276+
<ContactField label='Topic' required error={errors.topic}>
277+
<ChipDropdown
278+
fullWidth
279+
flush
280+
className={FIELD_HEIGHT}
281+
value={form.topic || undefined}
282+
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
283+
options={CONTACT_TOPIC_OPTIONS}
284+
placeholder='Select a topic'
285+
/>
286+
</ContactField>
287+
</div>
288+
289+
<ContactField label='Subject' htmlFor='contact-subject' required error={errors.subject}>
290+
<ChipInput
291+
id='contact-subject'
292+
className={FIELD_HEIGHT}
293+
value={form.subject}
294+
onChange={(event) => updateField('subject', event.target.value)}
295+
error={Boolean(errors.subject)}
296+
placeholder='How can we help?'
297+
/>
298+
</ContactField>
299+
300+
<ContactField label='Message' htmlFor='contact-message' required error={errors.message}>
301+
<ChipTextarea
302+
id='contact-message'
303+
value={form.message}
304+
onChange={(event) => updateField('message', event.target.value)}
305+
error={Boolean(errors.message)}
306+
placeholder='Share details so we can help as quickly as possible.'
307+
rows={4}
308+
/>
309+
</ContactField>
310+
311+
{TURNSTILE_SITE_KEY ? (
312+
<Turnstile
313+
ref={turnstileRef}
314+
siteKey={TURNSTILE_SITE_KEY}
315+
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
316+
onWidgetLoad={() => setWidgetLoaded(true)}
317+
onError={() => setWidgetLoaded(false)}
318+
onUnsupported={() => setWidgetLoaded(false)}
319+
/>
320+
) : null}
321+
322+
{submitError ? (
323+
<p role='alert' className='text-[var(--text-error)] text-caption'>
324+
{submitError}
325+
</p>
326+
) : null}
327+
328+
<Chip
329+
type='submit'
330+
variant='primary'
331+
flush
332+
fullWidth
333+
disabled={isBusy}
334+
className='mt-1 justify-center [&>span]:flex-none'
335+
>
336+
{isBusy ? 'Sending…' : 'Send message'}
337+
</Chip>
338+
</form>
339+
</>
340+
)
341+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ContactForm } from './contact-form'

0 commit comments

Comments
 (0)