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
66 changes: 29 additions & 37 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ test('pricing page routes software and support CTAs without checkout', async ({
await page.goto('/pricing');

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

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

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

test('contact page submits a lead payload and renders success state', async ({ page }) => {
let leadPayload: Record<string, unknown> | undefined;
await page.route('**/api/leads', async (route) => {
leadPayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
});

await page.goto('/contact');
const contactForm = page.locator('main form').first();
await contactForm.getByRole('textbox', { name: 'Email', exact: true }).fill('jane@acme.com');
await contactForm.getByRole('textbox', { name: 'Name' }).fill('Jane Smith');
await contactForm.getByRole('textbox', { name: 'Company' }).fill('Acme');
await contactForm.getByRole('textbox', { name: 'Message' }).fill('We are evaluating Threadplane.');
await contactForm.getByRole('button', { name: 'Send' }).click();

await expect(page.getByText("Thanks. We'll be in touch within one business day.")).toBeVisible();
const form = page.locator('main form').first();
await form.getByLabel('Work email').fill('jane@acme.com');
await form.getByLabel('Name').fill('Jane Smith');
await form.getByLabel('Company').fill('Acme');
await form.getByLabel('What are you shipping?').fill('We are evaluating Threadplane.');
await form.getByRole('button', { name: 'Send to Brian' }).click();

await expect(page.getByRole('status')).toContainText('Sent.');
expect(leadPayload).toMatchObject({
form_kind: 'contact',
email: 'jane@acme.com',
Expand All @@ -127,35 +121,33 @@ test('contact page submits a lead payload and renders success state', async ({ p
expect(leadPayload?.['submission_id']).toMatch(UUID_V4);
});

test('pricing lead form posts to /api/leads and renders success state', async ({ page }) => {
test('contact page enterprise intent posts the pricing form kind with a timeline', async ({ page }) => {
let leadPayload: Record<string, unknown> | undefined;
await page.route('**/api/leads', async (route) => {
leadPayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
});

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

await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible();
await form.getByLabel('Work email').fill('jane@acme.com');
await form.getByLabel('Company').fill('Acme');
await form.getByLabel('Timeline').selectOption('this_quarter');
await form.getByLabel('Tell us about your use case').fill('Volume seats and security review.');
await form.getByRole('button', { name: 'Request a conversation' }).click();

await expect(page.getByRole('status')).toContainText('Sent.');
expect(leadPayload).toMatchObject({
form_kind: 'pricing',
email: 'jane@acme.com',
name: 'Jane Smith',
company: 'Acme',
timeline: 'this_quarter',
message: 'Volume seats and security review.',
policy_version: GROWTH_FORM_POLICY_VERSION,
});
expect(leadPayload?.['submission_id']).toMatch(UUID_V4);
});

test('footer newsletter form posts to /api/newsletter and renders success state', async ({ page }) => {
Expand Down
66 changes: 66 additions & 0 deletions apps/website/src/app/contact/page.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

const track = vi.hoisted(() => vi.fn());

vi.mock('../../lib/analytics/client', () => ({ track, trackCtaClick: vi.fn(), trackExternalLinkClick: vi.fn() }));
vi.mock('server-only', () => ({}));
vi.mock('../../components/contact/GitHubStarsPill', () => ({ GitHubStarsPill: () => <span data-testid="stars" /> }));

import ContactPage from './page';

afterEach(() => {
vi.unstubAllGlobals();
});

describe('ContactPage', () => {
it('renders the contact variant by default', async () => {
render(await ContactPage({ searchParams: Promise.resolve({}) }));
expect(screen.getByText('Contact', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Talk to an engineer.');
expect(screen.getByRole('button', { name: 'Send to Brian' })).toBeTruthy();
expect(screen.queryByLabelText('Timeline')).toBeNull();
const email = screen.getByRole('link', { name: 'brian@threadplane.ai' });
expect(email.getAttribute('href')).toBe('mailto:brian@threadplane.ai');
const github = screen.getByRole('link', { name: 'GitHub issues' });
expect(github.getAttribute('href')).toBe('https://github.com/cacheplane/angular-agent-framework/issues');
const discord = screen.getByRole('link', { name: 'Discord' });
expect(discord.getAttribute('href')).toBe('https://discord.gg/cacheplane');
expect(screen.getByTestId('stars')).toBeTruthy();
});

it('renders the enterprise variant from the intent query and passes the entry point through', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
render(await ContactPage({ searchParams: Promise.resolve({ intent: 'enterprise', entry: 'pricing_tier_enterprise' }) }));
expect(screen.getByText('Enterprise', { selector: '[data-ui="eyebrow"]' })).toBeTruthy();
expect(screen.getByLabelText('Timeline')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Request a conversation' })).toBeTruthy();

fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'buyer@example.com' } });
fireEvent.change(screen.getByLabelText('Company'), { target: { value: 'Acme Co' } });
fireEvent.change(screen.getByLabelText('Timeline'), { target: { value: 'this_quarter' } });
fireEvent.click(screen.getByRole('button', { name: 'Request a conversation' }));

expect(track).toHaveBeenCalledWith(
'marketing:lead_form_submit',
expect.objectContaining({ surface: 'pricing', entry_point: 'pricing_tier_enterprise' })
);
});

it('ignores unknown intents and unsafe entry values', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
render(await ContactPage({ searchParams: Promise.resolve({ intent: 'weird', entry: '<script>' }) }));
const button = screen.getByRole('button', { name: 'Send to Brian' });
expect(button).toBeTruthy();

fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'buyer@example.com' } });
fireEvent.click(button);

expect(track).toHaveBeenCalledWith(
'marketing:lead_form_submit',
expect.not.objectContaining({ entry_point: expect.anything() })
);
});
});
74 changes: 51 additions & 23 deletions apps/website/src/app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,71 @@ import React, { Suspense } from 'react';
import { Container } from '../../components/ui/Container';
import { Section } from '../../components/ui/Section';
import { Eyebrow } from '../../components/ui/Eyebrow';
import { ContactForm } from '../../components/contact/ContactForm';
import { FormCard } from '../../components/form';
import { ContactForm, type ContactIntent } from '../../components/contact/ContactForm';
import { GitHubStarsPill } from '../../components/contact/GitHubStarsPill';
import { SlaCard } from '../../components/contact/SlaCard';
import { AltChannelRow } from '../../components/contact/AltChannelRow';
import { createPageMetadata } from '../../lib/site-metadata';
import { getFormPolicy } from '../../lib/growth/form-policy';

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

export default function ContactPage() {
// Entry points are analytics keys: lowercase snake_case, e.g. pricing_tier_enterprise. Anything else is dropped.
const ENTRY_POINT = /^[a-z0-9_]{1,64}$/u;

function readIntent(value: string | undefined): ContactIntent {
return value === 'enterprise' ? 'enterprise' : 'contact';
}

function readEntryPoint(value: string | undefined): string | undefined {
return value && ENTRY_POINT.test(value) ? value : undefined;
}

export default async function ContactPage({
searchParams,
}: {
searchParams: Promise<{ intent?: string; entry?: string }>;
}) {
const params = await searchParams;
const intent = readIntent(params.intent);
const entryPoint = readEntryPoint(params.entry);
const formPolicy = getFormPolicy();
return (
<Section surface="canvas" ariaLabelledBy="contact-heading">
<Section surface="tinted" ariaLabelledBy="contact-heading">
<Container>
<div className="contact-page-inner">
<Eyebrow tone="accent" className="contact-page-eyebrow-spaced">Contact</Eyebrow>
<h1 id="contact-heading" className="contact-page-h1">
Talk to an engineer.
</h1>
<p className="contact-page-subtitle">
Tell us what you&apos;re shipping. We&apos;ll reply within one business day — usually with code, not a calendar invite.
</p>
<div className="contact-page-sla-wrap">
<SlaCard />
</div>
<Suspense>
<ContactForm formPolicy={formPolicy} />
</Suspense>
<div className="contact-page-links-row">
<GitHubStarsPill />
<AltChannelRow />
<div className="contact-band">
<div>
<Eyebrow tone="accent" className="contact-band-eyebrow">
{intent === 'enterprise' ? 'Enterprise' : 'Contact'}
</Eyebrow>
<h1 id="contact-heading" className="contact-page-h1">
Talk to an engineer.
</h1>
<p className="contact-band-lede">
Tell us what you are shipping. We reply within one business day, usually with code, not a calendar invite.
</p>
<p className="contact-band-note">
Brian or someone on the team replies personally, from a real inbox, not <code>noreply@</code>. We read every message.
</p>
<div className="contact-band-channels">
<Eyebrow tone="muted">Prefer not to use a form</Eyebrow>
<div className="contact-chips">
<a className="contact-chip" href="mailto:brian@threadplane.ai">brian@threadplane.ai</a>
<a className="contact-chip" href="https://github.com/cacheplane/angular-agent-framework/issues">GitHub issues</a>
<a className="contact-chip" href="https://discord.gg/cacheplane">Discord</a>
</div>
<Suspense fallback={null}>
<GitHubStarsPill />
</Suspense>
</div>
</div>
<FormCard>
<ContactForm formPolicy={formPolicy} intent={intent} entryPoint={entryPoint} />
</FormCard>
</div>
</Container>
</Section>
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/pilot-to-prod/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export default function PilotToProdPage() {
<p className="pilot-contact-body">
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.
</p>
<Button variant="primary" size="lg" href="/pricing#lead-form">
<Button variant="primary" size="lg" href="/pricing#enterprise">
Request a discovery call
</Button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/pricing/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
vi.mock('server-only', () => ({}));
import PricingPage, { metadata } from './page';

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

Expand Down
6 changes: 2 additions & 4 deletions apps/website/src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import { Eyebrow } from '../../components/ui/Eyebrow';
import { CompareTable } from '../../components/pricing/CompareTable';
import { ArchitectureBoundary, PricingComparison } from '../../components/pricing/PricingDetails';
import { PricingFAQ } from '../../components/pricing/PricingFAQ';
import { LeadForm } from '../../components/pricing/LeadForm';
import { EnterpriseCtaBand } from '../../components/pricing/EnterpriseCtaBand';
import { FinalCTA } from '../../components/landing/FinalCTA';
import { createPageMetadata } from '../../lib/site-metadata';
import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs';
import { getFormPolicy } from '../../lib/growth/form-policy';

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

export default function PricingPage() {
const formPolicy = getFormPolicy();
return (
<>
<Section surface="canvas" ariaLabelledBy="pricing-heading">
Expand Down Expand Up @@ -63,7 +61,7 @@ export default function PricingPage() {

<PricingFAQ />

<LeadForm formPolicy={formPolicy} />
<EnterpriseCtaBand />
<FinalCTA />
</>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/solutions/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export default async function SolutionPage({ params }: PageProps) {
<FinalCTA
headline={solution.ctaHeadline}
subtext={solution.ctaSubtext}
primary={{ label: 'Talk to us', href: '/pricing#lead-form' }}
primary={{ label: 'Talk to us', href: '/pricing#enterprise' }}
secondary={{ label: 'Read the docs →', href: '/docs' }}
/>
</>
Expand Down
13 changes: 0 additions & 13 deletions apps/website/src/components/contact/AltChannelRow.tsx

This file was deleted.

Loading
Loading