Skip to content

Commit 802c8cd

Browse files
Merge pull request #106 from Multicorn-AI/lf-09
compare page;
2 parents cef52cc + f9c2237 commit 802c8cd

22 files changed

Lines changed: 1603 additions & 163 deletions

File tree

__tests__/CompareCard.test.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { cleanup, render, screen, within } from '@testing-library/react'
2+
import { afterEach, describe, expect, it } from 'vitest'
3+
import { CompareCard } from '@/components/CompareCard'
4+
5+
afterEach(() => {
6+
cleanup()
7+
})
8+
9+
describe('CompareCard', () => {
10+
it('renders use case heading, name, summary, strengths list, and gaps list', () => {
11+
render(
12+
<CompareCard
13+
useCase="I want an example use case."
14+
name="Example Tool"
15+
summary="One-line summary for the tool."
16+
strengths={['Strength A', 'Strength B']}
17+
gaps={['Gap A']}
18+
/>,
19+
)
20+
21+
expect(
22+
screen.getByRole('heading', { level: 2, name: 'I want an example use case.' }),
23+
).toBeInTheDocument()
24+
expect(screen.getByText('Example Tool')).toBeInTheDocument()
25+
expect(screen.getByText('One-line summary for the tool.')).toBeInTheDocument()
26+
expect(screen.getByRole('heading', { level: 3, name: 'What it does well' })).toBeInTheDocument()
27+
expect(
28+
screen.getByRole('heading', { level: 3, name: 'What it does not cover' }),
29+
).toBeInTheDocument()
30+
expect(screen.getByText('Strength A')).toBeInTheDocument()
31+
expect(screen.getByText('Strength B')).toBeInTheDocument()
32+
expect(screen.getByText('Gap A')).toBeInTheDocument()
33+
})
34+
35+
it('renders Learn more link with href, target, rel, and aria-label when url is provided', () => {
36+
render(
37+
<CompareCard
38+
useCase="Use case"
39+
name="Agent Safehouse"
40+
summary="Summary"
41+
strengths={[]}
42+
gaps={[]}
43+
url="https://agent-safehouse.dev"
44+
/>,
45+
)
46+
47+
const link = screen.getByRole('link', { name: 'Learn more about Agent Safehouse' })
48+
expect(link).toHaveAttribute('href', 'https://agent-safehouse.dev')
49+
expect(link).toHaveAttribute('target', '_blank')
50+
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
51+
})
52+
53+
it('does not render Learn more when url is omitted', () => {
54+
render(
55+
<CompareCard useCase="Use case" name="Tool" summary="Summary" strengths={[]} gaps={[]} />,
56+
)
57+
58+
expect(screen.queryByRole('link', { name: /Learn more about/ })).not.toBeInTheDocument()
59+
})
60+
61+
it('renders tracked CTA when highlight and trackedCta are provided', () => {
62+
render(
63+
<CompareCard
64+
useCase="Use case"
65+
name="Shield"
66+
summary="Summary"
67+
strengths={[]}
68+
gaps={[]}
69+
highlight
70+
trackedCta={{
71+
href: 'https://app.multicorn.ai/signup',
72+
eventName: 'test_click',
73+
label: 'Sign up for Shield',
74+
}}
75+
/>,
76+
)
77+
78+
expect(screen.getByRole('link', { name: 'Sign up for Shield' })).toHaveAttribute(
79+
'href',
80+
'https://app.multicorn.ai/signup',
81+
)
82+
})
83+
84+
it('does not render CTA when highlight is false and trackedCta is omitted', () => {
85+
render(
86+
<CompareCard
87+
useCase="Use case"
88+
name="Tool"
89+
summary="Summary"
90+
strengths={[]}
91+
gaps={[]}
92+
highlight={false}
93+
/>,
94+
)
95+
96+
expect(screen.queryByRole('link', { name: /Sign up/ })).not.toBeInTheDocument()
97+
})
98+
99+
it('renders strength and gap items in order', () => {
100+
const { container } = render(
101+
<CompareCard
102+
useCase="Use case"
103+
name="Tool"
104+
summary="Summary"
105+
strengths={['first strength', 'second strength', 'third strength']}
106+
gaps={['first gap', 'second gap']}
107+
/>,
108+
)
109+
110+
const article = container.querySelector('article')
111+
expect(article).not.toBeNull()
112+
if (article === null) {
113+
return
114+
}
115+
const lists = article.querySelectorAll('ul')
116+
expect(lists).toHaveLength(2)
117+
118+
const strengthItems = within(lists[0] as HTMLElement).getAllByRole('listitem')
119+
expect(strengthItems.map((li) => li.textContent)).toEqual([
120+
'first strength',
121+
'second strength',
122+
'third strength',
123+
])
124+
125+
const gapItems = within(lists[1] as HTMLElement).getAllByRole('listitem')
126+
expect(gapItems.map((li) => li.textContent)).toEqual(['first gap', 'second gap'])
127+
})
128+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cleanup, render, screen } from '@testing-library/react'
2+
import { afterEach, describe, expect, it } from 'vitest'
3+
import ShieldComparePage from '@/app/shield/compare/page'
4+
import { COMPARE_COMPETITORS, SHIELD_COMPARE } from '@/lib/compare-data'
5+
6+
afterEach(() => {
7+
cleanup()
8+
})
9+
10+
describe('/shield/compare page', () => {
11+
it('renders all four use-case tool names and no visible TODO placeholder strings', () => {
12+
const { container } = render(<ShieldComparePage />)
13+
14+
for (const competitor of COMPARE_COMPETITORS) {
15+
expect(screen.getByText(competitor.name)).toBeInTheDocument()
16+
}
17+
18+
expect(screen.getByText(SHIELD_COMPARE.name)).toBeInTheDocument()
19+
20+
expect(container.textContent ?? '').not.toContain('TODO')
21+
})
22+
23+
it('renders exactly four article cards', () => {
24+
const { container } = render(<ShieldComparePage />)
25+
expect(container.querySelectorAll('article')).toHaveLength(4)
26+
})
27+
28+
it('renders Multicorn Shield as the last compare card', () => {
29+
const { container } = render(<ShieldComparePage />)
30+
const articles = container.querySelectorAll('article')
31+
expect(articles).toHaveLength(4)
32+
const lastHeading = articles[3]?.querySelector('h2')
33+
expect(lastHeading?.textContent).toBe(SHIELD_COMPARE.useCase)
34+
})
35+
36+
it('renders the hero heading about choosing the right tool', () => {
37+
render(<ShieldComparePage />)
38+
expect(
39+
screen.getByRole('heading', {
40+
level: 1,
41+
name: /Choosing the right tool/,
42+
}),
43+
).toBeInTheDocument()
44+
})
45+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { cleanup, render, screen } from '@testing-library/react'
2+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
3+
import ShieldPage from '@/app/shield/page'
4+
5+
beforeAll(() => {
6+
Object.defineProperty(window, 'matchMedia', {
7+
writable: true,
8+
configurable: true,
9+
value: vi.fn().mockImplementation((query: string) => ({
10+
matches: false,
11+
media: query,
12+
onchange: null,
13+
addListener: vi.fn(),
14+
removeListener: vi.fn(),
15+
addEventListener: vi.fn(),
16+
removeEventListener: vi.fn(),
17+
dispatchEvent: vi.fn(),
18+
})),
19+
})
20+
})
21+
22+
afterEach(() => {
23+
cleanup()
24+
})
25+
26+
describe('/shield page compare CTA', () => {
27+
it('shows compare section heading and link to /shield/compare', () => {
28+
render(<ShieldPage />)
29+
30+
expect(
31+
screen.getByRole('heading', { name: /Not sure if Shield is right for you/i }),
32+
).toBeInTheDocument()
33+
34+
const link = screen.getByRole('link', { name: /Compare Shield to alternatives/i })
35+
expect(link).toHaveAttribute('href', '/shield/compare')
36+
})
37+
})

app/shield/compare/page.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { Metadata } from 'next'
2+
import { CompareCard } from '@/components/CompareCard'
3+
import { Footer } from '@/components/Footer'
4+
import { TrackedCtaLink } from '@/components/TrackedCtaLink'
5+
import { COMPARE_COMPETITORS, SHIELD_COMPARE } from '@/lib/compare-data'
6+
import { SIGNUP_URL } from '@/lib/urls'
7+
8+
const CANONICAL_URL = 'https://multicorn.ai/shield/compare'
9+
/** Same default OG asset as other product pages (e.g. /shield, /pricing). */
10+
const OG_IMAGE_URL = 'https://multicorn.ai/images/og-image.png'
11+
12+
const META_DESCRIPTION =
13+
'Compare Multicorn Shield to Agent Safehouse, agentsh, and AgentGate. Find the right AI agent control tool for your team based on what you need to protect.'
14+
15+
export const metadata: Metadata = {
16+
title: 'Compare tools for AI agent control | Multicorn Shield',
17+
description: META_DESCRIPTION,
18+
alternates: {
19+
canonical: CANONICAL_URL,
20+
},
21+
openGraph: {
22+
title: 'Compare tools for AI agent control | Multicorn Shield',
23+
description: META_DESCRIPTION,
24+
url: CANONICAL_URL,
25+
siteName: 'Multicorn',
26+
type: 'website',
27+
images: [
28+
{
29+
url: OG_IMAGE_URL,
30+
width: 1200,
31+
height: 630,
32+
alt: 'Compare tools for AI agent control',
33+
},
34+
],
35+
},
36+
twitter: {
37+
card: 'summary_large_image',
38+
title: 'Compare tools for AI agent control | Multicorn Shield',
39+
description: META_DESCRIPTION,
40+
images: [OG_IMAGE_URL],
41+
},
42+
}
43+
44+
export default function ShieldComparePage() {
45+
return (
46+
<>
47+
<main>
48+
<section className="relative overflow-hidden px-6 pb-10 pt-20 sm:pb-16 sm:pt-28">
49+
<div
50+
aria-hidden="true"
51+
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-b from-primary/5 via-indigo/5 to-transparent"
52+
/>
53+
<div className="mx-auto max-w-content text-center">
54+
<h1 className="mx-auto max-w-3xl text-4xl font-bold tracking-tight text-text-primary sm:text-5xl">
55+
Choosing the right tool for AI agent control
56+
</h1>
57+
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-text-secondary sm:text-xl">
58+
Different jobs need different kinds of control. The sections below are four common
59+
situations. Each one names a type of tool, what it is good at, and what it does not
60+
try to solve. Read them in order, or jump to the one that matches your situation.
61+
</p>
62+
</div>
63+
</section>
64+
65+
<section className="px-6 pb-14 sm:pb-24">
66+
<div className="mx-auto flex max-w-3xl flex-col gap-10">
67+
{COMPARE_COMPETITORS.map((competitor) => (
68+
<CompareCard
69+
key={competitor.id}
70+
useCase={competitor.useCase}
71+
name={competitor.name}
72+
summary={competitor.summary}
73+
strengths={competitor.strengths}
74+
gaps={competitor.gaps}
75+
url={competitor.url}
76+
/>
77+
))}
78+
79+
<CompareCard
80+
useCase={SHIELD_COMPARE.useCase}
81+
name={SHIELD_COMPARE.name}
82+
summary={SHIELD_COMPARE.summary}
83+
strengths={SHIELD_COMPARE.strengths}
84+
gaps={SHIELD_COMPARE.gaps}
85+
highlight
86+
trackedCta={{
87+
href: SIGNUP_URL,
88+
eventName: 'compare_shield_card_signup_click',
89+
eventProps: { location: 'compare_shield_card' },
90+
label: 'Sign up for Shield',
91+
}}
92+
/>
93+
</div>
94+
</section>
95+
96+
<section className="border-t border-border-light bg-surface-secondary px-6 py-14 sm:py-20">
97+
<div className="mx-auto max-w-content text-center">
98+
<h2 className="text-2xl font-bold tracking-tight text-text-primary sm:text-3xl">
99+
Ready for team-wide governance?
100+
</h2>
101+
<p className="mx-auto mt-4 max-w-xl text-lg text-text-secondary">
102+
Start free. No credit card required. Connect agents and MCP tools when you are ready.
103+
</p>
104+
<div className="mt-10 flex justify-center">
105+
<TrackedCtaLink
106+
href={SIGNUP_URL}
107+
className="inline-flex min-h-[44px] items-center rounded-lg bg-primary px-8 py-3 text-base font-semibold text-white shadow-sm hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 motion-safe:transition-colors"
108+
eventName="compare_signup_click"
109+
eventProps={{ location: 'compare_bottom_cta' }}
110+
>
111+
Start for free
112+
</TrackedCtaLink>
113+
</div>
114+
</div>
115+
</section>
116+
</main>
117+
<Footer />
118+
</>
119+
)
120+
}

0 commit comments

Comments
 (0)