shadimage
payload-components
blockcontact-channelssource: ok · hit

Contact Channels

Form-free Payload contact block: a grid of validated email, phone, and URL channels.

npx shadcn add "https://www.payload-components.xyz/r/contact-channels.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

badge

Files5 · source cached 10/14/2026, 2:11:10 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/ContactChannels/config.ts
import type { Block } from 'payload'

import { contactChannelFields, contactFields } from '@/blocks/shared/contactFields'

export const ContactChannels: Block = {
  slug: 'contactChannels',
  // Existing apps must migrate stored data before adopting this identifier:
  // https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
  dbName: 'pc_contact_chan',
  interfaceName: 'ContactChannelsBlock',
  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: 2,
      maxRows: 6,
      admin: {
        initCollapsed: true,
      },
      // Shared channel shape — see @/blocks/shared/contactFields.
      fields: contactChannelFields,
    },
    {
      name: 'footnote',
      type: 'text',
      custom: { payloadComponents: { localization: 'localized' } },
      admin: {
        description: 'Optional response-time or hours note rendered under the channels.',
      },
    },
  ],
  labels: {
    plural: 'Contact Channels Blocks',
    singular: 'Contact Channels',
  },
}
payload-components/source/blocks/ContactChannels/Component.tsx
import React from 'react'

import type { ContactChannelsBlock as ContactChannelsBlockData } from '@/payload-types'

import { getSafeContactHref } from '@/blocks/shared/contactUrls'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/utilities/ui'

type Props = ContactChannelsBlockData & {
  id?: string
  className?: string
  disableInnerContainer?: boolean
}

export const ContactChannelsBlock: React.FC<Props> = ({
  channels,
  className,
  description,
  disableInnerContainer,
  eyebrow,
  footnote,
  id,
  title,
}) => {
  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-5xl': !disableInnerContainer,
          })}
        >
          <div className="flex max-w-2xl 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-3xl font-medium tracking-title text-balance sm:text-4xl">
              {title}
            </h2>

            {description ? (
              <p className="text-base leading-7 text-muted-foreground">{description}</p>
            ) : null}
          </div>

          {channels && channels.length > 0 ? (
            <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
              {channels.map((channel, index) => {
                /* Rejects anything that would not resolve to a safe mailto:,
                   tel:, same-origin path, or HTTPS URL; an unsafe value falls
                   back to plain text rather than rendering a dead link. */
                const href = getSafeContactHref(channel.type, channel.value)

                return (
                  <div
                    key={channel.id ?? `${channel.label}-${index}`}
                    className="flex flex-col 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}

          {footnote ? <p className="text-sm leading-6 text-muted-foreground">{footnote}</p> : null}
        </div>
      </div>
    </section>
  )
}

More from payload-components

Browse all components in this library →