Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 161 additions & 46 deletions apps/sim/app/(landing)/components/contact/contact-form.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
'use client'

import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { toError } from '@sim/utils/errors'
import { useMutation } from '@tanstack/react-query'
import { Combobox, Input, Textarea } from '@/components/emcn'
import Link from 'next/link'
import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn'
import { Check } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { getEnv } from '@/lib/core/config/env'
import { captureClientEvent } from '@/lib/posthog/client'
import {
CONTACT_TOPIC_OPTIONS,
Expand Down Expand Up @@ -34,12 +37,28 @@ const INITIAL_FORM_STATE: ContactFormState = {
message: '',
}

const COMBOBOX_TOPICS = [...CONTACT_TOPIC_OPTIONS]

const LANDING_INPUT =
'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)]'
'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)]'

const LANDING_TEXTAREA =
'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)]'

const LANDING_COMBOBOX =
'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)]'

const LANDING_SUBMIT =
'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'

const LANDING_LABEL =
'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]'

async function submitContactRequest(payload: ContactRequestPayload) {
interface SubmitContactRequestInput extends ContactRequestPayload {
website: string
captchaToken?: string
captchaUnavailable?: boolean
}

async function submitContactRequest(payload: SubmitContactRequestInput) {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -59,9 +78,7 @@ async function submitContactRequest(payload: ContactRequestPayload) {
}

export function ContactForm() {
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
const [errors, setErrors] = useState<ContactErrors>({})
const [submitSuccess, setSubmitSuccess] = useState(false)
const turnstileRef = useRef<TurnstileInstance>(null)

const contactMutation = useMutation({
mutationFn: submitContactRequest,
Expand All @@ -71,8 +88,23 @@ export function ContactForm() {
setErrors({})
setSubmitSuccess(true)
},
onError: () => {
turnstileRef.current?.reset()
},
})

const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
const [errors, setErrors] = useState<ContactErrors>({})
const [submitSuccess, setSubmitSuccess] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [website, setWebsite] = useState('')
const [widgetReady, setWidgetReady] = useState(false)
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()

useEffect(() => {
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
}, [])

function updateField<TField extends keyof ContactFormState>(
field: TField,
value: ContactFormState[TField]
Expand All @@ -91,9 +123,10 @@ export function ContactForm() {
}
}

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (contactMutation.isPending) return
if (contactMutation.isPending || isSubmitting) return
setIsSubmitting(true)

const parsed = contactRequestSchema.safeParse({
...form,
Expand All @@ -110,35 +143,55 @@ export function ContactForm() {
subject: fieldErrors.subject?.[0],
message: fieldErrors.message?.[0],
})
setIsSubmitting(false)
return
}

contactMutation.mutate(parsed.data)
let captchaToken: string | undefined
let captchaUnavailable: boolean | undefined
const widget = turnstileRef.current

if (turnstileSiteKey) {
if (widgetReady && widget) {
try {
widget.reset()
widget.execute()
captchaToken = await widget.getResponsePromise(30_000)
} catch {
captchaUnavailable = true
}
} else {
captchaUnavailable = true
}
}

contactMutation.mutate({ ...parsed.data, website, captchaToken, captchaUnavailable })
Comment thread
waleedlatif1 marked this conversation as resolved.
setIsSubmitting(false)
}

const isBusy = contactMutation.isPending || isSubmitting

const submitError = contactMutation.isError
? contactMutation.error instanceof Error
? contactMutation.error.message
: 'Failed to send message. Please try again.'
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
: null

if (submitSuccess) {
return (
<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'>
<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)]'>
<div className='flex flex-col items-center px-8 py-16 text-center'>
<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)]'>
<Check className='h-8 w-8' />
</div>
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
Message received
</h2>
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
shortly.
</p>
<button
type='button'
onClick={() => setSubmitSuccess(false)}
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
>
Send another message
</button>
Expand All @@ -147,12 +200,33 @@ export function ContactForm() {
}

return (
<form
onSubmit={handleSubmit}
className='flex flex-col gap-4 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-6 sm:p-8'
>
<div className='grid gap-4 sm:grid-cols-2'>
<LandingField htmlFor='contact-name' label='Name' error={errors.name}>
<form onSubmit={handleSubmit} className='relative flex flex-col gap-5'>
{/* Honeypot */}
<div
aria-hidden='true'
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
>
<label htmlFor='contact-website'>Website</label>
<input
id='contact-website'
name='website'
type='text'
tabIndex={-1}
autoComplete='off'
value={website}
onChange={(event) => setWebsite(event.target.value)}
data-lpignore='true'
data-1p-ignore='true'
/>
</div>

<div className='grid gap-5 sm:grid-cols-2'>
<LandingField
htmlFor='contact-name'
label='Name'
error={errors.name}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-name'
value={form.name}
Expand All @@ -161,7 +235,12 @@ export function ContactForm() {
className={LANDING_INPUT}
/>
</LandingField>
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
<LandingField
htmlFor='contact-email'
label='Email'
error={errors.email}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-email'
type='email'
Expand All @@ -173,8 +252,14 @@ export function ContactForm() {
</LandingField>
</div>

<div className='grid gap-4 sm:grid-cols-2'>
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
<div className='grid gap-5 sm:grid-cols-2'>
<LandingField
htmlFor='contact-company'
label='Company'
optional
error={errors.company}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-company'
value={form.company}
Expand All @@ -183,21 +268,31 @@ export function ContactForm() {
className={LANDING_INPUT}
/>
</LandingField>
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
<LandingField
htmlFor='contact-topic'
label='Topic'
error={errors.topic}
labelClassName={LANDING_LABEL}
>
<Combobox
options={COMBOBOX_TOPICS}
options={CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption[]}
value={form.topic}
selectedValue={form.topic}
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
placeholder='Select a topic'
editable={false}
filterOptions={false}
className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
className={LANDING_COMBOBOX}
/>
</LandingField>
</div>

<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
<LandingField
htmlFor='contact-subject'
label='Subject'
error={errors.subject}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-subject'
value={form.subject}
Expand All @@ -207,33 +302,53 @@ export function ContactForm() {
/>
</LandingField>

<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
<LandingField
htmlFor='contact-message'
label='Message'
error={errors.message}
labelClassName={LANDING_LABEL}
>
<Textarea
id='contact-message'
value={form.message}
onChange={(event) => updateField('message', event.target.value)}
placeholder='Share details so we can help as quickly as possible'
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)]'
className={LANDING_TEXTAREA}
/>
</LandingField>

{turnstileSiteKey ? (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
onWidgetLoad={() => setWidgetReady(true)}
onExpire={() => setWidgetReady(false)}
onError={() => setWidgetReady(false)}
onUnsupported={() => setWidgetReady(false)}
/>
) : null}

{submitError ? (
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
Comment thread
waleedlatif1 marked this conversation as resolved.
{submitError}
</p>
) : null}

<button
type='submit'
disabled={contactMutation.isPending}
className={cn(
'flex h-[40px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)]',
'font-[430] font-season text-[14px] text-[var(--bg)] transition-opacity',
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60'
)}
>
{contactMutation.isPending ? 'Sending...' : 'Send message'}
<button type='submit' disabled={isBusy} className={LANDING_SUBMIT}>
{isBusy ? 'Sending...' : 'Send message'}
</button>

<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
By submitting, you agree to our{' '}
<Link
href='/privacy'
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
>
Privacy Policy
</Link>
.
</p>
</form>
)
}
41 changes: 28 additions & 13 deletions apps/sim/app/(landing)/components/forms/landing-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,44 @@ interface LandingFieldProps {
optional?: boolean
error?: string
children: React.ReactNode
/** Replaces the default label className. */
labelClassName?: string
}

export function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
const DEFAULT_LABEL_CLASSNAME =
'font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'

export function LandingField({
label,
htmlFor,
optional,
error,
children,
labelClassName,
}: LandingFieldProps) {
const errorId = error ? `${htmlFor}-error` : undefined
const describedChild =
errorId && isValidElement<{ 'aria-describedby'?: string; 'aria-invalid'?: boolean }>(children)
? cloneElement(children, { 'aria-describedby': errorId, 'aria-invalid': true })
: children
return (
<div className='flex flex-col gap-1.5'>
<label
htmlFor={htmlFor}
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</label>
<div className='flex min-h-[18px] items-baseline justify-between gap-3'>
<label htmlFor={htmlFor} className={labelClassName ?? DEFAULT_LABEL_CLASSNAME}>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</label>
{error ? (
<span
id={errorId}
role='alert'
className='truncate font-season text-[12px] text-[var(--text-error)]'
>
{error}
</span>
) : null}
</div>
{describedChild}
{error ? (
<p id={errorId} role='alert' className='text-[12px] text-[var(--text-error)]'>
{error}
</p>
) : null}
</div>
)
}
Loading
Loading