shadimage
payload-components
blockcall-to-action-signupsource: ok · hit

Call To Action Signup

Email-capture Payload call-to-action block: heading, copy, and a form that posts to a same-origin endpoint.

npx shadcn add "https://www.payload-components.xyz/r/call-to-action-signup.json"
free

Where it goes

ctasignup

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

button

Files4 · source cached 10/9/2026, 2:33:09 AM

payload-components/source/blocks/shared/callToActionFields.ts
import type { Field } from 'payload'

/**
 * Shared field core for the Call To Action component family.
 *
 * Every call-to-action variant (call-to-action-centered, call-to-action-boxed,
 * call-to-action-signup, …) spreads these heading fields first and then appends
 * its own variant-specific shape — the CTA link group for the centered and boxed
 * layouts, or the email-capture fields for the signup layout. Editing the shared
 * title/description here updates every installed call-to-action block at once, so
 * the family never drifts field-by-field across a repo.
 *
 * Installed once per repo at `src/blocks/shared/callToActionFields.ts`; re-running
 * `payload-components add call-to-action-*` never overwrites a copy you have already edited.
 */
export const callToActionFields: Field[] = [
  {
    name: 'title',
    type: 'text',
    custom: { payloadComponents: { localization: 'localized' } },
    required: true,
  },
  {
    name: 'description',
    type: 'textarea',
    custom: { payloadComponents: { localization: 'localized' } },
  },
]
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/CallToActionSignup/config.ts
import type { Block } from 'payload'

import { callToActionFields } from '@/blocks/shared/callToActionFields'
import { validateSameOriginFormAction } from '@/blocks/shared/safeUrls'

export const CallToActionSignup: Block = {
  slug: 'callToActionSignup',
  // Existing apps must migrate stored data before adopting this identifier:
  // https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
  dbName: 'pc_cal_to_act_sig',
  interfaceName: 'CallToActionSignupBlock',
  fields: [
    // Shared call-to-action core (title, description). Variant-specific fields
    // follow; edit the shared shape in @/blocks/shared/callToActionFields.
    ...callToActionFields,
    {
      name: 'emailPlaceholder',
      type: 'text',
      custom: { payloadComponents: { localization: 'localized' } },
    },
    {
      name: 'submitLabel',
      type: 'text',
      custom: { payloadComponents: { localization: 'localized' } },
    },
    {
      name: 'action',
      type: 'text',
      required: true,
      custom: { payloadComponents: { localization: 'global' } },
      validate: validateSameOriginFormAction,
      admin: {
        description: 'Same-origin path where the email form posts, such as /api/newsletter.',
      },
    },
  ],
  labels: {
    plural: 'Call To Action Signup Blocks',
    singular: 'Call To Action Signup',
  },
}
payload-components/source/blocks/CallToActionSignup/Component.tsx
import React from 'react'

import type { CallToActionSignupBlock as CallToActionSignupBlockData } from '@/payload-types'

import { Mail, SendHorizonal } from 'lucide-react'

import { getSafeFormAction } from '@/blocks/shared/safeUrls'
import { Button } from '@/components/ui/button'
import { cn } from '@/utilities/ui'

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

export const CallToActionSignupBlock: React.FC<Props> = ({
  action,
  className,
  description,
  disableInnerContainer,
  emailPlaceholder,
  id,
  submitLabel,
  title,
}) => {
  const formAction = getSafeFormAction(action)
  const resolvedSubmitLabel = submitLabel || 'Get Started'
  const configurationStatusId = React.useId()
  const signupControl = (
    <>
      <div className="relative grid grid-cols-[1fr_auto] items-center rounded-frame border border-border/70 bg-background/80 pe-2">
        <Mail className="pointer-events-none absolute inset-y-0 left-4 my-auto size-5 text-muted-foreground" />

        <input
          aria-describedby={formAction ? undefined : configurationStatusId}
          aria-label={emailPlaceholder || 'Email address'}
          autoComplete="email"
          className="h-12 w-full bg-transparent ps-11 text-sm focus:outline-none"
          disabled={!formAction}
          name="email"
          placeholder={emailPlaceholder || 'Your email address'}
          required
          type="email"
        />

        <Button disabled={!formAction} type="submit">
          <span className="sr-only sm:not-sr-only">{resolvedSubmitLabel}</span>
          <SendHorizonal className="size-4 sm:hidden" strokeWidth={2} aria-hidden="true" />
        </Button>
      </div>

      {!formAction ? (
        <p id={configurationStatusId} role="status" className="mt-2 text-sm text-muted-foreground">
          Signup unavailable. Add a valid same-origin form action in Payload.
        </p>
      ) : null}
    </>
  )

  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 items-center gap-4 text-center', {
            'mx-auto max-w-2xl': !disableInnerContainer,
          })}
        >
          <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}

          {formAction ? (
            <form action={formAction} className="mt-2 w-full max-w-sm" method="post">
              {signupControl}
            </form>
          ) : (
            <div className="mt-2 w-full max-w-sm" role="group">
              {signupControl}
            </div>
          )}
        </div>
      </div>
    </section>
  )
}

More from payload-components

Browse all components in this library →