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
5 changes: 4 additions & 1 deletion apps/website/src/app/pricing/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('PricingPage', () => {
expect(screen.getByText(/Every package is MIT/i)).toBeTruthy();
expect(screen.getByText(/commercial products, internal tools, and client work/i)).toBeTruthy();
expect(screen.getByText(/No Threadplane cloud/i)).toBeTruthy();
expect(screen.getByText(/Angular 20, 21, 22, CI-tested/i)).toBeTruthy();
expect(screen.getByRole('heading', { level: 2, name: /Open software/i })).toBeTruthy();
expect(screen.getByText(/does not host your agents or conversations/i)).toBeTruthy();
expect(screen.getByText(/durable persistence.*connected backend/i)).toBeTruthy();
Expand All @@ -27,7 +28,9 @@ describe('PricingPage', () => {
expect(screen.getByRole('table', { name: /Full plan comparison/i })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: 'Community' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: 'Production Assurance' })).toBeTruthy();
expect(screen.getByRole('rowheader', { name: 'MIT-licensed software' })).toBeTruthy();
expect(screen.queryByRole('rowheader', { name: 'MIT-licensed software' })).toBeNull();
expect(screen.getByRole('rowheader', { name: 'Private support channel' })).toBeTruthy();
expect(screen.getByText(/identical on every path/i)).toBeTruthy();
expect(container.querySelectorAll('table[aria-label="Full plan comparison"]')).toHaveLength(1);
});

Expand Down
17 changes: 3 additions & 14 deletions apps/website/src/app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { Section } from '../../components/ui/Section';
import { Eyebrow } from '../../components/ui/Eyebrow';
import { CompareTable } from '../../components/pricing/CompareTable';
import { ArchitectureBoundary, PricingComparison } from '../../components/pricing/PricingDetails';
import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix';
import { PricingFAQ } from '../../components/pricing/PricingFAQ';
import { LeadForm } from '../../components/pricing/LeadForm';
import { FinalCTA } from '../../components/landing/FinalCTA';
import { createPageMetadata } from '../../lib/site-metadata';
import { WEBSITE_SUPPORTED_ANGULAR_VERSIONS } from '../../components/pricing/angular-support.mjs';

export const metadata = createPageMetadata({
title: 'Pricing — Threadplane',
Expand Down Expand Up @@ -37,6 +37,8 @@ export default function PricingPage() {
<span aria-hidden="true">·</span>
<span>Commercial use without registration or runtime checks</span>
<span aria-hidden="true">·</span>
<span>{WEBSITE_SUPPORTED_ANGULAR_VERSIONS}, CI-tested</span>
<span aria-hidden="true">·</span>
<strong>No Threadplane cloud</strong>
</p>
</div>
Expand All @@ -57,19 +59,6 @@ export default function PricingPage() {
</Container>
</Section>

<Section surface="canvas">
<Container>
<Eyebrow className="pricing-page-eyebrow-tight">Compatibility</Eyebrow>
<h2 className="pricing-page-h2">
Angular version support
</h2>
<p className="pricing-page-compat-body">
We ship against the versions our CI tests. Other versions may work but aren&apos;t guaranteed.
</p>
<CompatibilityMatrix />
</Container>
</Section>

<PricingFAQ />

<LeadForm />
Expand Down
45 changes: 0 additions & 45 deletions apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx

This file was deleted.

44 changes: 0 additions & 44 deletions apps/website/src/components/pricing/CompatibilityMatrix.tsx

This file was deleted.

63 changes: 63 additions & 0 deletions apps/website/src/components/pricing/LeadForm.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LeadForm } from './LeadForm';

const trackMock = vi.fn();
vi.mock('../../lib/analytics/client', async (importOriginal) => {
const mod = await importOriginal<Record<string, unknown>>();
return { ...mod, track: (...args: unknown[]) => trackMock(...args) };
});

describe('LeadForm', () => {
beforeEach(() => {
trackMock.mockClear();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});

it('submits and fires the lead analytics with surface/source_section, then shows the sent state', async () => {
render(<LeadForm />);
fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } });
fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } });
fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } });
fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!);

await waitFor(() => {
expect(screen.getByText(/we'll be in touch within one business day/i)).toBeTruthy();
});

expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_submit', {
surface: 'pricing',
source_section: 'lead-form',
});
expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_success', {
surface: 'pricing',
source_section: 'lead-form',
});
expect(trackMock).not.toHaveBeenCalledWith('marketing:lead_form_fail', expect.anything());
});

it('fires the fail event with error_reason api_error when the request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
render(<LeadForm />);
fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Dev' } });
fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'dev@example.com' } });
fireEvent.change(screen.getByLabelText(/^company$/i), { target: { value: 'Acme' } });
fireEvent.submit(screen.getByRole('button', { name: /request enterprise quote/i }).closest('form')!);

await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeTruthy();
});

expect(trackMock).toHaveBeenCalledWith('marketing:lead_form_fail', {
surface: 'pricing',
source_section: 'lead-form',
error_reason: 'api_error',
});
});

it('renders the See how Pilot-to-Prod works link pointing at /pilot-to-prod', () => {
render(<LeadForm />);
const link = screen.getByRole('link', { name: /see how pilot-to-prod works/i });
expect(link.getAttribute('href')).toBe('/pilot-to-prod');
});
});
58 changes: 7 additions & 51 deletions apps/website/src/components/pricing/LeadForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,6 @@ import { Eyebrow } from '../ui/Eyebrow';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';

const VALUE_PROPS = [
{
title: 'Production assurance',
body: 'Architecture guidance, private support, and response commitments scoped to your production needs.',
},
{
title: 'SLA + security review',
body: 'Response SLAs, security questionnaires, and a private support channel.',
},
{
title: 'Optional Pilot-to-Prod engagement',
body: 'A separately scoped delivery engagement for teams that want guided implementation from prototype through production.',
highlight: true,
link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' },
},
{
title: 'Procurement support',
body: 'Master services agreement where applicable, security review, and custom indemnification where contractually agreed.',
},
];

export function LeadForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe');
Expand Down Expand Up @@ -77,45 +56,22 @@ export function LeadForm() {
<Container>
<div className="lead-form-wrap">
<div className="lead-form-header">
<Eyebrow tone="accent" className="lead-form-eyebrow">Enterprise</Eyebrow>
<div className="lead-form-rail">
<Eyebrow tone="accent" className="lead-form-eyebrow">Enterprise</Eyebrow>
<span className="lead-form-rail-line" aria-hidden="true" />
</div>
<h2 id="lead-form-heading" className="lead-form-heading">
Choose the support.<br />Add delivery if you need it.
</h2>
<p className="lead-form-subhead">
Production Assurance and Pilot-to-Prod are separate choices. Request ongoing support or ask us to scope a hands-on delivery engagement.
</p>
<a href="/pilot-to-prod" className="lead-form-p2p-link">
See how Pilot-to-Prod works →
</a>
</div>

<div className="lead-form-grid">
{/* Value props column */}
<ul className="lead-form-value-list">
{VALUE_PROPS.map((vp) => (
<li
key={vp.title}
className="lead-form-value-item"
data-highlight={vp.highlight || undefined}
>
<span aria-hidden="true" className="lead-form-value-check">
</span>
<div>
<div className="lead-form-value-title">
{vp.title}
</div>
<p className="lead-form-value-body">
{vp.body}
</p>
{vp.link && (
<a href={vp.link.href} className="lead-form-value-link">
{vp.link.label}
</a>
)}
</div>
</li>
))}
</ul>

{/* Form column */}
{status === 'sent' ? (
<Card padding="lg">
<p className="lead-form-sent-message">
Expand Down
Loading
Loading