Skip to content

Commit fddb26c

Browse files
bloveclaude
andauthored
feat(website): contact page on the form kit; enterprise form merges into it (#995)
* feat(website): contact page band layout styles; drop the old contact-form rules Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): rebuild ContactForm on the kit with an enterprise intent Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): contact page band with the form card and an enterprise intent Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): enterprise contact requires a company; cover success and failure blocks; trim optional facts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: lead forms — company is required on the enterprise intent * fix(website): contact page renders GitHubStarsPill as JSX; spec mocks it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): pricing CTA band replaces the enterprise lead form Also repoints the two /pricing#lead-form anchor links (pilot-to-prod, solutions/[slug]) at the band's new #enterprise id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): contact page suspends the stars pill, not the form; spec proves the entry point reaches the form Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): e2e for the contact band, enterprise intent, and pricing band Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): pricing CTA e2e follows the enterprise contact intent Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4c808e5 commit fddb26c

22 files changed

Lines changed: 509 additions & 904 deletions

apps/website/e2e/website.spec.ts

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ test('pricing page routes software and support CTAs without checkout', async ({
7373
await page.goto('/pricing');
7474

7575
await expect(page.getByRole('link', { name: 'Install from npm' })).toHaveAttribute('href', /npmjs\.com\/package\/@threadplane\/chat/);
76-
await expect(page.getByRole('link', { name: 'Discuss assurance' })).toHaveAttribute('href', '/contact?source=pricing_production_assurance');
77-
await expect(page.getByRole('link', { name: 'Talk to Sales' })).toHaveAttribute('href', '/contact?source=pricing_tier_enterprise');
76+
await expect(page.getByRole('link', { name: 'Discuss assurance' })).toHaveAttribute('href', '/contact?intent=enterprise&entry=pricing_tier_production_assurance');
77+
await expect(page.getByRole('link', { name: 'Talk to Sales' })).toHaveAttribute('href', '/contact?intent=enterprise&entry=pricing_tier_enterprise');
7878
await expect(page.locator('form[action*="checkout"]')).toHaveCount(0);
7979
});
8080

@@ -87,35 +87,29 @@ test('pricing page is responsive without page-level horizontal overflow', async
8787
}
8888
});
8989

90-
test('pricing page lead form validates required fields', async ({ page }) => {
90+
test('pricing plan buttons lead to the enterprise contact intent', async ({ page }) => {
9191
await page.goto('/pricing');
92-
const leadForm = page.locator('#lead-form form');
93-
await expect(leadForm).toBeVisible();
94-
await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click();
95-
await expect(leadForm).toBeVisible();
96-
expect(await leadForm.evaluate((form) => (form as HTMLFormElement).checkValidity())).toBe(false);
92+
await expect(page.getByRole('link', { name: 'Talk to Sales' })).toHaveAttribute('href', '/contact?intent=enterprise&entry=pricing_tier_enterprise');
93+
await expect(page.getByRole('link', { name: 'Request a conversation' })).toHaveAttribute('href', '/contact?intent=enterprise&entry=pricing_enterprise_band');
94+
await expect(page.locator('#lead-form')).toHaveCount(0);
9795
});
9896

9997
test('contact page submits a lead payload and renders success state', async ({ page }) => {
10098
let leadPayload: Record<string, unknown> | undefined;
10199
await page.route('**/api/leads', async (route) => {
102100
leadPayload = route.request().postDataJSON();
103-
await route.fulfill({
104-
status: 200,
105-
contentType: 'application/json',
106-
body: JSON.stringify({ ok: true }),
107-
});
101+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
108102
});
109103

110104
await page.goto('/contact');
111-
const contactForm = page.locator('main form').first();
112-
await contactForm.getByRole('textbox', { name: 'Email', exact: true }).fill('jane@acme.com');
113-
await contactForm.getByRole('textbox', { name: 'Name' }).fill('Jane Smith');
114-
await contactForm.getByRole('textbox', { name: 'Company' }).fill('Acme');
115-
await contactForm.getByRole('textbox', { name: 'Message' }).fill('We are evaluating Threadplane.');
116-
await contactForm.getByRole('button', { name: 'Send' }).click();
117-
118-
await expect(page.getByText("Thanks. We'll be in touch within one business day.")).toBeVisible();
105+
const form = page.locator('main form').first();
106+
await form.getByLabel('Work email').fill('jane@acme.com');
107+
await form.getByLabel('Name').fill('Jane Smith');
108+
await form.getByLabel('Company').fill('Acme');
109+
await form.getByLabel('What are you shipping?').fill('We are evaluating Threadplane.');
110+
await form.getByRole('button', { name: 'Send to Brian' }).click();
111+
112+
await expect(page.getByRole('status')).toContainText('Sent.');
119113
expect(leadPayload).toMatchObject({
120114
form_kind: 'contact',
121115
email: 'jane@acme.com',
@@ -127,35 +121,33 @@ test('contact page submits a lead payload and renders success state', async ({ p
127121
expect(leadPayload?.['submission_id']).toMatch(UUID_V4);
128122
});
129123

130-
test('pricing lead form posts to /api/leads and renders success state', async ({ page }) => {
124+
test('contact page enterprise intent posts the pricing form kind with a timeline', async ({ page }) => {
131125
let leadPayload: Record<string, unknown> | undefined;
132126
await page.route('**/api/leads', async (route) => {
133127
leadPayload = route.request().postDataJSON();
134-
await route.fulfill({
135-
status: 200,
136-
contentType: 'application/json',
137-
body: JSON.stringify({ ok: true }),
138-
});
128+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
139129
});
140130

141-
await page.goto('/pricing#lead-form');
142-
const leadForm = page.locator('#lead-form form');
143-
await leadForm.getByLabel('Name').fill('Jane Smith');
144-
await leadForm.getByLabel('Work email').fill('jane@acme.com');
145-
await leadForm.getByLabel('Company').fill('Acme');
146-
await leadForm.getByLabel('Tell us about your use case').fill('Volume seats and security review.');
147-
await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click();
131+
await page.goto('/contact?intent=enterprise&entry=pricing_tier_enterprise');
132+
const form = page.locator('main form').first();
133+
await form.getByRole('button', { name: 'Request a conversation' }).click();
134+
await expect(form.getByText('Enter your email address.')).toBeVisible();
148135

149-
await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible();
136+
await form.getByLabel('Work email').fill('jane@acme.com');
137+
await form.getByLabel('Company').fill('Acme');
138+
await form.getByLabel('Timeline').selectOption('this_quarter');
139+
await form.getByLabel('Tell us about your use case').fill('Volume seats and security review.');
140+
await form.getByRole('button', { name: 'Request a conversation' }).click();
141+
142+
await expect(page.getByRole('status')).toContainText('Sent.');
150143
expect(leadPayload).toMatchObject({
151144
form_kind: 'pricing',
152145
email: 'jane@acme.com',
153-
name: 'Jane Smith',
154146
company: 'Acme',
147+
timeline: 'this_quarter',
155148
message: 'Volume seats and security review.',
156149
policy_version: GROWTH_FORM_POLICY_VERSION,
157150
});
158-
expect(leadPayload?.['submission_id']).toMatch(UUID_V4);
159151
});
160152

161153
test('footer newsletter form posts to /api/newsletter and renders success state', async ({ page }) => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// @vitest-environment jsdom
2+
import React from 'react';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import { render, screen, fireEvent } from '@testing-library/react';
5+
6+
const track = vi.hoisted(() => vi.fn());
7+
8+
vi.mock('../../lib/analytics/client', () => ({ track, trackCtaClick: vi.fn(), trackExternalLinkClick: vi.fn() }));
9+
vi.mock('server-only', () => ({}));
10+
vi.mock('../../components/contact/GitHubStarsPill', () => ({ GitHubStarsPill: () => <span data-testid="stars" /> }));
11+
12+
import ContactPage from './page';
13+
14+
afterEach(() => {
15+
vi.unstubAllGlobals();
16+
});
17+
18+
describe('ContactPage', () => {
19+
it('renders the contact variant by default', async () => {
20+
render(await ContactPage({ searchParams: Promise.resolve({}) }));
21+
expect(screen.getByText('Contact', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
22+
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Talk to an engineer.');
23+
expect(screen.getByRole('button', { name: 'Send to Brian' })).toBeTruthy();
24+
expect(screen.queryByLabelText('Timeline')).toBeNull();
25+
const email = screen.getByRole('link', { name: 'brian@threadplane.ai' });
26+
expect(email.getAttribute('href')).toBe('mailto:brian@threadplane.ai');
27+
const github = screen.getByRole('link', { name: 'GitHub issues' });
28+
expect(github.getAttribute('href')).toBe('https://github.com/cacheplane/angular-agent-framework/issues');
29+
const discord = screen.getByRole('link', { name: 'Discord' });
30+
expect(discord.getAttribute('href')).toBe('https://discord.gg/cacheplane');
31+
expect(screen.getByTestId('stars')).toBeTruthy();
32+
});
33+
34+
it('renders the enterprise variant from the intent query and passes the entry point through', async () => {
35+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
36+
render(await ContactPage({ searchParams: Promise.resolve({ intent: 'enterprise', entry: 'pricing_tier_enterprise' }) }));
37+
expect(screen.getByText('Enterprise', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
38+
expect(screen.getByLabelText('Timeline')).toBeTruthy();
39+
expect(screen.getByRole('button', { name: 'Request a conversation' })).toBeTruthy();
40+
41+
fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'buyer@example.com' } });
42+
fireEvent.change(screen.getByLabelText('Company'), { target: { value: 'Acme Co' } });
43+
fireEvent.change(screen.getByLabelText('Timeline'), { target: { value: 'this_quarter' } });
44+
fireEvent.click(screen.getByRole('button', { name: 'Request a conversation' }));
45+
46+
expect(track).toHaveBeenCalledWith(
47+
'marketing:lead_form_submit',
48+
expect.objectContaining({ surface: 'pricing', entry_point: 'pricing_tier_enterprise' })
49+
);
50+
});
51+
52+
it('ignores unknown intents and unsafe entry values', async () => {
53+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
54+
render(await ContactPage({ searchParams: Promise.resolve({ intent: 'weird', entry: '<script>' }) }));
55+
const button = screen.getByRole('button', { name: 'Send to Brian' });
56+
expect(button).toBeTruthy();
57+
58+
fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'buyer@example.com' } });
59+
fireEvent.click(button);
60+
61+
expect(track).toHaveBeenCalledWith(
62+
'marketing:lead_form_submit',
63+
expect.not.objectContaining({ entry_point: expect.anything() })
64+
);
65+
});
66+
});

apps/website/src/app/contact/page.tsx

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,71 @@ import React, { Suspense } from 'react';
22
import { Container } from '../../components/ui/Container';
33
import { Section } from '../../components/ui/Section';
44
import { Eyebrow } from '../../components/ui/Eyebrow';
5-
import { ContactForm } from '../../components/contact/ContactForm';
5+
import { FormCard } from '../../components/form';
6+
import { ContactForm, type ContactIntent } from '../../components/contact/ContactForm';
67
import { GitHubStarsPill } from '../../components/contact/GitHubStarsPill';
7-
import { SlaCard } from '../../components/contact/SlaCard';
8-
import { AltChannelRow } from '../../components/contact/AltChannelRow';
98
import { createPageMetadata } from '../../lib/site-metadata';
109
import { getFormPolicy } from '../../lib/growth/form-policy';
1110

1211
export const metadata = createPageMetadata({
1312
title: 'Talk to an engineer — Threadplane',
14-
description: "Tell us what you're shipping. We'll reply within one business dayusually with code, not a calendar invite.",
13+
description: 'Tell us what you are shipping. We reply within one business day, usually with code, not a calendar invite.',
1514
pathname: '/contact',
1615
type: 'website',
1716
});
1817

19-
export default function ContactPage() {
18+
// Entry points are analytics keys: lowercase snake_case, e.g. pricing_tier_enterprise. Anything else is dropped.
19+
const ENTRY_POINT = /^[a-z0-9_]{1,64}$/u;
20+
21+
function readIntent(value: string | undefined): ContactIntent {
22+
return value === 'enterprise' ? 'enterprise' : 'contact';
23+
}
24+
25+
function readEntryPoint(value: string | undefined): string | undefined {
26+
return value && ENTRY_POINT.test(value) ? value : undefined;
27+
}
28+
29+
export default async function ContactPage({
30+
searchParams,
31+
}: {
32+
searchParams: Promise<{ intent?: string; entry?: string }>;
33+
}) {
34+
const params = await searchParams;
35+
const intent = readIntent(params.intent);
36+
const entryPoint = readEntryPoint(params.entry);
2037
const formPolicy = getFormPolicy();
2138
return (
22-
<Section surface="canvas" ariaLabelledBy="contact-heading">
39+
<Section surface="tinted" ariaLabelledBy="contact-heading">
2340
<Container>
24-
<div className="contact-page-inner">
25-
<Eyebrow tone="accent" className="contact-page-eyebrow-spaced">Contact</Eyebrow>
26-
<h1 id="contact-heading" className="contact-page-h1">
27-
Talk to an engineer.
28-
</h1>
29-
<p className="contact-page-subtitle">
30-
Tell us what you&apos;re shipping. We&apos;ll reply within one business day — usually with code, not a calendar invite.
31-
</p>
32-
<div className="contact-page-sla-wrap">
33-
<SlaCard />
34-
</div>
35-
<Suspense>
36-
<ContactForm formPolicy={formPolicy} />
37-
</Suspense>
38-
<div className="contact-page-links-row">
39-
<GitHubStarsPill />
40-
<AltChannelRow />
41+
<div className="contact-band">
42+
<div>
43+
<Eyebrow tone="accent" className="contact-band-eyebrow">
44+
{intent === 'enterprise' ? 'Enterprise' : 'Contact'}
45+
</Eyebrow>
46+
<h1 id="contact-heading" className="contact-page-h1">
47+
Talk to an engineer.
48+
</h1>
49+
<p className="contact-band-lede">
50+
Tell us what you are shipping. We reply within one business day, usually with code, not a calendar invite.
51+
</p>
52+
<p className="contact-band-note">
53+
Brian or someone on the team replies personally, from a real inbox, not <code>noreply@</code>. We read every message.
54+
</p>
55+
<div className="contact-band-channels">
56+
<Eyebrow tone="muted">Prefer not to use a form</Eyebrow>
57+
<div className="contact-chips">
58+
<a className="contact-chip" href="mailto:brian@threadplane.ai">brian@threadplane.ai</a>
59+
<a className="contact-chip" href="https://github.com/cacheplane/angular-agent-framework/issues">GitHub issues</a>
60+
<a className="contact-chip" href="https://discord.gg/cacheplane">Discord</a>
61+
</div>
62+
<Suspense fallback={null}>
63+
<GitHubStarsPill />
64+
</Suspense>
65+
</div>
4166
</div>
67+
<FormCard>
68+
<ContactForm formPolicy={formPolicy} intent={intent} entryPoint={entryPoint} />
69+
</FormCard>
4270
</div>
4371
</Container>
4472
</Section>

apps/website/src/app/pilot-to-prod/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export default function PilotToProdPage() {
195195
<p className="pilot-contact-body">
196196
30-minute discovery call. We&apos;ll dig into your Angular surface, your agent-eligible workflows, and whether Pilot-to-Prod is the right fit. No pitch deck.
197197
</p>
198-
<Button variant="primary" size="lg" href="/pricing#lead-form">
198+
<Button variant="primary" size="lg" href="/pricing#enterprise">
199199
Request a discovery call
200200
</Button>
201201
</div>

apps/website/src/app/pricing/page.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
66
vi.mock('server-only', () => ({}));
77
import PricingPage, { metadata } from './page';
88

9-
vi.mock('../../components/pricing/LeadForm', () => ({ LeadForm: () => null }));
9+
vi.mock('../../components/pricing/EnterpriseCtaBand', () => ({ EnterpriseCtaBand: () => null }));
1010
vi.mock('../../components/landing/FinalCTA', () => ({ FinalCTA: () => null }));
1111
vi.mock('../../lib/analytics/client', () => ({ trackCtaClick: vi.fn() }));
1212

apps/website/src/app/pricing/page.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { Eyebrow } from '../../components/ui/Eyebrow';
44
import { CompareTable } from '../../components/pricing/CompareTable';
55
import { ArchitectureBoundary, PricingComparison } from '../../components/pricing/PricingDetails';
66
import { PricingFAQ } from '../../components/pricing/PricingFAQ';
7-
import { LeadForm } from '../../components/pricing/LeadForm';
7+
import { EnterpriseCtaBand } from '../../components/pricing/EnterpriseCtaBand';
88
import { FinalCTA } from '../../components/landing/FinalCTA';
99
import { createPageMetadata } from '../../lib/site-metadata';
1010
import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs';
11-
import { getFormPolicy } from '../../lib/growth/form-policy';
1211

1312
export const metadata = createPageMetadata({
1413
title: 'Pricing — Threadplane',
@@ -19,7 +18,6 @@ export const metadata = createPageMetadata({
1918
});
2019

2120
export default function PricingPage() {
22-
const formPolicy = getFormPolicy();
2321
return (
2422
<>
2523
<Section surface="canvas" ariaLabelledBy="pricing-heading">
@@ -63,7 +61,7 @@ export default function PricingPage() {
6361

6462
<PricingFAQ />
6563

66-
<LeadForm formPolicy={formPolicy} />
64+
<EnterpriseCtaBand />
6765
<FinalCTA />
6866
</>
6967
);

apps/website/src/app/solutions/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export default async function SolutionPage({ params }: PageProps) {
206206
<FinalCTA
207207
headline={solution.ctaHeadline}
208208
subtext={solution.ctaSubtext}
209-
primary={{ label: 'Talk to us', href: '/pricing#lead-form' }}
209+
primary={{ label: 'Talk to us', href: '/pricing#enterprise' }}
210210
secondary={{ label: 'Read the docs →', href: '/docs' }}
211211
/>
212212
</>

apps/website/src/components/contact/AltChannelRow.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)