shadimage
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 · light

Registry 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>
  )
}

More from payload-components

Browse all components in this library →