
Contact sales form in one narrow column: first and last name, optional company, email, country select with phone, message and a privacy consent checkbox. Validated with react-hook-form and zod.
blocks/contact/contact-form-01/index.tsx
"use client"
import { useId } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { Heading } from "@/components/editorial/heading"
import {
Section,
SectionContent,
SectionDescription,
SectionHeader,
SectionTitleGroup,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
NativeSelect,
NativeSelectOption,
} from "@/components/ui/native-select"
import { Textarea } from "@/components/ui/textarea"
// The copy: change text here, not in the markup.
const COPY = {
title: "Contact sales",
body: "Tell us about the site you are building and a person on the team replies within one working day.",
firstName: "First name",
lastName: "Last name",
company: "Company",
email: "Email",
country: "Country",
phone: "Phone number",
message: "Message",
optional: "Optional",
consent: {
before: "By selecting this, you agree to our ",
link: "privacy policy",
after: ".",
},
submit: "Let’s talk",
errors: {
firstName: "Enter your first name.",
lastName: "Enter your last name.",
email: "Enter a valid email address.",
message: "Tell us a little about the project.",
consent: "Agree to the privacy policy to continue.",
},
} as const
// value is the ISO 3166 country code the form submits with the phone.
const COUNTRIES = [
{ value: "US", label: "United States (+1)" },
{ value: "CA", label: "Canada (+1)" },
{ value: "GB", label: "United Kingdom (+44)" },
{ value: "DE", label: "Germany (+49)" },
{ value: "FR", label: "France (+33)" },
{ value: "AU", label: "Australia (+61)" },
] as const
// What the form validates and submits. Messages come from COPY.errors.
const schema = z.object({
firstName: z.string().trim().min(1, COPY.errors.firstName),
lastName: z.string().trim().min(1, COPY.errors.lastName),
company: z.string().trim(),
email: z.email(COPY.errors.email),
country: z.enum(COUNTRIES.map((country) => country.value)),
phone: z.string().trim(),
message: z.string().trim().min(1, COPY.errors.message),
consent: z.boolean().refine((agreed) => agreed, COPY.errors.consent),
})
type ContactValues = z.infer<typeof schema>
export default function ContactForm01() {
const id = useId()
const form = useForm<ContactValues>({
resolver: zodResolver(schema),
defaultValues: {
firstName: "",
lastName: "",
company: "",
email: "",
country: "US",
phone: "",
message: "",
consent: false,
},
})
// Your send action goes here; until then the form only validates.
function onSubmit(values: ContactValues) {
void values
}
return (
<Section>
<SectionContent width="sm" size="lg">
{/* The header shares the form's column and its left edge. */}
<SectionHeader className="mx-auto max-w-xl">
<SectionTitleGroup>
<Heading>{COPY.title}</Heading>
</SectionTitleGroup>
<SectionDescription>{COPY.body}</SectionDescription>
</SectionHeader>
<form
onSubmit={form.handleSubmit(onSubmit)}
noValidate
className="mx-auto w-full max-w-xl"
>
<FieldGroup>
<div className="grid gap-4 @2xl/section:grid-cols-2">
<Controller
name="firstName"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}-first`}>
{COPY.firstName}
</FieldLabel>
<Input
{...field}
id={`${id}-first`}
autoComplete="given-name"
required
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="lastName"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}-last`}>
{COPY.lastName}
</FieldLabel>
<Input
{...field}
id={`${id}-last`}
autoComplete="family-name"
required
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
<Controller
name="company"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor={`${id}-company`}>
{COPY.company}
</FieldLabel>
<Input
{...field}
id={`${id}-company`}
autoComplete="organization"
aria-describedby={`${id}-company-note`}
/>
<FieldDescription id={`${id}-company-note`}>
{COPY.optional}
</FieldDescription>
</Field>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}-email`}>{COPY.email}</FieldLabel>
<Input
{...field}
id={`${id}-email`}
type="email"
autoComplete="email"
required
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid gap-4 @2xl/section:grid-cols-[auto_1fr]">
<Controller
name="country"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor={`${id}-country`}>
{COPY.country}
</FieldLabel>
<NativeSelect
{...field}
id={`${id}-country`}
autoComplete="country"
className="w-full"
>
{COUNTRIES.map((country) => (
<NativeSelectOption
key={country.value}
value={country.value}
>
{country.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)}
/>
<Controller
name="phone"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor={`${id}-phone`}>
{COPY.phone}
</FieldLabel>
<Input
{...field}
id={`${id}-phone`}
type="tel"
autoComplete="tel-national"
placeholder="555-123-4567"
aria-describedby={`${id}-phone-note`}
/>
<FieldDescription id={`${id}-phone-note`}>
{COPY.optional}
</FieldDescription>
</Field>
)}
/>
</div>
<Controller
name="message"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`${id}-message`}>
{COPY.message}
</FieldLabel>
<Textarea
{...field}
id={`${id}-message`}
rows={4}
required
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="consent"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Field orientation="horizontal">
<Checkbox
ref={field.ref}
id={`${id}-consent`}
name={field.name}
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(checked === true)
}
onBlur={field.onBlur}
required
aria-invalid={fieldState.invalid}
/>
<FieldLabel
htmlFor={`${id}-consent`}
className="font-normal"
>
<span>
{COPY.consent.before}
<a
href="#"
className="font-semibold underline underline-offset-4"
>
{COPY.consent.link}
</a>
{COPY.consent.after}
</span>
</FieldLabel>
</Field>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Field>
<Button type="submit" size="lg">
{COPY.submit}
</Button>
</Field>
</FieldGroup>
</form>
</SectionContent>
</Section>
)
}