payload-components
blockcontact-routing-formsource: ok · hit
Contact Routing Form
Validated contact channels beside an accessible same-origin inquiry form.
npx shadcn add "https://www.payload-components.xyz/r/contact-routing-form.json"free
Where it goes
contact
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
badgebuttoninputtextarea
Files5 · source cached 10/14/2026, 2:43:21 PM
payload-components/source/blocks/shared/safeUrls.ts
const approvedEmbedHosts = new Set([
'airtable.com',
'docs.google.com',
'form.typeform.com',
'lookerstudio.google.com',
'maps.google.com',
'player.vimeo.com',
'vimeo.com',
'www.airtable.com',
'www.google.com',
'www.youtube-nocookie.com',
'www.youtube.com',
'youtube-nocookie.com',
'youtube.com',
])
const approvedEmbedHostSuffixes = [
'.airtable.com',
'.google.com',
'.typeform.com',
'.vimeo.com',
'.youtube-nocookie.com',
'.youtube.com',
]
const embedUrlError = 'Use an approved HTTPS embed URL.'
const formActionError = 'Use a same-origin path, such as /api/newsletter.'
const sameOriginBase = 'https://payload-components.local'
const isApprovedEmbedHost = (hostname: string) => {
const normalizedHost = hostname.toLowerCase()
return (
approvedEmbedHosts.has(normalizedHost) ||
approvedEmbedHostSuffixes.some((suffix) => normalizedHost.endsWith(suffix))
)
}
export const getSafeEmbedUrl = (value: unknown) => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
try {
const parsed = new URL(trimmed)
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
return undefined
}
if (!isApprovedEmbedHost(parsed.hostname)) {
return undefined
}
return parsed.toString()
} catch {
return undefined
}
}
export const validateEmbedUrl = (value: unknown) => getSafeEmbedUrl(value) ? true : embedUrlError
export const getSafeFormAction = (value: unknown) => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed || !trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('\\')) {
return undefined
}
try {
const parsed = new URL(trimmed, sameOriginBase)
if (parsed.origin !== sameOriginBase) {
return undefined
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return undefined
}
}
export const validateSameOriginFormAction = (value: unknown) => {
if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) {
return true
}
return getSafeFormAction(value) ? true : formActionError
}
payload-components/source/blocks/shared/contactUrls.ts
import { getSafeFormAction } from './safeUrls'
export const contactChannelTypeOptions = ['email', 'phone', 'url'] as const
export type ContactChannelType = (typeof contactChannelTypeOptions)[number]
type ContactValidationContext = {
siblingData?: Record<string, unknown> | null
}
const contactErrors: Record<ContactChannelType, string> = {
email: 'Enter a valid email address.',
phone: 'Enter a valid phone number with 7 to 15 digits.',
url: 'Enter an HTTPS URL or a same-origin path such as /support.',
}
export const getSafeContactHref = (type: unknown, value: unknown) => {
if (
!contactChannelTypeOptions.includes(type as ContactChannelType) ||
typeof value !== 'string'
) {
return undefined
}
const trimmed = value.trim()
if (!trimmed || /[\r\n]/.test(trimmed)) return undefined
if (type === 'email') {
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(trimmed)
? `mailto:${trimmed}`
: undefined
}
if (type === 'phone') {
const normalized = trimmed.replace(/[\s().-]/g, '')
return /^\+?\d{7,15}$/.test(normalized) ? `tel:${normalized}` : undefined
}
const sameOriginPath = getSafeFormAction(trimmed)
if (sameOriginPath) return sameOriginPath
try {
const parsed = new URL(trimmed)
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return undefined
return parsed.toString()
} catch {
return undefined
}
}
export const validateContactValue = (
value: unknown,
{ siblingData }: ContactValidationContext,
) => {
const type = siblingData?.type
if (!contactChannelTypeOptions.includes(type as ContactChannelType)) {
return 'Choose a contact channel type before entering its value.'
}
return getSafeContactHref(type, value) ? true : contactErrors[type as ContactChannelType]
}
payload-components/source/blocks/shared/contactFields.ts
import type { Field } from 'payload'
import { contactChannelTypeOptions, validateContactValue } from './contactUrls'
/**
* Shared field core for the Contact component family.
*
* Every contact variant (contact-routing-form, contact-channels, …) spreads
* `contactFields` first for the shared heading, then appends its own
* variant-specific shape — the inquiry form for the routing layout, or the
* footnote for the channels-only layout. Editing the shared
* eyebrow/title/description here updates every installed contact block at once,
* so the family never drifts field-by-field across a repo.
*
* `contactChannelFields` is the one-channel shape (label, type, value, optional
* description) reused by every variant, so a channel looks and validates the
* same everywhere it appears. The `value` validator lives in
* `@/blocks/shared/contactUrls` and rejects anything that would not resolve to a
* safe `mailto:` / `tel:` / HTTPS href, so an editor cannot publish a channel
* that renders as a dead or unsafe link.
*
* Installed once per repo at `src/blocks/shared/contactFields.ts`; re-running
* `payload-components add contact-*` never overwrites a copy you have already edited.
*/
export const contactFields: Field[] = [
{
name: 'eyebrow',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
},
{
name: 'title',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
{
name: 'description',
type: 'textarea',
custom: { payloadComponents: { localization: 'localized' } },
},
]
export const contactChannelFields: Field[] = [
{
name: 'label',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
{
name: 'type',
type: 'select',
options: contactChannelTypeOptions.map((value) => ({
label: value.charAt(0).toUpperCase() + value.slice(1),
value,
})),
required: true,
},
{
name: 'value',
type: 'text',
custom: { payloadComponents: { localization: 'global' } },
required: true,
validate: validateContactValue,
},
{
name: 'description',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
},
]
payload-components/source/blocks/ContactRoutingForm/config.ts
import type { Block } from 'payload'
import { contactChannelFields, contactFields } from '@/blocks/shared/contactFields'
import { validateSameOriginFormAction } from '@/blocks/shared/safeUrls'
export const ContactRoutingForm: Block = {
slug: 'contactRoutingForm',
// Existing apps must migrate stored data before adopting this identifier:
// https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
dbName: 'pc_contact_route',
interfaceName: 'ContactRoutingFormBlock',
fields: [
// Shared contact core (eyebrow, title, description). Variant-specific fields
// follow; edit the shared shape in @/blocks/shared/contactFields.
...contactFields,
{
name: 'channels',
type: 'array',
required: true,
minRows: 1,
maxRows: 4,
admin: {
initCollapsed: true,
},
// Shared channel shape — see @/blocks/shared/contactFields.
fields: contactChannelFields,
},
{
name: 'formTitle',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
required: true,
},
{
name: 'formDescription',
type: 'textarea',
custom: { payloadComponents: { localization: 'localized' } },
},
{
name: 'formLabels',
type: 'group',
fields: [
{
name: 'name',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Name',
required: true,
},
{
name: 'email',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Email',
required: true,
},
{
name: 'organization',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Organization',
required: true,
},
{
name: 'phone',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Phone',
required: true,
},
{
name: 'message',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Message',
required: true,
},
],
},
{
name: 'submitLabel',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'Send inquiry',
required: true,
},
{
name: 'action',
type: 'text',
custom: { payloadComponents: { localization: 'global' } },
required: true,
validate: validateSameOriginFormAction,
admin: {
description: 'Same-origin path where the inquiry form posts, such as /api/contact.',
},
},
],
labels: {
plural: 'Contact Routing Form Blocks',
singular: 'Contact Routing Form',
},
}
payload-components/source/blocks/ContactRoutingForm/Component.tsx
import React from 'react'
import type { ContactRoutingFormBlock as ContactRoutingFormBlockData } from '@/payload-types'
import { getSafeContactHref } from '@/blocks/shared/contactUrls'
import { getSafeFormAction } from '@/blocks/shared/safeUrls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/utilities/ui'
// Layout adapted from tailark/blocks (MIT) — re-implemented as a Payload block.
type Props = ContactRoutingFormBlockData & {
id?: string
className?: string
disableInnerContainer?: boolean
}
export const ContactRoutingFormBlock: React.FC<Props> = ({
action,
channels,
className,
description,
disableInnerContainer,
eyebrow,
formDescription,
formLabels,
formTitle,
id,
submitLabel,
title,
}) => {
const fieldPrefix = React.useId()
const formAction = getSafeFormAction(action)
const labels = {
email: formLabels?.email || 'Email',
message: formLabels?.message || 'Message',
name: formLabels?.name || 'Name',
organization: formLabels?.organization || 'Organization',
phone: formLabels?.phone || 'Phone',
}
const formFields = (
<>
<div className="grid gap-5 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`${fieldPrefix}-name`}>
{labels.name}
</label>
<Input autoComplete="name" id={`${fieldPrefix}-name`} name="name" required type="text" />
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`${fieldPrefix}-email`}>
{labels.email}
</label>
<Input autoComplete="email" id={`${fieldPrefix}-email`} name="email" required type="email" />
</div>
<div className="flex flex-col gap-2">
<label
className="text-sm font-medium text-foreground"
htmlFor={`${fieldPrefix}-organization`}
>
{labels.organization}
</label>
<Input
autoComplete="organization"
id={`${fieldPrefix}-organization`}
name="organization"
type="text"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`${fieldPrefix}-phone`}>
{labels.phone}
</label>
<Input
autoComplete="tel"
id={`${fieldPrefix}-phone`}
inputMode="tel"
name="phone"
type="tel"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`${fieldPrefix}-message`}>
{labels.message}
</label>
<Textarea id={`${fieldPrefix}-message`} name="message" required rows={6} />
</div>
<Input
aria-hidden="true"
autoComplete="off"
className="absolute left-0 top-0 size-px opacity-0"
name="website"
tabIndex={-1}
type="text"
/>
<Button className="w-full sm:w-fit" disabled={!formAction} type="submit">
{submitLabel || 'Send inquiry'}
</Button>
</>
)
return (
<section className={cn('container', className)} id={id ? `block-${id}` : undefined}>
<div className="overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-14">
<div
className={cn('flex flex-col gap-10', {
'mx-auto max-w-6xl': !disableInnerContainer,
})}
>
<div className="flex max-w-3xl flex-col gap-4">
{eyebrow ? (
<Badge variant="outline" className="w-fit rounded-full px-3 py-1 uppercase tracking-eyebrow">
{eyebrow}
</Badge>
) : null}
<h2 className="text-4xl font-medium tracking-display text-balance sm:text-5xl">{title}</h2>
{description ? (
<p className="text-base leading-7 text-muted-foreground sm:text-lg">{description}</p>
) : null}
</div>
<div className="grid gap-8 lg:grid-cols-[0.8fr_1.2fr] lg:gap-12">
<address className="not-italic">
{channels && channels.length > 0 ? (
<div className="flex flex-col gap-4">
{channels.map((channel, index) => {
const href = getSafeContactHref(channel.type, channel.value)
return (
<div
key={channel.id ?? `${channel.label}-${index}`}
className="rounded-panel border border-border/70 bg-background/70 p-5"
>
<p className="text-sm font-medium text-foreground">{channel.label}</p>
{href ? (
<a className="mt-2 block break-words text-base text-primary underline-offset-4 hover:underline" href={href}>
{channel.value}
</a>
) : (
<span className="mt-2 block break-words text-base text-muted-foreground">
{channel.value}
</span>
)}
{channel.description ? (
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{channel.description}
</p>
) : null}
</div>
)
})}
</div>
) : null}
</address>
<div className="rounded-panel border border-border/70 bg-background/85 p-6 sm:p-8">
<div className="mb-6 flex flex-col gap-2">
<h3 className="text-2xl font-medium tracking-title text-foreground">{formTitle}</h3>
{formDescription ? (
<p className="text-sm leading-6 text-muted-foreground">{formDescription}</p>
) : null}
</div>
{formAction ? (
<form action={formAction} className="relative flex flex-col gap-5" method="post">
{formFields}
</form>
) : (
<div className="relative flex flex-col gap-5">{formFields}</div>
)}
{!formAction ? (
<p className="mt-4 text-sm text-destructive" role="status">
Configure a valid same-origin form action before publishing.
</p>
) : null}
</div>
</div>
</div>
</div>
</section>
)
}