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 · lighticonslucide-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>
)
}