Skip to content

Form

Overview

The primary form is the booking inquiry form at /contact. All form handling is done via Next.js Server Actions with Zod validation — no client-side form library is used.

Field Inventory

FieldTypeRequiredValidation
NameText inputYesMin 2 chars
OrganizationText inputYesMin 2 chars
Engagement TypeSelectYesOne of defined options
Budget RangeSelectNoOne of defined options
Preferred DateDate inputNoMust be future date
MessageTextareaYesMin 20 chars, max 1000

Field Styling

All form fields share the same base styling:

const fieldBase =
'w-full rounded-sm border border-brand-gray bg-brand-dark px-4 py-3 ' +
'text-sm text-white placeholder:text-white/30 ' +
'focus:border-brand-red focus:outline-none focus:ring-1 focus:ring-brand-red ' +
'transition-colors duration-150';

Text Input

<input
type="text"
className={fieldBase}
placeholder="Brett Johnson"
aria-describedby="name-error"
/>

Select

<select className={`${fieldBase} appearance-none cursor-pointer`}>
<option value="">Select type…</option>
<option value="keynote">Keynote Speaking</option>
<option value="workshop">Executive Workshop</option>
<option value="training">Law Enforcement Training</option>
<option value="consulting">Enterprise Consulting</option>
<option value="media">Media / Documentary</option>
<option value="podcast">Podcast / Interview</option>
</select>

Textarea

<textarea
rows={5}
className={fieldBase}
placeholder="Tell Brett about the engagement…"
/>

Validation — Zod Schema

lib/schemas/booking.ts
import { z } from 'zod';
export const bookingSchema = z.object({
name: z.string().min(2).max(100),
organization: z.string().min(2).max(100),
engagementType: z.enum(['keynote', 'workshop', 'training', 'consulting', 'media', 'podcast']),
budgetRange: z.enum(['under-10k', '10k-25k', '25k-50k', '50k-plus', 'tbd']).optional(),
preferredDate: z.string().optional(),
message: z.string().min(20).max(1000),
});

Server Action

app/contact/actions.ts
'use server';
import { bookingSchema } from '@/lib/schemas/booking';
import { saveLead } from '@/lib/supabase';
import { sendConfirmation } from '@/lib/email';
export async function submitBookingInquiry(formData: FormData) {
const raw = Object.fromEntries(formData.entries());
const result = bookingSchema.safeParse(raw);
if (!result.success) {
return { error: 'Validation failed', fields: result.error.flatten().fieldErrors };
}
await saveLead(result.data);
await sendConfirmation(result.data);
return { success: true };
}

Feedback States

Inline Field Error

{errors.name && (
<p id="name-error" className="mt-1 text-xs text-red-400" role="alert">
{errors.name}
</p>
)}

Form-Level Success

{state.success && (
<div className="rounded-sm border border-brand-red/30 bg-brand-red/10 p-4 text-sm text-white">
Got it — Brett's team will follow up within 24 hours.
</div>
)}

Form-Level Error

{state.error && (
<div className="rounded-sm border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300" role="alert">
Something went wrong. Try again or email directly.
</div>
)}

Accessibility

  • Every field has an associated <label> (visually present or sr-only)
  • Error messages are linked via aria-describedby
  • Error messages use role="alert" for screen reader announcement
  • Submit button is disabled during pending server action
  • Cloudflare Turnstile is rendered on the form for bot protection