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
4 changes: 2 additions & 2 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ test('whitepaper signup form posts to /api/whitepaper-signup and renders success
});

await page.goto('/chat');
await page.locator('#whitepaper-block').getByLabel('Email address').fill('reader@acme.com');
await page.locator('#whitepaper-block').getByRole('button', { name: 'Download (free)' }).click();
await page.locator('#whitepaper-block').getByLabel('Work email').fill('reader@acme.com');
await page.locator('#whitepaper-block').getByRole('button', { name: 'Get the field report' }).click();

await expect(page.getByText(/check your inbox/i)).toBeVisible();
expect(payload).toMatchObject({
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/components/landing/TeamsBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('TeamsBlock', () => {
expect(screen.getByRole('link', { name: 'Talk to an engineer' }).getAttribute('href')).toBe('/contact?source=home_enterprise&track=enterprise');
expect(screen.getByRole('link', { name: 'See the pilot program' }).getAttribute('href')).toBe('/pilot-to-prod');
expect(container.querySelectorAll('form')).toHaveLength(1);
expect(screen.getByLabelText('Email address')).toBeTruthy();
expect(screen.getByLabelText('Work email')).toBeTruthy();
expect(screen.getByText('Whitepaper disclosure')).toBeTruthy();
});

Expand Down
35 changes: 30 additions & 5 deletions apps/website/src/components/landing/WhitePaperBlock.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;

function submit(email: string): void {
fireEvent.change(screen.getByLabelText(/email address/i), {
fireEvent.change(screen.getByLabelText('Work email'), {
target: { value: email },
});
fireEvent.click(screen.getByRole('button', { name: /download \(free\)/i }));
fireEvent.click(screen.getByRole('button', { name: 'Get the field report' }));
}

function sentBody(fetchMock: ReturnType<typeof vi.fn>, call: number) {
Expand All @@ -57,20 +57,45 @@ describe('WhitePaperBlock', () => {
submit('dev@example.com');

await waitFor(() =>
expect(screen.getByText(/Check your inbox/i)).toBeTruthy()
expect(screen.getByRole('status').textContent).toContain('Check your inbox.')
);
const events = trackMock.mock.calls.map((call) => call[0]);
expect(events).toContain('marketing:whitepaper_signup_submit');
expect(events).toContain('marketing:whitepaper_signup_success');
});

it('shows the direct PDF link in the failure block', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<WhitePaperBlock formPolicy={formPolicy} paper="chat" />);
fireEvent.change(screen.getByLabelText('Work email'), { target: { value: 'reader@acme.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Get the field report' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy());
expect(screen.getByRole('link', { name: 'Download the PDF directly' }).getAttribute('href')).toBe('/whitepapers/chat.pdf');
});

it('validates on blur, focuses the field on an invalid submit, and clears once valid', () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render(<WhitePaperBlock formPolicy={formPolicy} />);
const input = screen.getByLabelText('Work email');
fireEvent.click(screen.getByRole('button', { name: 'Get the field report' }));
expect(screen.getByText('Enter your email address.')).toBeTruthy();
expect(document.activeElement).toBe(input);
expect(fetchMock).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: 'reader@acme' } });
fireEvent.blur(input);
expect(screen.getByText('Enter a full address, like jordan@acme.dev.')).toBeTruthy();
fireEvent.change(input, { target: { value: 'reader@acme.dev' } });
expect(screen.queryByText(/full address/)).toBeNull();
});
});

describe('WhitePaperBlock growth policy', () => {
it('renders the whitepaper disclosure and describes the submit control', () => {
render(<WhitePaperBlock formPolicy={formPolicy} />);

const disclosure = screen.getByText(formPolicy.disclosures.whitepaper);
const button = screen.getByRole('button', { name: /download \(free\)/i });
const button = screen.getByRole('button', { name: 'Get the field report' });

expect(disclosure.id).toBeTruthy();
expect(button.getAttribute('aria-describedby')).toBe(disclosure.id);
Expand Down Expand Up @@ -103,7 +128,7 @@ describe('WhitePaperBlock growth policy', () => {

submit('reader@example.com');
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: /download \(free\)/i }));
fireEvent.click(screen.getByRole('button', { name: 'Get the field report' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));

expect(sentBody(fetchMock, 1).submission_id).toBe(
Expand Down
221 changes: 76 additions & 145 deletions apps/website/src/components/landing/WhitePaperForm.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
'use client';
import { useRef, useState } from 'react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import type { PublicFormPolicy } from '../../lib/growth/form-policy';
import {
FORM_POLICY_REFRESH_MESSAGE,
growthFormRequestSnapshot,
type GrowthFormRequestSnapshot,
} from '../../lib/growth/form-client';
import { FORM_POLICY_REFRESH_MESSAGE } from '../../lib/growth/form-client';
import { Button } from '../ui/Button';
import {
analyticsEvents,
type AnalyticsSurface,
type WhitepaperId,
} from '../../lib/analytics/events';
import { track, trackWhitepaperDownloadClick } from '../../lib/analytics/client';
import { trackWhitepaperDownloadClick } from '../../lib/analytics/client';
import { Field, FormStatus, SubmitButton, TextInput, emailError, useGrowthForm } from '../form';

export type { WhitepaperId };

Expand All @@ -33,6 +31,7 @@ interface WhitePaperFormProps {
idPrefix: string;
}

/** The whitepaper signup on the form kit: label above the field, inline submit, validation on blur. */
export function WhitePaperForm({
paper,
formPolicy,
Expand All @@ -42,156 +41,88 @@ export function WhitePaperForm({
}: WhitePaperFormProps) {
const pdf = PDF_PATHS[paper];
const [email, setEmail] = useState('');
const [state, setState] = useState<
'idle' | 'submitting' | 'done' | 'error' | 'stale'
>('idle');
const submissionSnapshot = useRef<GrowthFormRequestSnapshot<{
email: string;
paper: WhitepaperId;
}> | null>(null);
const [emailMessage, setEmailMessage] = useState<string | null>(null);
const form = useGrowthForm<{ email: string; paper: WhitepaperId }>({
route: '/api/whitepaper-signup',
formPolicy,
events: {
submit: analyticsEvents.marketingWhitepaperSignupSubmit,
success: analyticsEvents.marketingWhitepaperSignupSuccess,
fail: analyticsEvents.marketingWhitepaperSignupFail,
},
analytics: { surface, source_section: sourceSection, paper },
});
const inputId = `${idPrefix}-email`;
const disclosureId = `${idPrefix}-growth-disclosure`;

const submit = async (e: React.FormEvent) => {
const directLink = (ctaId: 'home_whitepaper_direct' | 'home_whitepaper_direct_inline', label: string) => (
<a
href={pdf.href}
download={pdf.download}
onClick={() => trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId })}
>
{label}
</a>
);

const submit = (e: FormEvent) => {
e.preventDefault();
if (!email) return;
setState('submitting');
track(analyticsEvents.marketingWhitepaperSignupSubmit, {
surface,
source_section: sourceSection,
paper,
});
try {
const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, {
email,
paper,
});
submissionSnapshot.current = snapshot;
const res = await fetch('/api/whitepaper-signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...snapshot.facts,
acquisition_session_id: snapshot.acquisition_session_id,
submission_id: snapshot.submission_id,
policy_version: formPolicy.version,
}),
});
if (res.status === 409) {
submissionSnapshot.current = null;
setState('stale');
return;
}
if (res.status >= 400 && res.status < 500) {
submissionSnapshot.current = null;
}
if (!res.ok) throw new Error('whitepaper_signup_failed');
submissionSnapshot.current = null;
track(analyticsEvents.marketingWhitepaperSignupSuccess, {
surface,
source_section: sourceSection,
paper,
});
setState('done');
} catch {
track(analyticsEvents.marketingWhitepaperSignupFail, {
surface,
source_section: sourceSection,
paper,
error_reason: 'api_error',
});
setState('error');
const problem = emailError(email);
setEmailMessage(problem);
if (problem) {
document.getElementById(inputId)?.focus();
return;
}
void form.submit({ email: email.trim(), paper });
};

if (form.status === 'sent') {
return (
<FormStatus tone="success" title="Check your inbox." detail="The guide is on its way, and the PDF is here too.">
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
</FormStatus>
);
}
if (form.status === 'stale') {
return (
<FormStatus tone="stale" title="This page is out of date." detail={FORM_POLICY_REFRESH_MESSAGE}>
<Button type="button" variant="primary" size="lg" onClick={() => window.location.reload()}>
Refresh page
</Button>
</FormStatus>
);
}
return (
<>
{state === 'done' ? (
<div className="wp-success">
✓ Check your inbox — the guide is on its way.{' '}
<a
href={pdf.href}
download={pdf.download}
onClick={() =>
trackWhitepaperDownloadClick(paper, {
surface,
source_section: sourceSection,
cta_id: 'home_whitepaper_direct',
})
}
className="wp-success-link"
>
Or download directly.
</a>
</div>
) : state === 'stale' ? (
<div role="alert" className="wp-form">
<p className="wp-disclosure">{FORM_POLICY_REFRESH_MESSAGE}</p>
<Button
type="button"
variant="primary"
size="lg"
onClick={() => window.location.reload()}
>
Refresh page
</Button>
</div>
) : (
<form onSubmit={submit} className="wp-form">
<label htmlFor={inputId} className="sr-only">Email address</label>
<input
id={inputId}
<form onSubmit={submit} className="wp-form" data-ui="form" noValidate>
<Field id={inputId} label="Work email" error={emailMessage}>
<div data-ui="form-row">
<TextInput
type="email"
autoComplete="email"
inputMode="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={state === 'submitting'}
className="wp-email-input"
onChange={(e) => {
setEmail(e.target.value);
if (emailMessage) setEmailMessage(emailError(e.target.value));
}}
onBlur={() => setEmailMessage(emailError(email))}
disabled={form.status === 'pending'}
/>
<p id={disclosureId} className="wp-disclosure">
{formPolicy.disclosures.whitepaper}
</p>
<Button
type="submit"
variant="primary"
size="lg"
disabled={state === 'submitting' || !email}
aria-describedby={disclosureId}
>
{state === 'submitting' ? 'Sending…' : 'Download (free)'}
</Button>
</form>
)}
{state === 'error' && (
<p className="wp-error">
Something went wrong — please try again or{' '}
<a href={pdf.href} download={pdf.download} className="wp-error-link">
download directly
</a>
.
</p>
)}
{state !== 'done' && (
<p className="wp-already">
Already on the list?{' '}
<a
href={pdf.href}
download={pdf.download}
onClick={() =>
trackWhitepaperDownloadClick(paper, {
surface,
source_section: sourceSection,
cta_id: 'home_whitepaper_direct_inline',
})
}
className="wp-already-link"
>
Download the PDF directly.
</a>
</p>
)}
</>
<SubmitButton variant="primary" size="lg" pending={form.status === 'pending'} pendingLabel="Sending the guide…" aria-describedby={disclosureId}>
Get the field report
</SubmitButton>
</div>
</Field>
<p id={disclosureId} data-ui="form-disclosure">
{formPolicy.disclosures.whitepaper}
</p>
{form.status === 'failed' ? (
<FormStatus tone="failure" title="That did not send." detail="You can still get the guide.">
{directLink('home_whitepaper_direct', 'Download the PDF directly')}
</FormStatus>
) : null}
<p className="wp-already">Already on the list? {directLink('home_whitepaper_direct_inline', 'Download the PDF directly.')}</p>
</form>
);
}
Loading
Loading