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