diff --git a/apps/sim/app/(landing)/components/contact/consts.ts b/apps/sim/app/(landing)/components/contact/consts.ts new file mode 100644 index 00000000000..242d06ecaf0 --- /dev/null +++ b/apps/sim/app/(landing)/components/contact/consts.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils' +import { quickValidateEmail } from '@/lib/messaging/email/validation' + +export const CONTACT_TOPIC_VALUES = [ + 'general', + 'support', + 'integration', + 'feature_request', + 'sales', + 'partnership', + 'billing', + 'other', +] as const + +export const CONTACT_TOPIC_OPTIONS = [ + { value: 'general', label: 'General question' }, + { value: 'support', label: 'Technical support' }, + { value: 'integration', label: 'Integration request' }, + { value: 'feature_request', label: 'Feature request' }, + { value: 'sales', label: 'Sales & pricing' }, + { value: 'partnership', label: 'Partnership' }, + { value: 'billing', label: 'Billing' }, + { value: 'other', label: 'Other' }, +] as const + +export const contactRequestSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(120, 'Name must be 120 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + email: z + .string() + .trim() + .min(1, 'Email is required') + .max(320) + .transform((value) => value.toLowerCase()) + .refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'), + company: z + .string() + .trim() + .max(120, 'Company must be 120 characters or less') + .optional() + .transform((value) => (value && value.length > 0 ? value : undefined)), + topic: z.enum(CONTACT_TOPIC_VALUES, { + errorMap: () => ({ message: 'Please select a topic' }), + }), + subject: z + .string() + .trim() + .min(1, 'Subject is required') + .max(200, 'Subject must be 200 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + message: z + .string() + .trim() + .min(1, 'Message is required') + .max(5000, 'Message must be 5,000 characters or less'), +}) + +export type ContactRequestPayload = z.infer + +export function getContactTopicLabel(value: ContactRequestPayload['topic']): string { + return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value +} + +export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other' + +export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType { + switch (topic) { + case 'feature_request': + return 'feature_request' + case 'support': + return 'bug' + case 'integration': + return 'feedback' + default: + return 'other' + } +} diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx new file mode 100644 index 00000000000..4cde2b3da7a --- /dev/null +++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx @@ -0,0 +1,239 @@ +'use client' + +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { Combobox, Input, Textarea } from '@/components/emcn' +import { Check } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import { captureClientEvent } from '@/lib/posthog/client' +import { + CONTACT_TOPIC_OPTIONS, + type ContactRequestPayload, + contactRequestSchema, +} from '@/app/(landing)/components/contact/consts' +import { LandingField } from '@/app/(landing)/components/forms/landing-field' + +type ContactField = keyof ContactRequestPayload +type ContactErrors = Partial> + +interface ContactFormState { + name: string + email: string + company: string + topic: ContactRequestPayload['topic'] | '' + subject: string + message: string +} + +const INITIAL_FORM_STATE: ContactFormState = { + name: '', + email: '', + company: '', + topic: '', + subject: '', + 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)]' + +async function submitContactRequest(payload: ContactRequestPayload) { + const response = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + const result = (await response.json().catch(() => null)) as { + error?: string + message?: string + } | null + + if (!response.ok) { + throw new Error(result?.error || 'Failed to send message') + } + + return result +} + +export function ContactForm() { + const [form, setForm] = useState(INITIAL_FORM_STATE) + const [errors, setErrors] = useState({}) + const [submitSuccess, setSubmitSuccess] = useState(false) + + const contactMutation = useMutation({ + mutationFn: submitContactRequest, + onSuccess: (_data, variables) => { + captureClientEvent('landing_contact_submitted', { topic: variables.topic }) + setForm(INITIAL_FORM_STATE) + setErrors({}) + setSubmitSuccess(true) + }, + }) + + function updateField( + 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() + } + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (contactMutation.isPending) return + + const parsed = contactRequestSchema.safeParse({ + ...form, + company: form.company || undefined, + }) + + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors + setErrors({ + name: fieldErrors.name?.[0], + email: fieldErrors.email?.[0], + company: fieldErrors.company?.[0], + topic: fieldErrors.topic?.[0], + subject: fieldErrors.subject?.[0], + message: fieldErrors.message?.[0], + }) + return + } + + contactMutation.mutate(parsed.data) + } + + const submitError = contactMutation.isError + ? contactMutation.error instanceof Error + ? contactMutation.error.message + : 'Failed to send message. Please try again.' + : null + + if (submitSuccess) { + return ( +
+
+ +
+

+ Message received +

+

+ Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you + shortly. +

+ +
+ ) + } + + return ( +
+
+ + updateField('name', event.target.value)} + placeholder='Your name' + className={LANDING_INPUT} + /> + + + updateField('email', event.target.value)} + placeholder='you@company.com' + className={LANDING_INPUT} + /> + +
+ +
+ + updateField('company', event.target.value)} + placeholder='Company name' + className={LANDING_INPUT} + /> + + + 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]' + /> + +
+ + + updateField('subject', event.target.value)} + placeholder='How can we help?' + className={LANDING_INPUT} + /> + + + +