Skip to content

Promotion code

The smallest form in the library, with a distinct message for each way a code can fail.

Commercestartercouponpromodiscounterror-states

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 { Badge } from '@/components/ui/badge'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Coupon / promo code
 *
 * The smallest form in the library, and the one with the most failure states:
 * unknown code, expired code, code that does not apply to this basket. Each
 * gets its own message, because "invalid code" tells the customer nothing they
 * can act on.
 *
 * Try FOUNDRY20 (valid), EXPIRED2024 or TEAMONLY.
 */
const codes: Record<string, { ok: boolean; message: string }> = {
  FOUNDRY20: { ok: true, message: '20% off your first year has been applied.' },
  EXPIRED2024: { ok: false, message: 'That code expired on 31 December 2024.' },
  TEAMONLY: {
    ok: false,
    message: 'That code only applies to the Team plan. Your basket is on Solo.',
  },
}

export default function CouponForm() {
  const form = useDemoForm({
    schema: { code: { validators: [required('Code')] } },
    latency: 600,
    simulateServerError: (values) => {
      const entry = codes[(values.code ?? '').trim().toUpperCase()]
      if (!entry) return 'We do not recognise that code. Check for typos, or ask whoever shared it.'
      return entry.ok ? null : entry.message
    },
  })

  return (
    <FormShell
      title="Apply a promotion code"
      description="Try FOUNDRY20, EXPIRED2024 or TEAMONLY to see each failure path."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Apply code"
      submittingLabel="Checking code"
      onReset={form.reset}
      successTitle="Code applied"
      successBody={
        <p>
          <Badge tone="success" className="mr-1.5">
            FOUNDRY20
          </Badge>
          20% off your first year. This is a template demo — no submission was sent.
        </p>
      }
      width="sm"
    >
      <Field name="code" label="Promotion code" required error={form.error('code')}>
        {(field) => (
          <Input
            {...field}
            className="font-mono uppercase"
            placeholder="FOUNDRY20"
            autoCapitalize="characters"
            autoCorrect="off"
            spellCheck={false}
            {...form.field('code')}
          />
        )}
      </Field>
    </FormShell>
  )
}

components/blocks/forms/coupon.tsx

'use client'

import { Badge } from '@/components/ui/badge'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useDemoForm } from '@/hooks/use-demo-form'
import { required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Coupon / promo code
 *
 * The smallest form in the library, and the one with the most failure states:
 * unknown code, expired code, code that does not apply to this basket. Each
 * gets its own message, because "invalid code" tells the customer nothing they
 * can act on.
 *
 * Try FOUNDRY20 (valid), EXPIRED2024 or TEAMONLY.
 */
const codes: Record<string, { ok: boolean; message: string }> = {
  FOUNDRY20: { ok: true, message: '20% off your first year has been applied.' },
  EXPIRED2024: { ok: false, message: 'That code expired on 31 December 2024.' },
  TEAMONLY: {
    ok: false,
    message: 'That code only applies to the Team plan. Your basket is on Solo.',
  },
}

export default function CouponForm() {
  const form = useDemoForm({
    schema: { code: { validators: [required('Code')] } },
    latency: 600,
    simulateServerError: (values) => {
      const entry = codes[(values.code ?? '').trim().toUpperCase()]
      if (!entry) return 'We do not recognise that code. Check for typos, or ask whoever shared it.'
      return entry.ok ? null : entry.message
    },
  })

  return (
    <FormShell
      title="Apply a promotion code"
      description="Try FOUNDRY20, EXPIRED2024 or TEAMONLY to see each failure path."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Apply code"
      submittingLabel="Checking code"
      onReset={form.reset}
      successTitle="Code applied"
      successBody={
        <p>
          <Badge tone="success" className="mr-1.5">
            FOUNDRY20
          </Badge>
          20% off your first year. This is a template demo — no submission was sent.
        </p>
      }
      width="sm"
    >
      <Field name="code" label="Promotion code" required error={form.error('code')}>
        {(field) => (
          <Input
            {...field}
            className="font-mono uppercase"
            placeholder="FOUNDRY20"
            autoCapitalize="characters"
            autoCorrect="off"
            spellCheck={false}
            {...form.field('code')}
          />
        )}
      </Field>
    </FormShell>
  )
}

hooks/use-demo-form.ts

'use client'

import { useCallback, useMemo, useRef, useState, type FormEvent } from 'react'
import { runValidators, type Validator } from '@/lib/validation'

export type FormStatus = 'idle' | 'submitting' | 'success' | 'error'

export interface FieldSchema {
  initial?: string
  validators?: Validator[]
}

export interface UseDemoFormOptions<Schema extends Record<string, FieldSchema>> {
  schema: Schema
  /**
   * Simulated latency, in milliseconds, so the loading state is observable.
   * Nothing is sent anywhere.
   */
  latency?: number
  /** Return a message to force a server-style failure for demonstration. */
  simulateServerError?: (values: Record<string, string>) => string | null
  onSuccess?: (values: Record<string, string>) => void
}

/**
 * useDemoForm
 *
 * The shared behaviour behind all 28 Foundry form flows:
 *
 *   - validate on submit, then re-validate that field on every change
 *     (validating on first keystroke punishes people mid-word)
 *   - move focus to the first invalid control, so a keyboard or screen-reader
 *     user is taken to the problem rather than told one exists
 *   - announce the failure count in a polite live region
 *   - expose a server-style error path that is not attached to any one field
 *
 * No request is ever made. `latency` exists purely so the submitting state is
 * long enough to see.
 */
export function useDemoForm<Schema extends Record<string, FieldSchema>>({
  schema,
  latency = 900,
  simulateServerError,
  onSuccess,
}: UseDemoFormOptions<Schema>) {
  const initialValues = useMemo(() => {
    const entries = Object.entries(schema).map(([key, field]) => [key, field.initial ?? ''])
    return Object.fromEntries(entries) as Record<string, string>
  }, [schema])

  const [values, setValues] = useState<Record<string, string>>(initialValues)
  const [errors, setErrors] = useState<Record<string, string | null>>({})
  const [status, setStatus] = useState<FormStatus>('idle')
  const [serverError, setServerError] = useState<string | null>(null)
  const [submitted, setSubmitted] = useState(false)
  const formRef = useRef<HTMLFormElement>(null)
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)

  const setValue = useCallback(
    (name: string, value: string) => {
      setValues((current) => {
        const next = { ...current, [name]: value }
        // Only re-validate a field the user has already been told about.
        setErrors((currentErrors) => {
          if (currentErrors[name] === undefined) return currentErrors
          const validators = schema[name]?.validators ?? []
          return { ...currentErrors, [name]: runValidators(value, next, validators) }
        })
        return next
      })
      setServerError(null)
    },
    [schema],
  )

  const validateAll = useCallback(
    (currentValues: Record<string, string>) => {
      const next: Record<string, string | null> = {}
      for (const [name, field] of Object.entries(schema)) {
        next[name] = runValidators(currentValues[name] ?? '', currentValues, field.validators ?? [])
      }
      return next
    },
    [schema],
  )

  const focusFirstInvalid = useCallback((nextErrors: Record<string, string | null>) => {
    const firstInvalid = Object.keys(nextErrors).find((name) => nextErrors[name])
    if (!firstInvalid || !formRef.current) return
    const control = formRef.current.querySelector<HTMLElement>(
      `[name="${firstInvalid}"], #field-${firstInvalid}, [data-field="${firstInvalid}"]`,
    )
    control?.focus({ preventScroll: false })
  }, [])

  const handleSubmit = useCallback(
    (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault()
      if (status === 'submitting') return

      const nextErrors = validateAll(values)
      setErrors(nextErrors)
      setSubmitted(true)

      const invalidCount = Object.values(nextErrors).filter(Boolean).length
      if (invalidCount > 0) {
        focusFirstInvalid(nextErrors)
        setStatus('idle')
        return
      }

      setStatus('submitting')
      setServerError(null)

      if (timer.current) clearTimeout(timer.current)
      timer.current = setTimeout(() => {
        const failure = simulateServerError?.(values) ?? null
        if (failure) {
          setStatus('error')
          setServerError(failure)
          return
        }
        setStatus('success')
        onSuccess?.(values)
      }, latency)
    },
    [values, status, validateAll, focusFirstInvalid, simulateServerError, onSuccess, latency],
  )

  const reset = useCallback(() => {
    if (timer.current) clearTimeout(timer.current)
    setValues(initialValues)
    setErrors({})
    setStatus('idle')
    setServerError(null)
    setSubmitted(false)
  }, [initialValues])

  const invalidCount = Object.values(errors).filter(Boolean).length

  return {
    formRef,
    values,
    errors,
    status,
    serverError,
    submitted,
    invalidCount,
    setValue,
    handleSubmit,
    reset,
    /** Convenience: props for a controlled text control. */
    field: (name: string) => ({
      value: values[name] ?? '',
      onChange: (event: { target: { value: string } }) => setValue(name, event.target.value),
    }),
    error: (name: string) => errors[name] ?? null,
    isSubmitting: status === 'submitting',
    isSuccess: status === 'success',
  }
}

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

Usage

One field and four outcomes. Each failure gets its own message, because “invalid code” tells the customer nothing they can act on. Try FOUNDRY20, EXPIRED2024 or TEAMONLY.

  • Codes are uppercased for display but compared case-insensitively.
  • Autocorrect and spellcheck are disabled — a code field is not prose.

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.

  • Valid code
  • Unknown code
  • Expired code
  • Wrong-plan code

Accessibility

Specific failures
Each rejection explains what is wrong and what to do.
Server-style error
The failure is form-level, matching how a real API would respond.

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