diff --git a/apps/web/app/(main)/agents/[slug]/page.tsx b/apps/web/app/(main)/agents/[slug]/page.tsx index c150a5a6..5fdc0dc0 100644 --- a/apps/web/app/(main)/agents/[slug]/page.tsx +++ b/apps/web/app/(main)/agents/[slug]/page.tsx @@ -15,6 +15,7 @@ import { FavoriteButton } from '@/components/favorite-button' import { InstallCommand } from '@/components/install-command' import { JsonLd } from '@/components/json-ld' import { MobileInstallBar } from '@/components/mobile-install-bar' +import { StickyInstallCta } from '@/components/sticky-install-cta' import { compareRelatedAgents, countFilesByKind, @@ -209,10 +210,6 @@ async function AgentDetailContent({ agent }: { agent: AgentWithAuthor }) { /> - -

- {agent.description} -

{agent.authorUsername ? (
+ + + +

+ {agent.description} +

+ -

Install

+

+ Other package managers +

- Run this command in your eve app to add the agent. + Prefer pnpm, yarn, or bun? Copy the matching install command.

+ diff --git a/apps/web/components/agent-file-viewer.tsx b/apps/web/components/agent-file-viewer.tsx index 96840b62..c479b793 100644 --- a/apps/web/components/agent-file-viewer.tsx +++ b/apps/web/components/agent-file-viewer.tsx @@ -278,7 +278,9 @@ export function AgentFileViewer({ files }: { files: AgentRegistryFile[] }) {
-

Files

+

+ Files this command writes +

{files.length} diff --git a/apps/web/components/sticky-install-cta.tsx b/apps/web/components/sticky-install-cta.tsx new file mode 100644 index 00000000..db1bde48 --- /dev/null +++ b/apps/web/components/sticky-install-cta.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Button } from '@evex/ui/button' +import { cn } from '@evex/ui/lib/utils' +import { Check, Copy } from 'lucide-react' +import posthog from 'posthog-js' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { buildInstallCommand } from '@/lib/package-managers' + +/** + * Above-the-fold install affordance under the agent title. Sticky on sm+ only — + * on small viewports it stays in document flow so MobileInstallBar remains the + * sole sticky install control. Always shows the canonical + * `npx shadcn@latest add @evex/` form; package-manager tabs live on the + * secondary install card below, not here. + */ +export function StickyInstallCta({ + slug, + agentAuthor, + viewerIsAuthor, + className, +}: { + slug: string + agentAuthor: string | null + viewerIsAuthor: boolean | null + className?: string +}) { + const command = buildInstallCommand(slug) + const { copied, copy } = useCopyToClipboard() + + const handleCopy = async () => { + const didCopy = await copy(command, { + successMessage: 'Copied install command', + }) + if (didCopy) { + posthog.capture('agent_install_command_copied', { + agent_author: agentAuthor, + agent_slug: slug, + package_manager: 'npm', + surface: 'sticky_install_cta', + viewer_is_author: viewerIsAuthor, + }) + } + } + + return ( + + ) +} diff --git a/apps/web/lib/package-managers.ts b/apps/web/lib/package-managers.ts index 42278186..b1f4b685 100644 --- a/apps/web/lib/package-managers.ts +++ b/apps/web/lib/package-managers.ts @@ -21,10 +21,19 @@ export function parsePackageManager( return match ? match.id : DEFAULT_PACKAGE_MANAGER } +// Canonical public install command. Always the npx form — product surfaces +// that must not rotate package managers (hero, sticky CTA, SEO) use this. +export function buildInstallCommand(slug: string): string { + return `npx shadcn@latest add @evex/${slug}` +} + export function buildInstallCommandForManager( manager: PackageManagerId, slug: string, ): string { + if (manager === 'npm') { + return buildInstallCommand(slug) + } const match = PACKAGE_MANAGERS.find((entry) => entry.id === manager) const runner = match?.runner ?? 'npx' return `${runner} shadcn@latest add @evex/${slug}` diff --git a/apps/web/tests/package-managers.test.ts b/apps/web/tests/package-managers.test.ts new file mode 100644 index 00000000..6a099593 --- /dev/null +++ b/apps/web/tests/package-managers.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildInstallCommand, + buildInstallCommandForManager, +} from '@/lib/package-managers' + +describe('buildInstallCommand', () => { + it('always returns the canonical npx form', () => { + expect(buildInstallCommand('code-reviewer')).toBe( + 'npx shadcn@latest add @evex/code-reviewer', + ) + expect(buildInstallCommand('brand-visual-asset-generator')).toBe( + 'npx shadcn@latest add @evex/brand-visual-asset-generator', + ) + }) + + it('matches the server-side helper in site-url', async () => { + // Keep the client-safe and server-only copies in lockstep without a + // barrel re-export (ultracite bans those). + const { buildInstallCommand: serverBuildInstallCommand } = await import( + '@/lib/site-url' + ) + expect(buildInstallCommand('code-reviewer')).toBe( + serverBuildInstallCommand('code-reviewer'), + ) + }) +}) + +describe('buildInstallCommandForManager', () => { + it('matches the canonical command for npm', () => { + expect(buildInstallCommandForManager('npm', 'openui')).toBe( + buildInstallCommand('openui'), + ) + }) + + it('uses the selected runner for other managers', () => { + expect(buildInstallCommandForManager('pnpm', 'openui')).toBe( + 'pnpm dlx shadcn@latest add @evex/openui', + ) + expect(buildInstallCommandForManager('yarn', 'openui')).toBe( + 'yarn dlx shadcn@latest add @evex/openui', + ) + expect(buildInstallCommandForManager('bun', 'openui')).toBe( + 'bunx --bun shadcn@latest add @evex/openui', + ) + }) +})