chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useId, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn'
|
||||
import { Check } from '@sim/emcn/icons'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import {
|
||||
CONTACT_TOPIC_OPTIONS,
|
||||
type ContactRequestPayload,
|
||||
contactRequestSchema,
|
||||
} from '@/lib/api/contracts/contact'
|
||||
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import { useSubmitContact } from '@/hooks/queries/contact'
|
||||
|
||||
/**
|
||||
* Field control height — slightly taller than the 30px in-app chip default and
|
||||
* just under the 36px auth field, so the form reads as a roomy landing surface.
|
||||
* Applied to each control's `className`, the sanctioned way to own only a chip
|
||||
* field's height (mirrors the demo form).
|
||||
*/
|
||||
const FIELD_HEIGHT = 'h-[34px]'
|
||||
|
||||
/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */
|
||||
const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')
|
||||
|
||||
type ContactField = keyof ContactRequestPayload
|
||||
type ContactErrors = Partial<Record<ContactField, string>>
|
||||
|
||||
interface ContactFormState {
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
topic: ContactRequestPayload['topic'] | ''
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ContactFormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
topic: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
interface ContactFieldProps {
|
||||
label: string
|
||||
/** Set for native controls (inputs/textarea) to associate the label by `id`. */
|
||||
htmlFor?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
/** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label,
|
||||
* caption-sized error). Native controls associate via `htmlFor`/`id`; controls
|
||||
* that can't take a label `id` (the dropdown) become a `role='group'` named by
|
||||
* the label instead, so every field has an accessible name.
|
||||
*/
|
||||
function ContactField({ label, htmlFor, required, error, children }: ContactFieldProps) {
|
||||
const labelId = useId()
|
||||
const isGroup = htmlFor === undefined
|
||||
return (
|
||||
<div
|
||||
className='flex flex-col gap-[9px]'
|
||||
role={isGroup ? 'group' : undefined}
|
||||
aria-labelledby={isGroup ? labelId : undefined}
|
||||
>
|
||||
<Label id={labelId} htmlFor={htmlFor} className='pl-0.5 font-normal text-[var(--text-muted)]'>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden className='ml-0.5 text-[var(--text-error)]'>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{children}
|
||||
{error ? <p className='pl-0.5 text-[var(--text-error)] text-caption'>{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/contact` form — rendered inside the card chrome owned by the page, so it
|
||||
* returns just its heading and fields. Fields are hand-composed at the slightly
|
||||
* taller {@link FIELD_HEIGHT}, stacked at the platform `gap-4` rhythm with no
|
||||
* divider lines, mirroring the demo booking form.
|
||||
*
|
||||
* On submit it validates against the shared {@link contactRequestSchema}, runs an
|
||||
* invisible Turnstile challenge (falling back gracefully when the widget is
|
||||
* unavailable), and posts through {@link useSubmitContact}, which emails the help
|
||||
* inbox and sends the visitor a confirmation. A honeypot `website` field and the
|
||||
* captcha token ride along on the payload. A successful submit swaps the card to a
|
||||
* confirmation state.
|
||||
*/
|
||||
export function ContactForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const contactMutation = useSubmitContact()
|
||||
|
||||
const [form, setForm] = useState<ContactFormState>(INITIAL_STATE)
|
||||
const [errors, setErrors] = useState<ContactErrors>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [website, setWebsite] = useState('')
|
||||
const widgetLoadedRef = useRef(false)
|
||||
|
||||
function updateField<TField extends keyof ContactFormState>(
|
||||
field: TField,
|
||||
value: ContactFormState[TField]
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => {
|
||||
if (!prev[field as ContactField]) {
|
||||
return prev
|
||||
}
|
||||
const nextErrors = { ...prev }
|
||||
delete nextErrors[field as ContactField]
|
||||
return nextErrors
|
||||
})
|
||||
if (contactMutation.isError) {
|
||||
contactMutation.reset()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (contactMutation.isPending || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
const parsed = contactRequestSchema.safeParse({
|
||||
...form,
|
||||
company: form.company || undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
setErrors(flattenFieldErrors<ContactField>(parsed.error))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
let captchaToken: string | undefined
|
||||
const widget = turnstileRef.current
|
||||
|
||||
if (TURNSTILE_SITE_KEY && widgetLoadedRef.current && widget) {
|
||||
try {
|
||||
widget.reset()
|
||||
widget.execute()
|
||||
captchaToken = await widget.getResponsePromise(30_000)
|
||||
} catch {
|
||||
captchaToken = undefined
|
||||
}
|
||||
}
|
||||
|
||||
contactMutation.mutate(
|
||||
{ ...parsed.data, website, captchaToken },
|
||||
{
|
||||
onSuccess: () => {
|
||||
captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic })
|
||||
setForm(INITIAL_STATE)
|
||||
setErrors({})
|
||||
},
|
||||
onError: () => {
|
||||
turnstileRef.current?.reset()
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
quickValidateEmail(form.email.trim()).isValid &&
|
||||
form.name.trim().length > 0 &&
|
||||
form.topic.length > 0 &&
|
||||
form.subject.trim().length > 0 &&
|
||||
form.message.trim().length > 0
|
||||
|
||||
const isBusy = contactMutation.isPending || isSubmitting
|
||||
|
||||
const submitError = contactMutation.isError
|
||||
? toError(contactMutation.error).message || 'Failed to send message. Please try again.'
|
||||
: null
|
||||
|
||||
if (contactMutation.isSuccess) {
|
||||
return (
|
||||
<div className='flex flex-col items-center px-4 py-12 text-center'>
|
||||
<div className='flex size-14 items-center justify-center rounded-full border border-[var(--border-1)] bg-[var(--surface-1)] text-[var(--text-primary)]'>
|
||||
<Check className='size-7' />
|
||||
</div>
|
||||
<h2 className='mt-5 text-[var(--text-primary)] text-xl leading-[1.2]'>Message received</h2>
|
||||
<p className='mt-2 max-w-sm text-[var(--text-muted)] text-sm leading-[1.6]'>
|
||||
Thanks for reaching out. Our team will get back to you shortly.
|
||||
</p>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => contactMutation.reset()}
|
||||
className='mt-5 text-[var(--text-primary)] text-small underline underline-offset-2 transition-opacity hover:opacity-80'
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 id='contact-form-heading' className='text-[var(--text-primary)] text-xl leading-[1.2]'>
|
||||
Send us a message
|
||||
</h2>
|
||||
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
|
||||
Ask a question, request an integration, or get help — we'll get back to you shortly.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
aria-labelledby='contact-form-heading'
|
||||
className='relative mt-5 flex flex-col gap-4'
|
||||
noValidate
|
||||
>
|
||||
<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 grid-cols-2 gap-3 max-sm:grid-cols-1'>
|
||||
<ContactField label='Name' htmlFor='contact-name' required error={errors.name}>
|
||||
<ChipInput
|
||||
id='contact-name'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.name}
|
||||
onChange={(event) => updateField('name', event.target.value)}
|
||||
error={Boolean(errors.name)}
|
||||
placeholder='Jane Doe'
|
||||
autoComplete='name'
|
||||
/>
|
||||
</ContactField>
|
||||
<ContactField label='Email' htmlFor='contact-email' required error={errors.email}>
|
||||
<ChipInput
|
||||
id='contact-email'
|
||||
type='email'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.email}
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
error={Boolean(errors.email)}
|
||||
placeholder='jane@acme.co'
|
||||
autoComplete='email'
|
||||
/>
|
||||
</ContactField>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3 max-sm:grid-cols-1'>
|
||||
<ContactField label='Company (optional)' htmlFor='contact-company' error={errors.company}>
|
||||
<ChipInput
|
||||
id='contact-company'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.company}
|
||||
onChange={(event) => updateField('company', event.target.value)}
|
||||
error={Boolean(errors.company)}
|
||||
placeholder='Acme Inc.'
|
||||
autoComplete='organization'
|
||||
/>
|
||||
</ContactField>
|
||||
<ContactField label='Topic' required error={errors.topic}>
|
||||
<ChipDropdown
|
||||
fullWidth
|
||||
flush
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.topic || undefined}
|
||||
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
|
||||
options={CONTACT_TOPIC_OPTIONS}
|
||||
placeholder='Select a topic'
|
||||
/>
|
||||
</ContactField>
|
||||
</div>
|
||||
|
||||
<ContactField label='Subject' htmlFor='contact-subject' required error={errors.subject}>
|
||||
<ChipInput
|
||||
id='contact-subject'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.subject}
|
||||
onChange={(event) => updateField('subject', event.target.value)}
|
||||
error={Boolean(errors.subject)}
|
||||
placeholder='How can we help?'
|
||||
/>
|
||||
</ContactField>
|
||||
|
||||
<ContactField label='Message' htmlFor='contact-message' required error={errors.message}>
|
||||
<ChipTextarea
|
||||
id='contact-message'
|
||||
value={form.message}
|
||||
onChange={(event) => updateField('message', event.target.value)}
|
||||
error={Boolean(errors.message)}
|
||||
placeholder='Share details so we can help as quickly as possible.'
|
||||
rows={4}
|
||||
/>
|
||||
</ContactField>
|
||||
|
||||
{TURNSTILE_SITE_KEY ? (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
|
||||
onWidgetLoad={() => {
|
||||
widgetLoadedRef.current = true
|
||||
}}
|
||||
onError={() => {
|
||||
widgetLoadedRef.current = false
|
||||
}}
|
||||
onUnsupported={() => {
|
||||
widgetLoadedRef.current = false
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<p role='alert' className='text-[var(--text-error)] text-caption'>
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Chip
|
||||
type='submit'
|
||||
variant='primary'
|
||||
flush
|
||||
fullWidth
|
||||
disabled={isBusy || !canSubmit}
|
||||
className='mt-1 justify-center [&>span]:flex-none'
|
||||
>
|
||||
{isBusy ? 'Sending…' : 'Send message'}
|
||||
</Chip>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ContactForm } from './contact-form'
|
||||
Reference in New Issue
Block a user