Skip to content

Commit 2456128

Browse files
authored
feat(contact): add contact page, migrate help/demo forms to useMutation (#4242)
* feat(contact): add contact page, migrate help/demo forms to useMutation * improvement(contact): address greptile review feedback - Map contact topic to help email type for accurate confirmation emails - Drop Zod schema details from 400 response on public /api/contact - Wire aria-describedby + aria-invalid in LandingField for both forms - Reset helpMutation on modal reopen to match demo-request pattern * improvement(landing): extract shared LandingField component
1 parent 699bbfd commit 2456128

9 files changed

Lines changed: 857 additions & 394 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { z } from 'zod'
2+
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
3+
import { quickValidateEmail } from '@/lib/messaging/email/validation'
4+
5+
export const CONTACT_TOPIC_VALUES = [
6+
'general',
7+
'support',
8+
'integration',
9+
'feature_request',
10+
'sales',
11+
'partnership',
12+
'billing',
13+
'other',
14+
] as const
15+
16+
export const CONTACT_TOPIC_OPTIONS = [
17+
{ value: 'general', label: 'General question' },
18+
{ value: 'support', label: 'Technical support' },
19+
{ value: 'integration', label: 'Integration request' },
20+
{ value: 'feature_request', label: 'Feature request' },
21+
{ value: 'sales', label: 'Sales & pricing' },
22+
{ value: 'partnership', label: 'Partnership' },
23+
{ value: 'billing', label: 'Billing' },
24+
{ value: 'other', label: 'Other' },
25+
] as const
26+
27+
export const contactRequestSchema = z.object({
28+
name: z
29+
.string()
30+
.trim()
31+
.min(1, 'Name is required')
32+
.max(120, 'Name must be 120 characters or less')
33+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
34+
email: z
35+
.string()
36+
.trim()
37+
.min(1, 'Email is required')
38+
.max(320)
39+
.transform((value) => value.toLowerCase())
40+
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'),
41+
company: z
42+
.string()
43+
.trim()
44+
.max(120, 'Company must be 120 characters or less')
45+
.optional()
46+
.transform((value) => (value && value.length > 0 ? value : undefined)),
47+
topic: z.enum(CONTACT_TOPIC_VALUES, {
48+
errorMap: () => ({ message: 'Please select a topic' }),
49+
}),
50+
subject: z
51+
.string()
52+
.trim()
53+
.min(1, 'Subject is required')
54+
.max(200, 'Subject must be 200 characters or less')
55+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
56+
message: z
57+
.string()
58+
.trim()
59+
.min(1, 'Message is required')
60+
.max(5000, 'Message must be 5,000 characters or less'),
61+
})
62+
63+
export type ContactRequestPayload = z.infer<typeof contactRequestSchema>
64+
65+
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
66+
return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value
67+
}
68+
69+
export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other'
70+
71+
export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType {
72+
switch (topic) {
73+
case 'feature_request':
74+
return 'feature_request'
75+
case 'support':
76+
return 'bug'
77+
case 'integration':
78+
return 'feedback'
79+
default:
80+
return 'other'
81+
}
82+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { useMutation } from '@tanstack/react-query'
5+
import { Combobox, Input, Textarea } from '@/components/emcn'
6+
import { Check } from '@/components/emcn/icons'
7+
import { cn } from '@/lib/core/utils/cn'
8+
import { captureClientEvent } from '@/lib/posthog/client'
9+
import {
10+
CONTACT_TOPIC_OPTIONS,
11+
type ContactRequestPayload,
12+
contactRequestSchema,
13+
} from '@/app/(landing)/components/contact/consts'
14+
import { LandingField } from '@/app/(landing)/components/forms/landing-field'
15+
16+
type ContactField = keyof ContactRequestPayload
17+
type ContactErrors = Partial<Record<ContactField, string>>
18+
19+
interface ContactFormState {
20+
name: string
21+
email: string
22+
company: string
23+
topic: ContactRequestPayload['topic'] | ''
24+
subject: string
25+
message: string
26+
}
27+
28+
const INITIAL_FORM_STATE: ContactFormState = {
29+
name: '',
30+
email: '',
31+
company: '',
32+
topic: '',
33+
subject: '',
34+
message: '',
35+
}
36+
37+
const COMBOBOX_TOPICS = [...CONTACT_TOPIC_OPTIONS]
38+
39+
const 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+
42+
async function submitContactRequest(payload: ContactRequestPayload) {
43+
const response = await fetch('/api/contact', {
44+
method: 'POST',
45+
headers: { 'Content-Type': 'application/json' },
46+
body: JSON.stringify(payload),
47+
})
48+
49+
const result = (await response.json().catch(() => null)) as {
50+
error?: string
51+
message?: string
52+
} | null
53+
54+
if (!response.ok) {
55+
throw new Error(result?.error || 'Failed to send message')
56+
}
57+
58+
return result
59+
}
60+
61+
export function ContactForm() {
62+
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
63+
const [errors, setErrors] = useState<ContactErrors>({})
64+
const [submitSuccess, setSubmitSuccess] = useState(false)
65+
66+
const contactMutation = useMutation({
67+
mutationFn: submitContactRequest,
68+
onSuccess: (_data, variables) => {
69+
captureClientEvent('landing_contact_submitted', { topic: variables.topic })
70+
setForm(INITIAL_FORM_STATE)
71+
setErrors({})
72+
setSubmitSuccess(true)
73+
},
74+
})
75+
76+
function updateField<TField extends keyof ContactFormState>(
77+
field: TField,
78+
value: ContactFormState[TField]
79+
) {
80+
setForm((prev) => ({ ...prev, [field]: value }))
81+
setErrors((prev) => {
82+
if (!prev[field as ContactField]) {
83+
return prev
84+
}
85+
const nextErrors = { ...prev }
86+
delete nextErrors[field as ContactField]
87+
return nextErrors
88+
})
89+
if (contactMutation.isError) {
90+
contactMutation.reset()
91+
}
92+
}
93+
94+
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
95+
event.preventDefault()
96+
if (contactMutation.isPending) return
97+
98+
const parsed = contactRequestSchema.safeParse({
99+
...form,
100+
company: form.company || undefined,
101+
})
102+
103+
if (!parsed.success) {
104+
const fieldErrors = parsed.error.flatten().fieldErrors
105+
setErrors({
106+
name: fieldErrors.name?.[0],
107+
email: fieldErrors.email?.[0],
108+
company: fieldErrors.company?.[0],
109+
topic: fieldErrors.topic?.[0],
110+
subject: fieldErrors.subject?.[0],
111+
message: fieldErrors.message?.[0],
112+
})
113+
return
114+
}
115+
116+
contactMutation.mutate(parsed.data)
117+
}
118+
119+
const submitError = contactMutation.isError
120+
? contactMutation.error instanceof Error
121+
? contactMutation.error.message
122+
: 'Failed to send message. Please try again.'
123+
: null
124+
125+
if (submitSuccess) {
126+
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)]'>
129+
<Check className='h-8 w-8' />
130+
</div>
131+
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
132+
Message received
133+
</h2>
134+
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
135+
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
136+
shortly.
137+
</p>
138+
<button
139+
type='button'
140+
onClick={() => setSubmitSuccess(false)}
141+
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
142+
>
143+
Send another message
144+
</button>
145+
</div>
146+
)
147+
}
148+
149+
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}>
156+
<Input
157+
id='contact-name'
158+
value={form.name}
159+
onChange={(event) => updateField('name', event.target.value)}
160+
placeholder='Your name'
161+
className={LANDING_INPUT}
162+
/>
163+
</LandingField>
164+
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
165+
<Input
166+
id='contact-email'
167+
type='email'
168+
value={form.email}
169+
onChange={(event) => updateField('email', event.target.value)}
170+
placeholder='you@company.com'
171+
className={LANDING_INPUT}
172+
/>
173+
</LandingField>
174+
</div>
175+
176+
<div className='grid gap-4 sm:grid-cols-2'>
177+
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
178+
<Input
179+
id='contact-company'
180+
value={form.company}
181+
onChange={(event) => updateField('company', event.target.value)}
182+
placeholder='Company name'
183+
className={LANDING_INPUT}
184+
/>
185+
</LandingField>
186+
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
187+
<Combobox
188+
options={COMBOBOX_TOPICS}
189+
value={form.topic}
190+
selectedValue={form.topic}
191+
onChange={(value) => updateField('topic', value as ContactRequestPayload['topic'])}
192+
placeholder='Select a topic'
193+
editable={false}
194+
filterOptions={false}
195+
className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]'
196+
/>
197+
</LandingField>
198+
</div>
199+
200+
<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
201+
<Input
202+
id='contact-subject'
203+
value={form.subject}
204+
onChange={(event) => updateField('subject', event.target.value)}
205+
placeholder='How can we help?'
206+
className={LANDING_INPUT}
207+
/>
208+
</LandingField>
209+
210+
<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
211+
<Textarea
212+
id='contact-message'
213+
value={form.message}
214+
onChange={(event) => updateField('message', event.target.value)}
215+
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)]'
217+
/>
218+
</LandingField>
219+
220+
{submitError ? (
221+
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
222+
{submitError}
223+
</p>
224+
) : null}
225+
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+
>
235+
{contactMutation.isPending ? 'Sending...' : 'Send message'}
236+
</button>
237+
</form>
238+
)
239+
}

0 commit comments

Comments
 (0)