'use client'
/**
* `` — the canonical contact form used by every public
* surface (TMCG join, case-study pitch, generic /contact, Help Center
* ticket creation, etc.).
*
* Self-contained inside the lib — host-specific values (user id for
* tracking, platform-specific contact reasons, reddit-click attribution
* id) flow IN via props. The hub passes them via a thin
* `` wrapper that resolves them from `useAuth` /
* `getAppConfig` / `getStoredRedditClickId`. Other embedders pass
* whatever they have (or omit).
*
* Field-hide + custom-submit + extra-top-field knobs let one form
* serve both contact and ticket-creation flows without forking:
* - Contact page: rendered with all fields visible, built-in submit
* flow to `/api/contact` via `useContactSubmission`.
* - Ticket page: hides name/email/companySize/referralSource/
* helpCategory; supplies `extraTopField` (a Subject input) +
* `onCustomSubmit` wired to `useTicketActions.submitTicket`.
*/
import { useState, type ReactNode } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
ContactSchema,
type ContactFormData,
companySizeOptions,
referralSourceOptions,
defaultHelpCategoryOptions,
} from '../../schemas/contact-schema'
import { SECTION_HEADING_CLASS } from '../layout/page-heading'
import {
Button,
type ButtonProps,
Input,
Textarea,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Label,
} from '../ui'
import { useContactSubmission } from '../../hooks/use-contact-submission'
import { useHumanitySignals } from '../../hooks/use-humanity-signals'
import { HoneypotField } from '../ui/honeypot-field'
import {
ChatAttachmentAddButton,
ChatAttachmentChipStrip,
} from '../chat/chat-attachment-bar'
import { useChatAttachments } from '../chat/hooks/use-chat-attachments'
import type { ChatAttachment } from '../chat/utils/chat-attachment-markdown'
/**
* Fields the caller can suppress. Six values — every primary form
* field plus `name` and `email` (newly hideable so ticket-creation
* surfaces can hide them; they still need to validate, so the caller
* MUST supply pre-filled values via `defaultValues` when hiding them).
*/
export type ContactFormHideableField =
| 'name'
| 'email'
| 'companySize'
| 'referralSource'
| 'helpCategory'
| 'message'
export interface ContactFormProps {
/** Host-side user id passed to `useContactSubmission` for attribution.
* Hub wrapper passes `useAuth().user?.id`; lib's Help Center surface
* passes `useChatIdentity().user?.id`. Omit for anon flows. */
userId?: string
/** Platform-specific help-category dropdown options. Hub wrapper
* passes `getAppConfig().contact.contactReasons`. Defaults to the
* lib's `defaultHelpCategoryOptions`. */
helpCategoryOptions?: readonly string[]
/** Reddit click attribution id. Caller resolves from wherever they
* stash it (hub: sessionStorage via `getStoredRedditClickId`). When
* set, it's spread into the submission payload. */
rdtCid?: string
/** Called after a successful submit so the caller can clear their
* attribution storage (hub wrapper calls `clearStoredRedditClickId`).
* Fires for BOTH the built-in and custom submit paths. */
onSubmitSuccess?: () => void
prefilledReason?: string
prefilledMessage?: string
hideFields?: ContactFormHideableField[]
/** Authoritative pre-fill for any field the caller hides. Merged
* into react-hook-form's `defaultValues` AFTER the legacy
* `prefilledReason` / `prefilledMessage` props (caller-supplied
* wins). REQUIRED when hiding `name` / `email` / `helpCategory` —
* those fields are still validated by Zod even when not rendered. */
defaultValues?: Partial
/** Optional custom submit handler. When provided, the form bypasses
* the built-in `useContactSubmission` flow (no /api/contact call,
* no success-redirect, no built-in toast) — the caller owns the
* entire side-effect chain. Reset + `onSubmitSuccess` still fire
* on a successful await.
*
* Receives the schema-validated form payload PLUS the ready
* attachments array (empty when `attachmentsEnabled === false` or
* the user hasn't picked any). Caller forwards `attachments` to
* whichever sink owns the upload (e.g. `actions.submitTicket`'s
* `attachments` field for HubSpot Note engagements). */
onCustomSubmit?: (data: ContactFormData, attachments: ChatAttachment[]) => Promise
/** Turn on the attachments bar (file `+` button + chip strip) using
* the same lib primitives the chat composer uses
* (`` + `` +
* `useChatAttachments`). When `false` (the default), the form
* doesn't render the bar AND the attachments array passed to
* `onCustomSubmit` is always empty. */
attachmentsEnabled?: boolean
/** Render slot for an EXTRA field at the very top of the form,
* ABOVE the name/email row. Use this for ticket surfaces that need
* a Subject input — the field is NOT part of `ContactSchema`, so
* the caller manages its own state + validation and reads the
* value back inside `onCustomSubmit`. */
extraTopField?: ReactNode
title?: string
subtitle?: string
footerText?: string
noBorder?: boolean
noPadding?: boolean
buttonVariant?: ButtonProps['variant']
buttonClassName?: string
/** Submit-button label. Defaults to "Send Message". Override for
* ticket surfaces (e.g. "Open ticket"). */
submitLabel?: string
/** Success-state submit-button label (shown briefly after submit on
* the built-in flow). Defaults to "Message Sent!". Has no effect
* when `onCustomSubmit` is provided — the caller owns success UX. */
submitSuccessLabel?: string
successRedirectUrl?: string
successToastMessage?: string
}
export function ContactForm({
userId,
helpCategoryOptions = defaultHelpCategoryOptions,
rdtCid,
onSubmitSuccess,
prefilledReason,
prefilledMessage,
hideFields = [],
defaultValues: defaultValuesProp,
onCustomSubmit,
extraTopField,
attachmentsEnabled = false,
title = 'Hit Us Up',
subtitle,
footerText = 'We typically respond within 24 hours. We respect your privacy – no spam, ever.',
noBorder = false,
noPadding = false,
buttonVariant = 'accent',
buttonClassName = '',
submitLabel = 'Send Message',
submitSuccessLabel = 'Message Sent!',
successRedirectUrl = '/blog#community',
successToastMessage = 'Redirecting you to join our community...',
}: ContactFormProps = {}) {
// Attachments staging — same hook the chat composer + ticket
// detail-drawer composer use. Files upload to Supabase as soon as
// the user picks them; `readyAttachments` is the wire-shape array
// ready for the next submit. `hasInflightUploads` disables Send
// until every upload settles.
const attachments = useChatAttachments()
// Built-in contact-API flow. Hook is called unconditionally (rules
// of hooks); we just don't dispatch its `submit` when the caller
// passes `onCustomSubmit`. The hook owns its own toast + redirect
// chain so bypassing it cleanly hands all side-effects to the caller.
const builtInSubmission = useContactSubmission({
userId,
successRedirectUrl,
successToastMessage,
})
// Independent in-flight tracker for the custom path — we can't reuse
// `builtInSubmission.isSubmitting` because that hook never sees a
// request when `onCustomSubmit` is active.
const [customSubmitting, setCustomSubmitting] = useState(false)
// Invisible bot-protection signals (honeypot + timing). Spread into the
// submit payload for BOTH the built-in and custom paths; reset on success.
const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals()
const isSubmitting = onCustomSubmit ? customSubmitting : builtInSubmission.isSubmitting
// `isSuccess` only ever fires on the built-in path; custom callers
// own their own UX (no "Message Sent!" button-label flicker).
const isSuccess = onCustomSubmit ? false : builtInSubmission.isSuccess
const {
register,
handleSubmit,
control,
formState: { errors },
reset,
} = useForm({
resolver: zodResolver(ContactSchema),
defaultValues: {
...(prefilledReason && { helpCategory: prefilledReason }),
...(prefilledMessage && { message: prefilledMessage }),
// Caller-supplied defaults win over the legacy `prefilled*` props
// (they're the authoritative seed for hidden fields).
...defaultValuesProp,
},
})
const handleFormSubmit = async (data: ContactFormData) => {
if (isSubmitting) return
if (attachmentsEnabled && attachments.hasInflightUploads) return
try {
const payload = { ...data, ...(rdtCid && { rdt_cid: rdtCid }), ...getSignals() }
const readyAttachments = attachmentsEnabled ? attachments.readyAttachments : []
if (onCustomSubmit) {
setCustomSubmitting(true)
try {
await onCustomSubmit(payload, readyAttachments)
} finally {
setCustomSubmitting(false)
}
} else {
await builtInSubmission.submit(payload)
}
onSubmitSuccess?.()
reset()
resetSignals()
if (attachmentsEnabled) attachments.clear()
} catch {
// Error toast is owned by the active flow:
// - built-in: `useContactSubmission` toasts inside `submit()`.
// - custom: the caller toasts inside `onCustomSubmit`.
// Either way we swallow here so a thrown error doesn't crash the
// form tree (react-hook-form's onSubmit handler rejects upward).
}
}
const showName = !hideFields.includes('name')
const showEmail = !hideFields.includes('email')
const showNameEmailRow = showName || showEmail
const showCompanySize = !hideFields.includes('companySize')
const showReferralSource = !hideFields.includes('referralSource')
const showHelpCategory = !hideFields.includes('helpCategory')
const showMessage = !hideFields.includes('message')
return (