Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions apps/web/app/(main)/agents/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -209,10 +210,6 @@ async function AgentDetailContent({ agent }: { agent: AgentWithAuthor }) {
/>
</Suspense>
</div>
<AgentDefinitionSection agent={agent} />
<p className="mt-1 max-w-2xl text-pretty text-muted-foreground">
<AgentDescription>{agent.description}</AgentDescription>
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground text-sm">
{agent.authorUsername ? (
<Link
Expand Down Expand Up @@ -244,10 +241,24 @@ async function AgentDetailContent({ agent }: { agent: AgentWithAuthor }) {
</div>
</div>

<StickyInstallCta
agentAuthor={agent.authorUsername}
className="mt-6"
slug={agent.slug}
viewerIsAuthor={viewerIsAuthor}
/>

<AgentDefinitionSection agent={agent} />
<p className="mt-1 max-w-2xl text-pretty text-muted-foreground">
<AgentDescription>{agent.description}</AgentDescription>
</p>

<Card className="mt-8 w-full min-w-0 rounded-md border border-border p-5 shadow-[var(--shadow-card)] ring-0">
<h2 className="font-medium text-foreground text-sm">Install</h2>
<h2 className="font-medium text-foreground text-sm">
Other package managers
</h2>
<p className="mt-1 text-muted-foreground text-sm">
Run this command in your eve app to add the agent.
Prefer pnpm, yarn, or bun? Copy the matching install command.
</p>
<div className="mt-4">
<InstallCommand
Expand Down Expand Up @@ -625,6 +636,7 @@ function AgentDetailSkeleton() {
<Skeleton className="h-4 w-full max-w-48" />
</div>
</div>
<Skeleton className="mt-6 h-[10.5rem] rounded-md border border-border sm:h-44" />
<Skeleton className="mt-8 h-28 rounded-md border border-border" />
<Skeleton className="mt-8 h-32 rounded-md border border-border" />
<Skeleton className="mt-8 h-64 rounded-md border border-border" />
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components/agent-file-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,9 @@ export function AgentFileViewer({ files }: { files: AgentRegistryFile[] }) {
<div className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-foreground text-lg">Files</h2>
<h2 className="font-semibold text-foreground text-lg">
Files this command writes
</h2>
<span className="mono-label font-pixel text-muted-foreground/70 tabular-nums">
{files.length}
</span>
Expand Down
78 changes: 78 additions & 0 deletions apps/web/components/sticky-install-cta.tsx
Original file line number Diff line number Diff line change
@@ -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/<slug>` 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 (
<aside
aria-label="Install"
className={cn(
// In-flow card on mobile; sticky under the site header from sm up.
'rounded-md border border-border bg-background px-4 py-3 shadow-[var(--shadow-card)] sm:sticky sm:top-14 sm:z-30 sm:bg-background/95 sm:px-5 sm:py-4 sm:backdrop-blur-md',
className,
)}
>
<p className="mono-label text-muted-foreground">Install</p>
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<code className="scrollbar-hide edge-fade-x min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-md border border-border bg-muted px-3 py-2.5 font-mono text-foreground text-sm">
{command}
</code>
<Button
aria-label={copied ? 'Copied command' : 'Copy command'}
className="h-11 w-full shrink-0 sm:h-10 sm:w-auto sm:px-4"
onClick={handleCopy}
type="button"
>
<span className="t-icon-swap" data-state={copied ? 'b' : 'a'}>
<Copy aria-hidden="true" className="t-icon size-4" data-icon="a" />
<Check aria-hidden="true" className="t-icon size-4" data-icon="b" />
</span>
{copied ? 'Copied' : 'Copy command'}
</Button>
</div>
<p className="mt-2 text-muted-foreground text-sm">
Inspect the files below before you run it.
</p>
</aside>
)
}
9 changes: 9 additions & 0 deletions apps/web/lib/package-managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
47 changes: 47 additions & 0 deletions apps/web/tests/package-managers.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
Loading