|
| 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 | +} |
0 commit comments