Skip to content

Sales enquiry

A qualification form that asks only what changes the conversation — and deliberately omits budget.

Contactstartersalesqualificationb2blead

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required, url } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Sales enquiry
 *
 * A qualification form that stays short by asking only what changes the
 * conversation: size, timeline and what they are replacing. Budget is
 * deliberately absent — it is the field that most often ends the enquiry.
 */
export default function SalesInquiryForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Name')] },
      email: { validators: [required('Work email'), email()] },
      company: { validators: [required('Company')] },
      website: { validators: [url()] },
      size: { initial: '51-200', validators: [required('Company size')] },
      timeline: { initial: 'this-quarter' },
      context: {},
      interests: { initial: 'false' },
    },
  })

  return (
    <FormShell
      title="Talk to sales"
      description="Tell us enough to make the first call useful."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Request a call"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Request received"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="name" label="Name" required error={form.error('name')}>
          {(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
        </Field>
        <Field name="email" label="Work email" required error={form.error('email')}>
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('email')} />
          )}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="company" label="Company" required error={form.error('company')}>
          {(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
        </Field>
        <Field name="website" label="Website" showOptional error={form.error('website')}>
          {(field) => (
            <Input
              {...field}
              inputMode="url"
              placeholder="example.com"
              {...form.field('website')}
            />
          )}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="size" label="Company size" required error={form.error('size')}>
          {(field) => (
            <Select
              {...field}
              {...form.field('size')}
              options={[
                { value: '1-10', label: '1–10 people' },
                { value: '11-50', label: '11–50 people' },
                { value: '51-200', label: '51–200 people' },
                { value: '201-1000', label: '201–1,000 people' },
                { value: '1000+', label: 'More than 1,000' },
              ]}
            />
          )}
        </Field>
        <Field name="timeline" label="Timeline">
          {(field) => (
            <Select
              {...field}
              {...form.field('timeline')}
              options={[
                { value: 'evaluating', label: 'Just evaluating' },
                { value: 'this-quarter', label: 'This quarter' },
                { value: 'next-quarter', label: 'Next quarter' },
                { value: 'urgent', label: 'We need this now' },
              ]}
            />
          )}
        </Field>
      </div>

      <Field
        name="context"
        label="What are you replacing?"
        showOptional
        hint="An internal library, another vendor, or nothing yet."
      >
        {(field) => <Textarea {...field} rows={4} {...form.field('context')} />}
      </Field>

      <Fieldset legend="Follow-up" name="interests">
        <Checkbox
          id="sales-newsletter"
          name="interests"
          label="Send me release notes"
          description="Roughly one email a month. Nothing is actually sent in this demo."
          checked={form.values.interests === 'true'}
          onChange={(event) => form.setValue('interests', String(event.target.checked))}
        />
      </Fieldset>
    </FormShell>
  )
}

components/blocks/forms/sales-inquiry.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, required, url } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Sales enquiry
 *
 * A qualification form that stays short by asking only what changes the
 * conversation: size, timeline and what they are replacing. Budget is
 * deliberately absent — it is the field that most often ends the enquiry.
 */
export default function SalesInquiryForm() {
  const form = useDemoForm({
    schema: {
      name: { validators: [required('Name')] },
      email: { validators: [required('Work email'), email()] },
      company: { validators: [required('Company')] },
      website: { validators: [url()] },
      size: { initial: '51-200', validators: [required('Company size')] },
      timeline: { initial: 'this-quarter' },
      context: {},
      interests: { initial: 'false' },
    },
  })

  return (
    <FormShell
      title="Talk to sales"
      description="Tell us enough to make the first call useful."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Request a call"
      submittingLabel="Sending"
      onReset={form.reset}
      successTitle="Request received"
      width="lg"
    >
      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="name" label="Name" required error={form.error('name')}>
          {(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
        </Field>
        <Field name="email" label="Work email" required error={form.error('email')}>
          {(field) => (
            <Input {...field} type="email" autoComplete="email" {...form.field('email')} />
          )}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="company" label="Company" required error={form.error('company')}>
          {(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
        </Field>
        <Field name="website" label="Website" showOptional error={form.error('website')}>
          {(field) => (
            <Input
              {...field}
              inputMode="url"
              placeholder="example.com"
              {...form.field('website')}
            />
          )}
        </Field>
      </div>

      <div className="grid gap-stack sm:grid-cols-2">
        <Field name="size" label="Company size" required error={form.error('size')}>
          {(field) => (
            <Select
              {...field}
              {...form.field('size')}
              options={[
                { value: '1-10', label: '1–10 people' },
                { value: '11-50', label: '11–50 people' },
                { value: '51-200', label: '51–200 people' },
                { value: '201-1000', label: '201–1,000 people' },
                { value: '1000+', label: 'More than 1,000' },
              ]}
            />
          )}
        </Field>
        <Field name="timeline" label="Timeline">
          {(field) => (
            <Select
              {...field}
              {...form.field('timeline')}
              options={[
                { value: 'evaluating', label: 'Just evaluating' },
                { value: 'this-quarter', label: 'This quarter' },
                { value: 'next-quarter', label: 'Next quarter' },
                { value: 'urgent', label: 'We need this now' },
              ]}
            />
          )}
        </Field>
      </div>

      <Field
        name="context"
        label="What are you replacing?"
        showOptional
        hint="An internal library, another vendor, or nothing yet."
      >
        {(field) => <Textarea {...field} rows={4} {...form.field('context')} />}
      </Field>

      <Fieldset legend="Follow-up" name="interests">
        <Checkbox
          id="sales-newsletter"
          name="interests"
          label="Send me release notes"
          description="Roughly one email a month. Nothing is actually sent in this demo."
          checked={form.values.interests === 'true'}
          onChange={(event) => form.setValue('interests', String(event.target.checked))}
        />
      </Fieldset>
    </FormShell>
  )
}

components/ui/select.tsx

import type { SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'

/**
 * Select
 *
 * A styled native `<select>`, on purpose. The native control brings free
 * keyboard support, free mobile pickers and free assistive-tech semantics; the
 * only thing Foundry adds is the chevron and the shared control chrome.
 *
 * When the interaction genuinely needs filtering or multi-select, reach for the
 * Combobox instead — see `/components/combobox`.
 */
export interface SelectOption {
  value: string
  label: string
  disabled?: boolean
}

export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
  options: SelectOption[]
  /** Rendered as a disabled, selected-by-default first option. */
  placeholder?: string
  selectSize?: 'sm' | 'md' | 'lg'
  /** Groups options under `<optgroup>` labels. */
  groups?: Array<{ label: string; options: SelectOption[] }>
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function Select({
  options,
  groups,
  placeholder,
  selectSize = 'md',
  className,
  ...props
}: SelectProps) {
  return (
    <div className="relative flex items-center">
      <select
        className={cn(
          controlSurface,
          heights[selectSize],
          'cursor-pointer appearance-none py-0 pr-9 pl-3',
          className,
        )}
        defaultValue={props.value === undefined && placeholder ? '' : undefined}
        {...props}
      >
        {placeholder ? (
          <option value="" disabled>
            {placeholder}
          </option>
        ) : null}
        {groups
          ? groups.map((group) => (
              <optgroup key={group.label} label={group.label}>
                {group.options.map((option) => (
                  <option key={option.value} value={option.value} disabled={option.disabled}>
                    {option.label}
                  </option>
                ))}
              </optgroup>
            ))
          : options.map((option) => (
              <option key={option.value} value={option.value} disabled={option.disabled}>
                {option.label}
              </option>
            ))}
      </select>
      <ChevronDown
        className="pointer-events-none absolute right-3 size-4 text-ink-subtle"
        aria-hidden="true"
      />
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Short by design: size, timeline and what they are replacing. Budget is absent because it is the field that most often ends the enquiry before it starts.

  • Website is optional and validated loosely — a URL rule that rejects “example.com” loses leads.
  • The opt-in is unchecked by default, which is both correct and, in many jurisdictions, required.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Company and size
  • Timeline
  • Replacement context
  • Newsletter opt-in

Accessibility

Optional marking
Optional fields are labelled, so required ones need no asterisk storm.
Grouped opt-in
The newsletter checkbox sits in its own Fieldset with a legend.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.

All forms