diff --git a/src/app/admin-components/page.tsx b/src/app/admin-components/page.tsx new file mode 100644 index 00000000..4ebee855 --- /dev/null +++ b/src/app/admin-components/page.tsx @@ -0,0 +1,363 @@ +import type { Metadata } from 'next' + +import Link from 'next/link' + +import { + ArrowRight, + BadgeCheck, + FileCode2, + GitMerge, + LayoutDashboard, + ListChecks, + PanelsTopLeft, + SquareTerminal, +} from 'lucide-react' + +import { JsonLd } from '@/components/seo/JsonLd' +import { CommandCopyButton } from '@/components/site/CommandCopyButton' +import { Eyebrow, HeadingAccent, Section, SectionHeading } from '@/components/site/section' +import { SiteFooter } from '@/components/site/SiteFooter' +import { SiteHeader } from '@/components/site/SiteHeader' +import { + adminComponentsDescription, + adminComponentsRoute, + adminComponentsTitle, + componentEntries, + githubIssuesUrl, + primaryInstallCommand, +} from '@/lib/site' +import { breadcrumbNode, graph, techArticleNode } from '@/lib/structured-data' + +export const metadata: Metadata = { + alternates: { canonical: adminComponentsRoute }, + title: adminComponentsTitle, + description: adminComponentsDescription, + openGraph: { + description: adminComponentsDescription, + title: adminComponentsTitle, + type: 'article', + url: adminComponentsRoute, + }, + twitter: { + card: 'summary_large_image', + description: adminComponentsDescription, + title: adminComponentsTitle, + }, +} + +const adminComponentsStructuredData = graph( + breadcrumbNode([ + { name: 'Home', path: '/' }, + { name: adminComponentsTitle, path: adminComponentsRoute }, + ]), + techArticleNode({ + description: adminComponentsDescription, + title: adminComponentsTitle, + url: adminComponentsRoute, + }), +) + +function getExampleBlock() { + const block = componentEntries.find((component) => component.slug === 'hero-basic') + + if (!block) { + throw new Error('Missing hero-basic component entry') + } + + return block +} + +const exampleBlock = getExampleBlock() + +const adminWiringArtifacts = [ + { + detail: + 'React, block config, shared fields, and editor labels need to move as one source unit.', + icon: FileCode2, + label: 'Component source', + path: 'src/blocks//*', + }, + { + detail: + 'The block must appear in a collection slot before editors can choose it in the Payload admin panel.', + icon: PanelsTopLeft, + label: 'Collection slot', + path: 'src/collections/Pages/index.ts', + }, + { + detail: + 'Custom views, fields, and dashboard surfaces need the same explicit host-file edits as page blocks.', + icon: LayoutDashboard, + label: 'Custom view or field', + path: 'Payload admin config', + }, + { + detail: + 'Saved layout data has to resolve to a frontend renderer, not just a compiled component file.', + icon: GitMerge, + label: 'Render map', + path: 'src/blocks/RenderBlocks.tsx', + }, + { + detail: + 'Payload types and the admin import map must be regenerated after the component is registered.', + icon: BadgeCheck, + label: 'Generated outputs', + path: 'payload-types.ts, importMap.js', + }, +] as const + +const installSteps = [ + { + body: + 'Run the CLI in a supported Payload v3 and Next.js project. The command below uses a shipped block as the concrete example because it proves the full Payload admin component wiring path today.', + icon: SquareTerminal, + title: 'Install a wired block', + }, + { + body: + 'Review the copied source, collection patch, render-map patch, generated types, and admin import-map update in one normal git diff.', + icon: ListChecks, + title: 'Inspect the admin-facing diff', + }, + { + body: + 'Use that pattern for Payload admin panel customization: keep custom dashboard cards, fields, and views tied to the host files they need.', + icon: LayoutDashboard, + title: 'Apply the same contract', + }, +] as const + +const boundaryRows = [ + { + label: 'What exists today', + text: + 'Payload Components ships installable page blocks with the wiring model admin work needs: collection slots, render maps, generated types, and the admin import map.', + }, + { + label: 'What is not promised', + text: + 'This is not a generic Payload CMS admin UI kit, dashboard template library, or form-builder replacement.', + }, + { + label: 'What to request next', + text: + 'If your project needs a custom dashboard card, admin field, or view registered by the CLI, open the concrete component request in GitHub.', + }, +] as const + +export default function AdminComponentsPage() { + return ( + <> + + + +
+
+
+ +
+ + +
+ {adminWiringArtifacts.map((item) => { + const Icon = item.icon + + return ( +
+
+ + +
+

{item.label}

+

{item.detail}

+ + {item.path} + +
+
+
+ ) + })} +
+
+ +
+
+ + +
+ {installSteps.map((step, index) => { + const Icon = step.icon + + return ( +
+
+ + + + Step {index + 1} + +
+

{step.title}

+

{step.body}

+ {index === 0 ? ( +
+ + {primaryInstallCommand} + + +
+ ) : null} +
+ ) + })} +
+
+
+ +
+
+ + +
+ {boundaryRows.map((row) => ( +
+

+ {row.label} +

+

{row.text}

+
+ ))} +
+
+
+ +
+
+
+ Start from the shipped contract +

+ Install a real block, then request the admin surface you need. +

+

+ The fastest proof is still the command: copy source, patch Payload, regenerate + types, update the admin import map, and review the diff before adapting the pattern + to your admin UI. +

+
+ +
+ + Read install docs + + + Request an admin component +
+
+
+
+ + + + ) +} diff --git a/src/app/forms/page.tsx b/src/app/forms/page.tsx new file mode 100644 index 00000000..f48ff178 --- /dev/null +++ b/src/app/forms/page.tsx @@ -0,0 +1,292 @@ +import type { Metadata } from 'next' + +import Link from 'next/link' + +import { + ArrowRight, + CheckCircle2, + FileCode2, + MailPlus, + Route, + ShieldCheck, + SquareTerminal, +} from 'lucide-react' + +import { JsonLd } from '@/components/seo/JsonLd' +import { CommandCopyButton } from '@/components/site/CommandCopyButton' +import { CallToActionSignupDemo } from '@/components/site/demos/CallToActionSignupDemo' +import { Eyebrow, Section, SectionHeading } from '@/components/site/section' +import { SiteFooter } from '@/components/site/SiteFooter' +import { SiteHeader } from '@/components/site/SiteHeader' +import { componentEntries } from '@/lib/site' +import { breadcrumbNode, graph } from '@/lib/structured-data' + +const description = + 'Install form-facing Payload CMS components honestly: start with the email-capture CTA block, get Payload wiring in one command, and bring your own same-origin form handler.' + +function getSignupBlock() { + const block = componentEntries.find((component) => component.slug === 'call-to-action-signup') + + if (!block) { + throw new Error('Missing call-to-action-signup component entry') + } + + return block +} + +const signupBlock = getSignupBlock() + +export const metadata: Metadata = { + alternates: { canonical: '/forms' }, + title: 'Payload Forms Install Guide', + description, + openGraph: { + description, + title: 'Payload Forms Install Guide', + type: 'website', + url: '/forms', + }, + twitter: { + card: 'summary_large_image', + description, + title: 'Payload Forms Install Guide', + }, +} + +const formsStructuredData = graph( + breadcrumbNode([ + { name: 'Home', path: '/' }, + { name: 'Forms install guide', path: '/forms' }, + ]), +) + +const proofRows = [ + { + copy: 'The registry ships an email-capture call-to-action block with editable title, copy, placeholder, submit label, and form action fields.', + icon: MailPlus, + label: 'Available today', + }, + { + copy: 'The installer copies block source, registers it in the Pages collection, maps the renderer, then regenerates types and the admin import map.', + icon: CheckCircle2, + label: 'Wired by the CLI', + }, + { + copy: 'The block validates that form actions stay same-origin, such as /api/newsletter, before it renders the post target.', + icon: ShieldCheck, + label: 'Safer form surface', + }, +] as const + +const installSteps = [ + { + body: 'Run the CLI from a supported Payload v3 and Next.js project. The command installs the form-facing CTA block and lands everything as a normal repo diff.', + icon: SquareTerminal, + title: 'Install the signup block', + }, + { + body: 'Review the added block config, component source, Pages collection patch, RenderBlocks mapping, generated types, and admin import map.', + icon: FileCode2, + title: 'Review what changed', + }, + { + body: 'Create the same-origin endpoint named in the action field. Payload Components gives you the editable form surface, not the email service or CRM pipeline.', + icon: Route, + title: 'Bring the handler', + }, +] as const + +const boundaryRows = [ + { + label: 'It is', + text: 'A page block for email capture, newsletter signups, waitlists, and other simple one-field capture moments.', + }, + { + label: 'It is not', + text: 'A general form builder, submissions database, spam layer, email provider integration, or CRM connector.', + }, + { + label: 'You can extend', + text: 'The installed source after it lands in your repo, including fields, validation, and the endpoint it posts to.', + }, +] as const + +export default function FormsPage() { + return ( + <> + + + +
+
+
+
+ Payload forms +

+ Payload forms start with one honest install. +

+

+ Payload Components does not ship a full form builder or submission backend today. + It does ship a form-facing signup block that installs the Payload config, renderer, + generated types, import map, and same-origin action surface in one command. +

+ +
+ + Open the signup block +
+ +
+ + {signupBlock.command} + + +
+
+ + +
+
+ +
+
+
+ +
+ + Read install docs + + + See architecture + +
+
+ +
+ +
+
+
+ +
+
+ {installSteps.map((step, index) => { + const Icon = step.icon + + return ( +
+
+ + + + Step {index + 1} + +
+

{step.title}

+

{step.body}

+ {index === 0 ? ( +
+ + {signupBlock.command} + + +
+ ) : null} +
+ ) + })} +
+
+ +
+
+ + +
+ {boundaryRows.map((row) => ( +
+

+ {row.label} +

+

{row.text}

+
+ ))} +
+
+
+ +
+
+
+ Start with the real block +

+ Install the form-facing CTA, then add the backend you trust. +

+

+ The command gets the editable Payload surface into your repo. Your project still + chooses where submissions go and how they are stored, filtered, or sent. +

+
+ + + View component docs +
+
+
+ + + + ) +} diff --git a/src/app/llms-full.txt/route.ts b/src/app/llms-full.txt/route.ts index c3c4562f..c5ff7197 100644 --- a/src/app/llms-full.txt/route.ts +++ b/src/app/llms-full.txt/route.ts @@ -1,5 +1,14 @@ import { getLLMText, source } from '@/lib/source' -import { faqEntries, githubRepoUrl, componentEntries, siteDescription, siteUrl } from '@/lib/site' +import { + faqEntries, + githubRepoUrl, + componentEntries, + customComponentsDescription, + customComponentsRoute, + customComponentsTitle, + siteDescription, + siteUrl, +} from '@/lib/site' export async function GET() { const docs = await Promise.all(source.getPages().map(getLLMText)) @@ -11,12 +20,23 @@ export async function GET() { `Home: ${siteUrl}/`, `Docs: ${siteUrl}/docs`, `Catalog: ${siteUrl}/components`, + `${customComponentsTitle}: ${siteUrl}${customComponentsRoute}`, `Registry: ${siteUrl}/r/registry.json`, + `Forms: ${siteUrl}/forms`, `GitHub: ${githubRepoUrl}`, + `Payload admin components guide: ${siteUrl}/admin-components`, + '', + `## ${customComponentsTitle}`, + customComponentsDescription, + 'A Payload custom component is installable when its source, collection slot, render map, manifest fragments, generated types, and admin import map move together.', '', '## Components', ...componentEntries.map((component) => `- ${component.title} (${component.slug}): ${component.command}`), '', + '## Payload admin components guide', + 'A developer guide to Payload admin components: collection slots, custom views or fields, render maps, generated types, and admin import-map wiring.', + 'Payload admin components need more than React source: collection slots, custom views or fields, render maps, generated types, and the admin import map have to move together.', + '', '## FAQ', ...faqEntries.flatMap((entry) => [`### ${entry.question}`, entry.answer, '']), '## Documentation', diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts index 7048c29a..35d8d718 100644 --- a/src/app/llms.txt/route.ts +++ b/src/app/llms.txt/route.ts @@ -2,6 +2,9 @@ import { faqEntries, githubRepoUrl, componentEntries, + customComponentsDescription, + customComponentsRoute, + customComponentsTitle, primaryInstallCommand, siteDescription, siteUrl, @@ -28,19 +31,30 @@ export function GET() { `- [Home](${siteUrl}/)`, `- [Docs](${siteUrl}/docs)`, `- [Component catalog](${siteUrl}/components)`, + `- [${customComponentsTitle}](${siteUrl}${customComponentsRoute})`, `- [About](${siteUrl}/about)`, + `- [Forms install guide](${siteUrl}/forms)`, `- [Public registry](${siteUrl}/r/registry.json)`, `- [Full LLM context](${siteUrl}/llms-full.txt)`, + `- [Payload admin components guide](${siteUrl}/admin-components)`, `- [GitHub repository](${githubRepoUrl})`, '', '## Supported stack', ...stackItems.map((item) => `- ${item.label} (${item.detail})`), '', + `## ${customComponentsTitle}`, + customComponentsDescription, + 'A Payload custom component is installable when its source, collection slot, render map, manifest fragments, generated types, and admin import map move together.', + '', '## Installable components', /* Keep ": <command>" intact (no backticks) — the GEO contract test in tests/e2e/geo.e2e.spec.ts pins that exact substring. */ ...componentEntries.map((component) => `- ${component.title}: ${component.command} — ${component.description}`), '', + '## Payload admin components guide', + 'A developer guide to Payload admin components: collection slots, custom views or fields, render maps, generated types, and admin import-map wiring.', + 'Payload admin components need more than React source: collection slots, custom views or fields, render maps, generated types, and the admin import map have to move together.', + '', '## FAQ', ...faqEntries.flatMap((entry) => [`### ${entry.question}`, entry.answer, '']), ].join('\n') diff --git a/src/app/page.tsx b/src/app/page.tsx index 9853847b..b1bbab1d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { CatalogSection } from '@/components/site/sections/CatalogSection' import { CommunityCta } from '@/components/site/sections/CommunityCta' import { FaqSection } from '@/components/site/sections/FaqSection' import { HeroSection } from '@/components/site/sections/HeroSection' +import { InstallProofSection } from '@/components/site/sections/InstallProofSection' import { StackBand } from '@/components/site/sections/StackBand' import { WiringSection } from '@/components/site/sections/WiringSection' import { WorkflowSection } from '@/components/site/sections/WorkflowSection' @@ -49,6 +50,7 @@ export default function HomePage() { <main className="flex-1"> <HeroSection /> <StackBand /> + <InstallProofSection /> <WiringSection /> <WorkflowSection /> <CatalogSection /> diff --git a/src/app/payload-custom-components/page.tsx b/src/app/payload-custom-components/page.tsx new file mode 100644 index 00000000..0eb97972 --- /dev/null +++ b/src/app/payload-custom-components/page.tsx @@ -0,0 +1,281 @@ +import type { Metadata } from 'next' + +import Link from 'next/link' + +import { ArrowRight, CheckCircle2 } from 'lucide-react' + +import { JsonLd } from '@/components/seo/JsonLd' +import { CommandCopyButton } from '@/components/site/CommandCopyButton' +import { Eyebrow, HeadingAccent, Section, SectionHeading } from '@/components/site/section' +import { SiteFooter } from '@/components/site/SiteFooter' +import { SiteHeader } from '@/components/site/SiteHeader' +import { + componentEntries, + customComponentBuildSteps, + customComponentFitChecks, + customComponentManifestSnippet, + customComponentsDescription, + customComponentsHero, + customComponentsRoute, + customComponentsTitle, + customComponentWiringArtifacts, + primaryInstallCommand, +} from '@/lib/site' +import { breadcrumbNode, graph, techArticleNode } from '@/lib/structured-data' + +export const metadata: Metadata = { + alternates: { canonical: customComponentsRoute }, + title: customComponentsTitle, + description: customComponentsDescription, + openGraph: { + description: customComponentsDescription, + title: customComponentsTitle, + type: 'article', + url: customComponentsRoute, + }, + twitter: { + card: 'summary_large_image', + description: customComponentsDescription, + title: customComponentsTitle, + }, +} + +const customComponentsStructuredData = graph( + breadcrumbNode([ + { name: 'Home', path: '/' }, + { name: customComponentsTitle, path: customComponentsRoute }, + ]), + techArticleNode({ + description: customComponentsDescription, + title: customComponentsTitle, + url: customComponentsRoute, + }), +) + +const exampleComponent = componentEntries.find((component) => component.slug === 'hero-basic')! + +export default function PayloadCustomComponentsPage() { + return ( + <> + <JsonLd data={customComponentsStructuredData} /> + <SiteHeader /> + + <main className="flex-1"> + <section className="hero-shell overflow-hidden border-b border-border/60"> + <div aria-hidden="true" className="hero-atmosphere" /> + + <div className="container relative py-16 sm:py-20 lg:py-24"> + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.78fr)] lg:items-end lg:gap-16"> + <div className="max-w-3xl"> + <span + className="hero-reveal flex w-fit items-center gap-2 rounded-full border border-border/70 bg-background/90 px-4 py-1.5 text-[0.72rem] font-medium uppercase tracking-[0.2em] text-muted-foreground backdrop-blur-sm" + style={{ animationDelay: '80ms' }} + > + <span aria-hidden="true" className="hero-eyebrow-dot" /> + {customComponentsHero.eyebrow} + </span> + + <h1 + className="hero-reveal mt-6 text-balance text-[clamp(2.35rem,5.6vw,4.15rem)] font-medium leading-[1] text-foreground" + style={{ animationDelay: '180ms' }} + > + Make a Payload custom component <HeadingAccent>installable.</HeadingAccent> + </h1> + + <p + className="hero-reveal mt-5 max-w-2xl text-pretty text-base leading-7 text-muted-foreground sm:text-lg" + style={{ animationDelay: '320ms' }} + > + {customComponentsHero.intro} + </p> + + <div + className="hero-reveal mt-7 flex flex-col gap-3 sm:flex-row" + style={{ animationDelay: '430ms' }} + > + <Link + href="#wiring-contract" + className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground shadow-[0_18px_40px_-22px_rgba(15,23,42,0.55)] transition-[transform,box-shadow] duration-200 hover:-translate-y-px hover:shadow-[0_22px_50px_-22px_rgba(15,23,42,0.6)]" + > + Read the wiring model + <ArrowRight className="size-4" aria-hidden="true" /> + </Link> + <Link + href="/docs/components/hero-basic" + className="inline-flex h-11 items-center justify-center rounded-full border border-border/70 bg-background/80 px-5 text-sm font-medium text-foreground backdrop-blur-sm transition-[transform,background-color] duration-200 hover:-translate-y-px hover:bg-background" + > + Open a real component contract + </Link> + </div> + </div> + + <div + className="hero-reveal rounded-[1.25rem] border border-border bg-background/90 p-4 shadow-[var(--shadow-card)] backdrop-blur-sm sm:p-5" + style={{ animationDelay: '520ms' }} + > + <div className="flex items-center justify-between gap-4 border-b border-border pb-3"> + <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"> + Example install + </p> + <span className="rounded-full bg-brand/10 px-2 py-1 font-mono text-[11px] text-brand"> + {exampleComponent.version} + </span> + </div> + <div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2.5"> + <code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> + {primaryInstallCommand} + </code> + <CommandCopyButton command={primaryInstallCommand} /> + </div> + <p className="mt-4 text-sm leading-6 text-muted-foreground"> + This guide uses the shipped Hero Basic manifest as the concrete example because it + declares every piece a custom block needs before it can move between Payload projects. + </p> + </div> + </div> + </div> + </section> + + <Section id="wiring-contract"> + <SectionHeading + accentWord="five" + eyebrow="Wiring contract" + heading="A custom block has five moving parts." + intro="The React component is only one part of the component. The installable unit is the full contract below." + /> + + <div className="mt-10 overflow-hidden rounded-[1.25rem] border border-border bg-card"> + <div className="hidden grid-cols-[0.9fr_1.5fr_1.4fr] border-b border-border bg-muted/40 px-5 py-3 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground md:grid"> + <span>Artifact</span> + <span>What to check</span> + <span>Where it lands</span> + </div> + <div className="divide-y divide-border"> + {customComponentWiringArtifacts.map((item) => ( + <div + key={item.artifact} + className="grid gap-3 px-5 py-5 md:grid-cols-[0.9fr_1.5fr_1.4fr] md:gap-6" + > + <p className="font-medium text-foreground">{item.artifact}</p> + <p className="text-sm leading-6 text-muted-foreground">{item.check}</p> + <code className="min-w-0 overflow-x-auto whitespace-nowrap rounded-md border border-border bg-muted/40 px-2.5 py-1.5 font-mono text-xs text-foreground/80"> + {item.path} + </code> + </div> + ))} + </div> + </div> + </Section> + + <Section className="bg-muted/40"> + <div className="grid gap-12 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start lg:gap-16"> + <SectionHeading + accentWord="registry" + eyebrow="Build sequence" + heading="Turn a one-off block into a registry contract." + intro="Use this sequence when a block should travel across repos instead of living as local app code." + /> + + <div className="grid gap-4 sm:grid-cols-2"> + {customComponentBuildSteps.map((step, index) => ( + <div key={step.title} className="rounded-[1.25rem] border border-border bg-card p-5"> + <div className="flex items-center gap-3"> + <span className="flex size-8 items-center justify-center rounded-full border border-brand/30 bg-brand/10 font-mono text-xs text-brand"> + {index + 1} + </span> + <h2 className="text-base font-semibold text-foreground">{step.title}</h2> + </div> + <p className="mt-4 text-sm leading-6 text-muted-foreground">{step.body}</p> + </div> + ))} + </div> + </div> + </Section> + + <Section> + <div className="grid gap-10 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:items-start lg:gap-16"> + <div> + <Eyebrow>Manifest example</Eyebrow> + <h2 className="mt-4 text-balance text-3xl font-semibold leading-[1.08] text-foreground sm:text-[2.6rem]"> + The manifest says what the CLI is allowed to touch. + </h2> + <p className="mt-5 text-base leading-7 text-muted-foreground"> + A custom component becomes installable when its manifest names the copied files, + the two host files to patch, and the Payload generators that must run after the + block is registered. + </p> + <Link + href="/docs/registry-contract" + className="mt-7 inline-flex items-center gap-2 text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground" + > + Read the registry contract docs + <ArrowRight className="size-4" aria-hidden="true" /> + </Link> + </div> + + <pre className="max-h-[36rem] overflow-x-auto rounded-[1.25rem] border border-terminal-border bg-terminal p-5 text-[12px] leading-6 text-terminal-foreground shadow-[var(--shadow-frame)]"> + <code>{customComponentManifestSnippet}</code> + </pre> + </div> + </Section> + + <Section className="bg-muted/40"> + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start lg:gap-16"> + <SectionHeading + accentWord="installable" + eyebrow="Decision rule" + heading="Not every custom component should become installable." + intro="The registry path is worth it when the same shape should be repeated, reviewed, and recovered across projects." + /> + + <div className="grid gap-4"> + {customComponentFitChecks.map((item) => ( + <div key={item.label} className="flex gap-4 rounded-[1.25rem] border border-border bg-card p-5"> + <CheckCircle2 className="mt-0.5 size-5 shrink-0 text-brand" aria-hidden="true" /> + <div> + <h2 className="text-base font-semibold text-foreground">{item.label}</h2> + <p className="mt-2 text-sm leading-6 text-muted-foreground">{item.text}</p> + </div> + </div> + ))} + </div> + </div> + </Section> + + <Section> + <div className="flex flex-col items-start gap-6 rounded-[1.5rem] border border-border bg-card p-6 shadow-[var(--shadow-card)] sm:p-8 lg:flex-row lg:items-center lg:justify-between"> + <div className="max-w-2xl"> + <Eyebrow>Next step</Eyebrow> + <h2 className="mt-4 text-2xl font-semibold leading-tight text-foreground sm:text-3xl"> + Start from a component that already proves the contract. + </h2> + <p className="mt-4 text-sm leading-6 text-muted-foreground sm:text-base"> + Hero Basic is the smallest complete example: source files, Pages collection + registration, RenderBlocks mapping, generated types, import map, recovery files, + and sample content. + </p> + </div> + + <div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row"> + <Link + href="/docs/components/hero-basic" + className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" + > + Open Hero Basic + <ArrowRight className="size-4" aria-hidden="true" /> + </Link> + <Link + href="/components" + className="inline-flex h-11 items-center justify-center rounded-full border border-border bg-background px-5 text-sm font-medium text-foreground transition-colors hover:bg-secondary" + > + Browse the catalog + </Link> + </div> + </div> + </Section> + </main> + + <SiteFooter /> + </> + ) +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index c1ef1f1e..ce2e165f 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -8,8 +8,11 @@ import { source } from '@/lib/source' const staticRoutes = [ { changeFrequency: 'weekly', path: '/', priority: 1 }, { changeFrequency: 'weekly', path: '/components', priority: 0.9 }, + { changeFrequency: 'weekly', path: '/payload-custom-components', priority: 0.8 }, { changeFrequency: 'monthly', path: '/about', priority: 0.5 }, + { changeFrequency: 'weekly', path: '/forms', priority: 0.75 }, { changeFrequency: 'monthly', path: '/brand-guide', priority: 0.5 }, + { changeFrequency: 'weekly', path: '/admin-components', priority: 0.75 }, ] as const satisfies ReadonlyArray<{ changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'] path: string diff --git a/src/components/site/CatalogFamilyTeaser.tsx b/src/components/site/CatalogFamilyTeaser.tsx index 9cdca420..5a5474fd 100644 --- a/src/components/site/CatalogFamilyTeaser.tsx +++ b/src/components/site/CatalogFamilyTeaser.tsx @@ -75,7 +75,7 @@ function FamilyCard({ family }: { family: PageFamily }) { > <Link href={categoryHref} - aria-label={`Browse ${family.label} components`} + aria-label={`View install-ready ${family.label} components`} className="relative block border-b border-border bg-muted/40" > <DemoFitFrame className="h-52 [mask-image:linear-gradient(to_bottom,black_82%,transparent)] transition-transform duration-500 ease-out group-hover:scale-[1.02] motion-reduce:transform-none motion-reduce:transition-none"> @@ -108,7 +108,7 @@ function FamilyCard({ family }: { family: PageFamily }) { href={categoryHref} className="mt-auto inline-flex items-center gap-1 pt-1 text-sm font-medium text-foreground transition-colors hover:text-brand" > - Browse {family.label.toLowerCase()} + Install-ready {family.label.toLowerCase()} <ArrowRight className="size-3.5" aria-hidden="true" /> </Link> </div> @@ -140,7 +140,7 @@ export function CatalogFamilyTeaser() { href="/components" className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-brand" > - Browse all {installableCount} components + All {installableCount} install-ready components <ArrowRight className="size-4" aria-hidden="true" /> </Link> <Link diff --git a/src/components/site/SiteHeader.tsx b/src/components/site/SiteHeader.tsx index c302decc..dea4a16a 100644 --- a/src/components/site/SiteHeader.tsx +++ b/src/components/site/SiteHeader.tsx @@ -8,7 +8,7 @@ import { cn } from '@/utilities/ui' const navLinks = [ { href: '/docs', label: 'Docs' }, - { href: '/components', label: 'Components' }, + { href: '/components', label: 'Install' }, { href: '/blog', label: 'Blog' }, { href: '/about', label: 'About' }, ] as const diff --git a/src/components/site/sections/CatalogSection.tsx b/src/components/site/sections/CatalogSection.tsx index 3339b691..0f2f14a4 100644 --- a/src/components/site/sections/CatalogSection.tsx +++ b/src/components/site/sections/CatalogSection.tsx @@ -23,7 +23,7 @@ export function CatalogSection() { href="/components" className="inline-flex h-10 w-fit shrink-0 items-center gap-2 rounded-full border border-border bg-background px-5 text-sm font-medium text-foreground transition-colors hover:bg-secondary" > - Browse the catalog + Install-ready catalog <ArrowRight className="size-4" aria-hidden="true" /> </Link> </div> diff --git a/src/components/site/sections/InstallProofSection.tsx b/src/components/site/sections/InstallProofSection.tsx new file mode 100644 index 00000000..1e1e8bdf --- /dev/null +++ b/src/components/site/sections/InstallProofSection.tsx @@ -0,0 +1,109 @@ +import Link from 'next/link' + +import { ArrowRight, CheckCircle2, FileCode2, MonitorCheck, TerminalSquare } from 'lucide-react' + +import { CommandCopyButton } from '@/components/site/CommandCopyButton' +import { Section, SectionHeading } from '@/components/site/section' +import { + frameInstalledFiles, + installProofIntro, + installProofItems, + installProofNoAdoption, + landingSections, + primaryInstallCommand, + type InstallProofItem, +} from '@/lib/site' + +const proofIcons = { + diff: FileCode2, + result: MonitorCheck, + terminal: TerminalSquare, +} satisfies Record<InstallProofItem['icon'], typeof TerminalSquare> + +/* An honest proof pass: no customer logos or testimonials until they exist. + * The homepage shows what can be verified now, namely the CLI stages, the repo + * diff, and the rendered component output. */ +export function InstallProofSection() { + return ( + <Section id={landingSections.proof.id}> + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.48fr)] lg:items-end"> + <SectionHeading + accentWord="diff" + eyebrow="Install proof" + heading={landingSections.proof.heading} + intro={installProofIntro} + /> + + <aside className="reveal-on-scroll rounded-xl border border-border bg-muted/40 p-5"> + <p className="text-sm font-semibold tracking-tight text-foreground"> + {installProofNoAdoption.title} + </p> + <p className="mt-2 text-sm leading-6 text-muted-foreground"> + {installProofNoAdoption.body} + </p> + </aside> + </div> + + <div className="reveal-on-scroll mt-12 grid grid-cols-1 gap-4 lg:grid-cols-3"> + {installProofItems.map((item) => { + const Icon = proofIcons[item.icon] + + return ( + <article + key={item.title} + className="flex min-h-full flex-col rounded-xl border border-border bg-background p-5 shadow-card" + > + <div className="flex items-center gap-3"> + <span + aria-hidden="true" + className="grid size-10 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-brand" + > + <Icon className="size-4" /> + </span> + <h3 className="text-base font-semibold tracking-tight text-foreground"> + {item.title} + </h3> + </div> + <p className="mt-4 flex-1 text-sm leading-6 text-muted-foreground">{item.body}</p> + <Link + href={item.href} + className="mt-5 inline-flex w-fit items-center gap-2 text-sm font-medium text-foreground transition-colors hover:text-brand" + > + {item.linkLabel} + <ArrowRight className="size-4" aria-hidden="true" /> + </Link> + </article> + ) + })} + </div> + + <div className="reveal-on-scroll mt-6 grid gap-4 rounded-xl border border-border bg-muted/30 p-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:p-6"> + <div className="flex min-w-0 flex-col justify-between gap-5 rounded-lg border border-border bg-background p-5"> + <div> + <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"> + Run it + </p> + <code className="mt-3 block overflow-x-auto whitespace-nowrap font-mono text-sm text-foreground"> + {primaryInstallCommand} + </code> + </div> + <CommandCopyButton command={primaryInstallCommand} /> + </div> + + <div className="rounded-lg border border-border bg-background p-5"> + <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"> + Resulting diff includes + </p> + <ul className="mt-4 grid gap-3 sm:grid-cols-2"> + {frameInstalledFiles.map((filePath) => ( + <li key={filePath} className="flex min-w-0 items-center gap-2 text-sm text-foreground/80"> + <CheckCircle2 className="size-4 shrink-0 text-brand" aria-hidden="true" /> + <code className="truncate font-mono text-[12px]">{filePath}</code> + </li> + ))} + </ul> + </div> + </div> + </Section> + ) +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 70a55b6c..6b981007 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -39,7 +39,7 @@ export const heroSubheadline = export const heroPrimaryCta = { href: '/docs', label: 'Get started' } as const export const heroTertiaryLinks = [ - { href: '/components', label: 'Browse the components' }, + { href: '/components', label: 'Install-ready components' }, { href: '#wiring', label: 'See what add actually wires' }, ] as const @@ -80,6 +80,7 @@ export const landingSections = { community: { heading: 'Open source, end to end.', id: 'community' }, faq: { heading: 'Questions, answered straight.', id: 'faq' }, components: { heading: 'The catalog, rendered live.', id: 'components' }, + proof: { heading: 'The proof is the diff.', id: 'proof' }, wiring: { heading: "A block isn't live until it's wired.", id: 'wiring' }, workflow: { heading: 'From catalog to commit in three moves.', id: 'workflow' }, } as const @@ -132,6 +133,47 @@ export const workflowSteps = [ }, ] as const +/* ------------------------------------------------------------------ */ +/* Install proof — honest proof before install intent. */ +/* ------------------------------------------------------------------ */ + +export const installProofIntro = + 'Customer logos and testimonials stay off the page until there is a named source. For now, the proof is the install trace, the files it changes, and the live component result you can inspect before copying the command.' + +export const installProofNoAdoption = { + body: 'No fake quotes, no implied customer count, no borrowed logos. Real customer proof can replace this box only when there is a real install to name.', + title: 'No borrowed trust', +} as const + +export const installProofItems = [ + { + body: + 'The replay follows the installer stages: resolve the component, copy source, patch Payload files, regenerate Payload output, and record install state.', + href: '#wiring', + icon: 'terminal', + linkLabel: 'See the wiring ledger', + title: 'The command shows its work', + }, + { + body: + 'The installed files list names the block source plus the generated Payload outputs that land in the project as a normal reviewable diff.', + href: '/docs/installation', + icon: 'diff', + linkLabel: 'Read the install workflow', + title: 'The repo diff is visible', + }, + { + body: + 'The catalog renders the same component twins the docs use, so the installed result is inspectable before a developer runs the CLI.', + href: '/components', + icon: 'result', + linkLabel: 'Browse the live catalog', + title: 'The result is rendered live', + }, +] as const + +export type InstallProofItem = (typeof installProofItems)[number] + /* ------------------------------------------------------------------ */ /* Wiring ledger — the differentiator as a verifiable artifact table. */ /* Rows mirror the manifest contract: recovery.patchedFiles plus the */ @@ -1257,6 +1299,118 @@ export const catalogTitle = 'Component catalog' export const catalogDescription = 'Installable Payload CMS blocks and components, each with docs, registry metadata, and CLI wiring that registers, renders, types, and import-maps it for you. Read the contract before you add it.' +/* ------------------------------------------------------------------ */ +/* Payload custom components guide */ +/* ------------------------------------------------------------------ */ + +export const customComponentsRoute = '/payload-custom-components' +export const customComponentsTitle = 'Payload custom components guide' +export const customComponentsDescription = + 'A developer guide to making Payload custom components installable: block source, collection wiring, render maps, generated types, and admin import maps.' + +export const customComponentsHero = { + eyebrow: 'Developer guide', + heading: 'Make a Payload custom component installable.', + intro: + 'A custom Payload component is not finished when the React file compiles. It needs a block config, a collection slot, a render-map entry, generated types, and an admin import-map update that all move together.', +} as const + +export const customComponentWiringArtifacts = [ + { + artifact: 'Block source', + check: 'Keep the fields, config, component, and shared helpers together under src/blocks.', + path: 'src/blocks/HeroBasic/{config.ts, Component.tsx}', + }, + { + artifact: 'Collection slot', + check: 'Register the block in the Pages layout field so editors can choose it.', + path: 'src/collections/Pages/index.ts', + }, + { + artifact: 'Render map', + check: 'Map the block slug to a frontend renderer so saved content reaches React.', + path: 'src/blocks/RenderBlocks.tsx', + }, + { + artifact: 'Manifest fragments', + check: 'Declare the imports and patch targets the CLI should apply in another project.', + path: 'payload-components/manifests/<slug>.json', + }, + { + artifact: 'Generated outputs', + check: 'Regenerate Payload types and the admin import map after the block is registered.', + path: 'src/payload-types.ts, admin importMap.js', + }, +] as const + +export const customComponentBuildSteps = [ + { + title: 'Start with the editor contract', + body: + 'Name the fields editors actually fill in, then keep shared field groups beside the block so the config and component cannot drift.', + }, + { + title: 'Make the saved shape renderable', + body: + 'Use a stable block slug and add the renderer import that turns a saved layout item into the right React component.', + }, + { + title: 'Write the install contract', + body: + 'List copied files, collection and render-map fragments, recovery files, post-install generators, and sample content in the manifest.', + }, + { + title: 'Prove the install in a fresh repo', + body: + 'Run the installer against a supported Payload starter, inspect the diff, then rerun it to confirm the second install converges.', + }, +] as const + +export const customComponentManifestSnippet = `{ + "name": "hero-basic", + "files": [ + "src/blocks/shared/heroFields.ts", + "src/blocks/HeroBasic/config.ts", + "src/blocks/HeroBasic/Component.tsx" + ], + "payloadFragments": [ + { + "kind": "renderBlocks", + "importName": "HeroBasicBlock", + "importPath": "@/blocks/HeroBasic/Component", + "blockSlug": "heroBasic" + }, + { + "kind": "pagesLayout", + "importName": "HeroBasic", + "importPath": "../../blocks/HeroBasic/config", + "blockName": "HeroBasic" + } + ], + "recovery": { + "patchedFiles": [ + "src/blocks/RenderBlocks.tsx", + "src/collections/Pages/index.ts" + ] + }, + "postInstall": ["generate:types", "generate:importmap"] +}` + +export const customComponentFitChecks = [ + { + label: 'Keep it local', + text: 'If one project needs the block once, write it directly in that project and skip the registry work.', + }, + { + label: 'Make it installable', + text: 'If the same block should move across client sites, turn the wiring into a manifest so the next repo gets a repeatable diff.', + }, + { + label: 'Request it upstream', + text: 'If the shape belongs in the shared catalog but you do not want to author it, open an issue with the fields and target collection.', + }, +] as const + /* ------------------------------------------------------------------ */ /* Shared navigation surfaces */ /* ------------------------------------------------------------------ */ @@ -1268,15 +1422,20 @@ export const surfaceLinks = [ title: 'Documentation', }, { - description: 'Current components with exact commands and contracts.', + description: 'Install-ready components with exact commands and contracts.', href: '/components', - title: 'Component catalog', + title: 'Install-ready catalog', }, { description: 'What payload-components add wires, step by step.', href: '/docs/installation', title: 'Install workflow', }, + { + description: 'How to turn a custom Payload block into an installable registry contract.', + href: customComponentsRoute, + title: customComponentsTitle, + }, ] as const export const communityLinks = [ @@ -1303,20 +1462,36 @@ const footerComponentCategoryLinks = (Object.keys(componentCategories) as Compon label: componentCategories[key].label, })) +/* ------------------------------------------------------------------ */ +/* Payload admin components guide */ +/* ------------------------------------------------------------------ */ + +export const adminComponentsRoute = '/admin-components' +export const adminComponentsTitle = 'Payload admin components guide' +export const adminComponentsDescription = + 'A developer guide to Payload admin components: collection slots, custom views or fields, render maps, generated types, and admin import-map wiring.' + export const footerColumns = [ { links: [ - { href: '/components', label: 'Component catalog' }, + { href: '/components', label: 'Install-ready catalog' }, + { href: '/forms', label: 'Forms install guide' }, { href: '/docs', label: 'Documentation' }, { href: '/docs/installation', label: 'Install workflow' }, + { href: customComponentsRoute, label: 'Custom components guide' }, { href: '/docs/architecture', label: 'Architecture' }, + { href: adminComponentsRoute, label: 'Admin components guide' }, ], title: 'Product', }, { links: [ ...footerComponentCategoryLinks, - { accent: true, href: '/components', label: `All ${componentEntries.length} components` }, + { + accent: true, + href: '/components', + label: `All ${componentEntries.length} install-ready components`, + }, ], title: 'Components', }, diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index c2734083..a9606198 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -2,13 +2,17 @@ import { expect, type Page, test } from '@playwright/test' import { catalogTitle, + customComponentsRoute, + customComponentsTitle, heroHeadline, + heroTertiaryLinks, homeMetadataDescription, homeMetadataTitle, componentEntries, landingSections, primaryInstallCommand, terminalDemoLines, + adminComponentsRoute, upcomingComponents, } from '../../src/lib/site' @@ -115,6 +119,12 @@ test.describe('Light shadcn frontend', () => { ) await expect(page.getByRole('heading', { level: 1, name: heroHeadline })).toBeVisible() await expect(page.locator('code', { hasText: primaryInstallCommand }).first()).toBeVisible() + await expect( + page.locator('section.hero-shell').getByRole('link', { + exact: true, + name: heroTertiaryLinks[0].label, + }), + ).toHaveAttribute('href', heroTertiaryLinks[0].href) // Forced single light theme: the dark class must never appear. await expect(page.locator('html')).not.toHaveClass(/dark/) @@ -239,6 +249,11 @@ test.describe('Light shadcn frontend', () => { path: '/components', title: /Payload CMS Block Catalog/, }, + { + h1: 'Payload forms start with one honest install.', + path: '/forms', + title: /Payload Forms Install Guide/, + }, { h1: 'Why Payload Components exists', path: '/about', @@ -249,17 +264,40 @@ test.describe('Light shadcn frontend', () => { path: '/brand-guide', title: /Brand Guide/, }, - ...componentEntries.map((component) => ({ - h1: component.title, - path: component.href, - title: new RegExp(component.title), - })), ] - for (const route of routes) { - await page.goto(`${baseURL}${route.path}`) - await expect(page).toHaveTitle(route.title) - await expect(page.getByRole('heading', { level: 1, name: route.h1 })).toBeVisible() + for (const [index, route] of routes.entries()) { + const routePage = index === 0 ? page : await page.context().newPage() + + await routePage.goto(`${baseURL}${route.path}`, { waitUntil: 'domcontentloaded' }) + await expect(routePage).toHaveTitle(route.title, { timeout: 15_000 }) + await expect(routePage.getByRole('heading', { level: 1, name: route.h1 })).toBeVisible() + + const hasHorizontalOverflow = await routePage.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ) + expect(hasHorizontalOverflow).toBe(false) + + if (routePage !== page) { + await routePage.close() + } + } + + const sampledComponentSlugs = ['hero-basic', 'call-to-action-signup', 'comparator-stack'] + const sampledComponents = sampledComponentSlugs.map((slug) => { + const component = componentEntries.find((entry) => entry.slug === slug) + + if (!component) { + throw new Error(`Missing component fixture for ${slug}`) + } + + return component + }) + + for (const component of sampledComponents) { + await page.goto(`${baseURL}${component.href}`, { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveTitle(new RegExp(component.title), { timeout: 15_000 }) + await expect(page.getByRole('heading', { level: 1, name: component.title })).toBeVisible() const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, @@ -268,6 +306,88 @@ test.describe('Light shadcn frontend', () => { } }) + test('routes Payload forms intent to the honest install path', async ({ page }) => { + const signupBlock = componentEntries.find((component) => component.slug === 'call-to-action-signup') + + expect(signupBlock).toBeDefined() + + await page.goto(`${baseURL}/forms`) + + await expect( + page.getByRole('heading', { + level: 1, + name: 'Payload forms start with one honest install.', + }), + ).toBeVisible() + await expect(page.getByText('does not ship a full form builder')).toBeVisible() + await expect(page.locator('code', { hasText: signupBlock!.command }).first()).toBeVisible() + await expect(page.getByRole('link', { name: /Open the signup block/ })).toHaveAttribute( + 'href', + signupBlock!.href, + ) + await expect(page.getByRole('link', { name: 'Read install docs' })).toHaveAttribute( + 'href', + '/docs/installation', + ) + }) + + test('tracks Payload forms install-copy source', async ({ page, context }) => { + const signupBlock = componentEntries.find((component) => component.slug === 'call-to-action-signup') + + expect(signupBlock).toBeDefined() + + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(`${baseURL}/forms`) + await stubGtagEvents(page) + + await page.getByRole('button', { name: 'Copy' }).first().click() + + await expectCopiedAlert(page) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(signupBlock!.command) + expect(await getGtagEvents(page)).toContainEqual([ + 'event', + 'copy_install_command', + { + command: signupBlock!.command, + component: 'call-to-action-signup', + source_path: '/forms', + }, + ]) + }) + + test('renders the Payload custom components guide as distinct developer guidance', async ({ + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(`${baseURL}${customComponentsRoute}`) + + await expect(page).toHaveTitle(new RegExp(customComponentsTitle)) + await expect( + page.getByRole('heading', { + level: 1, + name: 'Make a Payload custom component installable.', + }), + ).toBeVisible() + await expect(page.getByRole('heading', { level: 2, name: 'A custom block has five moving parts.' })).toBeVisible() + await expect(page.getByText('payload-components/manifests/<slug>.json')).toBeVisible() + await expect(page.getByText('"payloadFragments"')).toBeVisible() + + const command = page.locator('code', { hasText: primaryInstallCommand }).first() + await expect(command).toBeVisible() + await page.getByRole('button', { name: 'Copy' }).first().click() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(primaryInstallCommand) + + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ) + expect(hasHorizontalOverflow).toBe(false) + }) + test('drives the responsive component preview frame', async ({ page }) => { await page.goto(`${baseURL}/docs/components/hero-basic`) @@ -301,7 +421,7 @@ test.describe('Light shadcn frontend', () => { for (const route of [ { label: 'Docs', path: '/docs' }, - { label: 'Components', path: '/components' }, + { label: 'Install', path: '/components' }, { label: 'About', path: '/about' }, ]) { await page.goto(`${baseURL}${route.path}`) @@ -364,8 +484,18 @@ test.describe('Light shadcn frontend', () => { // The catalog section teases page families with live previews instead of // listing every component as a text row; the full index lives at /components. + await expect(page.getByText('No fake quotes, no implied customer count')).toBeVisible() + await expect(page.getByRole('link', { name: 'Read the install workflow' })).toHaveAttribute( + 'href', + '/docs/installation', + ) + await expect(page.getByText('Resulting diff includes')).toBeVisible() await expect(page.getByRole('heading', { name: 'Page blocks' })).toBeVisible() - await expect(page.getByRole('link', { name: /Browse all \d+ components/ })).toBeVisible() + await expect( + page.locator('#components').getByRole('link', { + name: /All \d+ install-ready components/, + }), + ).toBeVisible() await expect(page.locator('code', { hasText: primaryInstallCommand }).first()).toBeVisible() await expect(page.getByRole('contentinfo')).toBeVisible() @@ -484,6 +614,51 @@ test.describe('Light shadcn frontend', () => { ) }) + test('routes Payload admin components intent to the installable wiring path', async ({ page }) => { + await page.goto(`${baseURL}${adminComponentsRoute}`) + + await expect( + page.getByRole('heading', { + level: 1, + name: 'Payload admin components need wiring.', + }), + ).toBeVisible() + await expect(page.getByText('Payload admin panel customization').first()).toBeVisible() + await expect(page.getByText('custom dashboard').first()).toBeVisible() + await expect(page.getByText('admin import map').first()).toBeVisible() + await expect(page.locator('code', { hasText: primaryInstallCommand }).first()).toBeVisible() + await expect(page.getByRole('link', { name: 'Read install docs' })).toHaveAttribute( + 'href', + '/docs/installation', + ) + await expect(page.getByRole('link', { name: 'Request an admin component' })).toHaveAttribute( + 'href', + 'https://github.com/Ducksss/payload-components/issues', + ) + }) + + test('tracks Payload admin components install-copy source', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(`${baseURL}${adminComponentsRoute}`) + await stubGtagEvents(page) + + await page.getByRole('button', { name: 'Copy' }).first().click() + + await expectCopiedAlert(page) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(primaryInstallCommand) + expect(await getGtagEvents(page)).toContainEqual([ + 'event', + 'copy_install_command', + { + command: primaryInstallCommand, + component: 'hero-basic', + source_path: adminComponentsRoute, + }, + ]) + }) + test('shows an alert after copying a docs code block', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto(`${baseURL}/docs`) diff --git a/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-desktop-chromium-linux.png b/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-desktop-chromium-linux.png index d89ba903..fcf43e91 100644 Binary files a/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-desktop-chromium-linux.png and b/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-desktop-chromium-linux.png differ diff --git a/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-mobile-chromium-linux.png b/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-mobile-chromium-linux.png index b1a2de4b..177380a8 100644 Binary files a/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-mobile-chromium-linux.png and b/tests/e2e/frontend.e2e.spec.ts-snapshots/landing-home-mobile-chromium-linux.png differ diff --git a/tests/e2e/geo.e2e.spec.ts b/tests/e2e/geo.e2e.spec.ts index aa46b52d..982768b7 100644 --- a/tests/e2e/geo.e2e.spec.ts +++ b/tests/e2e/geo.e2e.spec.ts @@ -61,8 +61,11 @@ test.describe('AI-readable documentation surfaces', () => { /^<\?xml version="1.0" encoding="UTF-8"\?>\n<urlset xmlns="http:\/\/www.sitemaps.org\/schemas\/sitemap\/0.9">/, ) expect(sitemapBody).toContain(`<loc>${baseURL}/</loc>`) + expect(sitemapBody).toContain(`<loc>${baseURL}/admin-components</loc>`) expect(sitemapBody).toContain(`<loc>${baseURL}/components</loc>`) + expect(sitemapBody).toContain(`<loc>${baseURL}/payload-custom-components</loc>`) expect(sitemapBody).toContain(`<loc>${baseURL}/docs/installation</loc>`) + expect(sitemapBody).toContain(`<loc>${baseURL}/forms</loc>`) await page.goto(baseURL) @@ -106,8 +109,12 @@ test.describe('AI-readable documentation surfaces', () => { expect(body).toContain(`- [Home](${baseURL}/)`) expect(body).toContain(`- [Docs](${baseURL}/docs)`) expect(body).toContain(`- [Component catalog](${baseURL}/components)`) + expect(body).toContain(`- [Payload custom components guide](${baseURL}/payload-custom-components)`) + expect(body).toContain('A Payload custom component is installable when its source') expect(body).toContain(`- [Public registry](${baseURL}/r/registry.json)`) + expect(body).toContain(`- [Forms install guide](${baseURL}/forms)`) expect(body).toContain(`- [GitHub repository](${githubRepoUrl})`) + expect(body).toContain(`- [Payload admin components guide](${baseURL}/admin-components)`) expect(body).toContain('Hero Basic: npx payload-components add hero-basic') }) @@ -122,6 +129,7 @@ test.describe('AI-readable documentation surfaces', () => { expect(body).toContain('# Payload Components') expect(body).toContain('# Introduction') expect(body).toContain('# Architecture') + expect(body).toContain('## Payload custom components guide') expect(body).toContain('AI-readable surfaces') expect(body).toContain('The v2 app is intentionally not a Payload CMS site.') expect(body).toContain('npx payload-components add feature-grid-basic') @@ -240,6 +248,17 @@ test.describe('AI-readable documentation surfaces', () => { expect(JSON.stringify(itemList)).toContain('Feature Grid Basic') }) + test('Payload admin components guide exposes TechArticle structured data', async ({ page }) => { + await page.goto(`${baseURL}/admin-components`) + + expect(findStructuredData(await getStructuredData(page), 'TechArticle')).toMatchObject({ + '@type': 'TechArticle', + headline: 'Payload admin components guide', + mainEntityOfPage: `${baseURL}/admin-components`, + url: `${baseURL}/admin-components`, + }) + }) + test('docs pages expose TechArticle structured data', async ({ page }) => { await page.goto(`${baseURL}/docs/installation`) @@ -250,4 +269,15 @@ test.describe('AI-readable documentation surfaces', () => { url: `${baseURL}/docs/installation`, }) }) + + test('Payload custom components guide exposes TechArticle structured data', async ({ page }) => { + await page.goto(`${baseURL}/payload-custom-components`) + + expect(findStructuredData(await getStructuredData(page), 'TechArticle')).toMatchObject({ + '@type': 'TechArticle', + headline: 'Payload custom components guide', + mainEntityOfPage: `${baseURL}/payload-custom-components`, + url: `${baseURL}/payload-custom-components`, + }) + }) })