Promotion code
The smallest form in the library, with a distinct message for each way a code can fail.
Live preview
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.
Related
All formsCheckout wizard
Four steps with the order summary repeated on every one.
advancedFeatured5 variantsPayment method
Card entry with live formatting and a real Luhn checksum.
advancedFeatured4 variantsBadge
Compact labelling with seven tones, three appearances and an optional second, colour-independent signal.
starter6 variants