diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index a2011277e..bdb593dc5 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -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({ diff --git a/apps/website/src/components/landing/TeamsBlock.spec.tsx b/apps/website/src/components/landing/TeamsBlock.spec.tsx index f0232ecab..44fa93846 100644 --- a/apps/website/src/components/landing/TeamsBlock.spec.tsx +++ b/apps/website/src/components/landing/TeamsBlock.spec.tsx @@ -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(); }); diff --git a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx index 9379cd674..cb1a3c5a7 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.spec.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.spec.tsx @@ -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, call: number) { @@ -57,12 +57,37 @@ 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(); + 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(); + 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', () => { @@ -70,7 +95,7 @@ describe('WhitePaperBlock growth policy', () => { render(); 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); @@ -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( diff --git a/apps/website/src/components/landing/WhitePaperForm.tsx b/apps/website/src/components/landing/WhitePaperForm.tsx index 92e94b4a7..55a92809b 100644 --- a/apps/website/src/components/landing/WhitePaperForm.tsx +++ b/apps/website/src/components/landing/WhitePaperForm.tsx @@ -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 }; @@ -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, @@ -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 | null>(null); + const [emailMessage, setEmailMessage] = useState(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) => ( + trackWhitepaperDownloadClick(paper, { surface, source_section: sourceSection, cta_id: ctaId })} + > + {label} + + ); + + 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 ( + + {directLink('home_whitepaper_direct', 'Download the PDF directly')} + + ); + } + if (form.status === 'stale') { + return ( + + + + ); + } return ( - <> - {state === 'done' ? ( - - ) : state === 'stale' ? ( -
-

{FORM_POLICY_REFRESH_MESSAGE}

- -
- ) : ( -
- - + +
+ 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'} /> -

- {formPolicy.disclosures.whitepaper} -

- - - )} - {state === 'error' && ( -

- Something went wrong — please try again or{' '} - - download directly - - . -

- )} - {state !== 'done' && ( -

- Already on the list?{' '} - - trackWhitepaperDownloadClick(paper, { - surface, - source_section: sourceSection, - cta_id: 'home_whitepaper_direct_inline', - }) - } - className="wp-already-link" - > - Download the PDF directly. - -

- )} - + + Get the field report + +
+
+

+ {formPolicy.disclosures.whitepaper} +

+ {form.status === 'failed' ? ( + + {directLink('home_whitepaper_direct', 'Download the PDF directly')} + + ) : null} +

Already on the list? {directLink('home_whitepaper_direct_inline', 'Download the PDF directly.')}

+ ); } diff --git a/apps/website/src/components/shared/AnnouncementToast.spec.tsx b/apps/website/src/components/shared/AnnouncementToast.spec.tsx index b13a02c65..35eb23bcd 100644 --- a/apps/website/src/components/shared/AnnouncementToast.spec.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.spec.tsx @@ -83,10 +83,10 @@ function openForm(): void { } function fillAndSubmit(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: /send me the guide/i })); + fireEvent.click(screen.getByRole('button', { name: 'Get the field report' })); } async function flush(): Promise { @@ -120,7 +120,7 @@ describe('AnnouncementToast growth policy', () => { openForm(); const disclosure = screen.getByText(formPolicy.disclosures.whitepaper); - const submit = screen.getByRole('button', { name: /send me the guide/i }); + const submit = screen.getByRole('button', { name: 'Get the field report' }); expect(disclosure.id).toBeTruthy(); expect(submit.getAttribute('aria-describedby')).toBe(disclosure.id); @@ -157,4 +157,40 @@ describe('AnnouncementToast growth policy', () => { expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy(); expect(screen.queryByText(/check your inbox/i)).toBeNull(); }); + + it('reports a failed send instead of pretending it succeeded', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + openForm(); + + fillAndSubmit('reader@acme.com'); + + await flush(); + expect(screen.getByRole('alert').textContent).toContain('That did not send.'); + }); + + it('validates on blur and focuses the field on an invalid submit', () => { + vi.stubGlobal('fetch', vi.fn()); + openForm(); + + const input = screen.getByLabelText('Work email'); + act(() => { (document.activeElement as HTMLElement | null)?.blur(); }); + expect(document.activeElement).not.toBe(input); + fireEvent.click(screen.getByRole('button', { name: 'Get the field report' })); + expect(screen.getByText('Enter your email address.')).toBeTruthy(); + expect(document.activeElement).toBe(input); + + fireEvent.change(input, { target: { value: 'reader@acme' } }); + fireEvent.blur(input); + expect(screen.getByText('Enter a full address, like jordan@acme.dev.')).toBeTruthy(); + }); + + it('shows the sent confirmation on success', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); + openForm(); + + fillAndSubmit('reader@example.com'); + + await flush(); + expect(screen.getByRole('status').textContent).toContain('Check your inbox.'); + }); }); diff --git a/apps/website/src/components/shared/AnnouncementToast.tsx b/apps/website/src/components/shared/AnnouncementToast.tsx index ce020d324..dc1f042ae 100644 --- a/apps/website/src/components/shared/AnnouncementToast.tsx +++ b/apps/website/src/components/shared/AnnouncementToast.tsx @@ -1,17 +1,21 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } 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 { analyticsEvents } from '../../lib/analytics/events'; import { track, trackWhitepaperDownloadClick, } from '../../lib/analytics/client'; import { Button } from '../ui/Button'; +import { + Field, + FormStatus, + SubmitButton, + TextInput, + emailError, + useGrowthForm, +} from '../form'; /** * Bump this date to re-show the toast for all users. @@ -32,13 +36,24 @@ export function AnnouncementToast({ const [mounted, setMounted] = useState(false); const [step, setStep] = useState('cta'); const [email, setEmail] = useState(''); - const [submitting, setSubmitting] = useState(false); - const submissionSnapshot = useRef | null>(null); + const [emailMessage, setEmailMessage] = useState(null); const disclosureId = 'toast-whitepaper-growth-disclosure'; + const form = useGrowthForm<{ email: string; paper: 'overview' }>({ + route: '/api/whitepaper-signup', + formPolicy, + events: { + submit: analyticsEvents.marketingWhitepaperSignupSubmit, + success: analyticsEvents.marketingWhitepaperSignupSuccess, + fail: analyticsEvents.marketingWhitepaperSignupFail, + }, + analytics: { + surface: 'toast', + source_section: 'announcement-toast', + paper: 'overview', + }, + }); + const [timerDone, setTimerDone] = useState(false); const [scrolledEnough, setScrolledEnough] = useState(false); @@ -99,58 +114,27 @@ export function AnnouncementToast({ } }; - const handleSubmit = async (e: React.FormEvent) => { + // Mirror the hook's terminal states onto the toast's step machine. + useEffect(() => { + if (form.status === 'sent') { + setStep('sent'); + const id = setTimeout(dismiss, 4000); + return () => clearTimeout(id); + } + if (form.status === 'stale') setStep('stale'); + return undefined; + // dismiss is stable for the component's lifetime; intentionally omitted. + }, [form.status]); + + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!email) return; - setSubmitting(true); - track(analyticsEvents.marketingWhitepaperSignupSubmit, { - surface: 'toast', - source_section: 'announcement-toast', - paper: 'overview', - }); - try { - const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { - email, - paper: 'overview' as const, - }); - submissionSnapshot.current = snapshot; - const response = 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 (response.status === 409) { - submissionSnapshot.current = null; - setStep('stale'); - setSubmitting(false); - return; - } - if (response.status >= 400 && response.status < 500) { - submissionSnapshot.current = null; - } - submissionSnapshot.current = null; - track(analyticsEvents.marketingWhitepaperSignupSuccess, { - surface: 'toast', - source_section: 'announcement-toast', - paper: 'overview', - }); - } catch { - track(analyticsEvents.marketingWhitepaperSignupFail, { - surface: 'toast', - source_section: 'announcement-toast', - paper: 'overview', - error_reason: 'api_error', - }); + const problem = emailError(email); + setEmailMessage(problem); + if (problem) { + document.getElementById('toast-email')?.focus(); + return; } - setStep('sent'); - setSubmitting(false); - // Auto-dismiss after showing success - setTimeout(dismiss, 4000); + void form.submit({ email: email.trim(), paper: 'overview' }); }; if (!visible) return null; @@ -212,35 +196,65 @@ export function AnnouncementToast({ )} {step === 'form' && ( -
- - setEmail(e.target.value)} - required - disabled={submitting} - autoFocus - className="toast-input" - /> -

+ + + { + setEmail(e.target.value); + if (emailMessage) setEmailMessage(emailError(e.target.value)); + }} + onBlur={() => setEmailMessage(emailError(email))} + disabled={form.status === 'pending'} + autoFocus + /> + +

{formPolicy.disclosures.whitepaper}

- + Get the field report +
+ {form.status === 'failed' ? ( + + { + trackWhitepaperDownloadClick('overview', { + surface: 'toast', + source_section: 'announcement-toast', + cta_id: 'toast_direct_download', + }); + dismiss(); + }} + > + Download the PDF directly + + + ) : null} -

{FORM_POLICY_REFRESH_MESSAGE}

-
+
+ -
+
)} {step === 'sent' && (
- {/* role=status: the step swap is announced without stealing focus. */} -

- ✓ Check your inbox — the guide is on its way! -

+
)} diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index 87e1e3bfc..964fe8a64 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -447,39 +447,22 @@ font-size: 0.68rem; color: var(--color-text-muted); text-decoration: underline; - padding: 8px 4px; + padding: 8px 6px; + min-height: 24px; } .toast-mt-section { margin-top: 8px; } -.toast-input { - width: 100%; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 8px 12px; - font-size: 0.82rem; - color: var(--color-text-primary); - font-family: var(--font-inter); - outline: none; - margin-bottom: 10px; -} -.toast-input:focus { - border-color: var(--color-accent); -} .toast-download-link { - display: inline-block; - margin-top: 10px; - font-size: 0.7rem; + display: inline-flex; + align-items: center; + min-height: 24px; + margin-top: 0; + font-size: 12px; color: var(--color-text-muted); text-decoration: underline; font-family: var(--font-inter); } -.toast-success-text { - font-size: 0.85rem; - color: #1a7a40; - line-height: 1.5; -} /* Mobile docs-search entry (polish arc PR 3) — button reset to match the * .nav-mobile-item link styling it shares. */ diff --git a/apps/website/src/styles/forms.css b/apps/website/src/styles/forms.css index d3086bf4b..de81ec21b 100644 --- a/apps/website/src/styles/forms.css +++ b/apps/website/src/styles/forms.css @@ -130,6 +130,11 @@ select[data-ui="form-control"] { flex: 1 1 160px; min-width: 0; } +/* A row button matches the control height so the pair sits flush. */ +/* Ties on specificity with [data-ui="button"][data-size="lg"] in ui.css; wins only because forms.css is imported after ui.css in global.css. */ +[data-ui="form-row"] [data-ui="button"] { + height: var(--form-control-height); +} /* A compact form's row button matches the compact control height. */ [data-ui="form"][data-compact] [data-ui="form-row"] [data-ui="button"] { height: var(--form-control-height-compact); diff --git a/apps/website/src/styles/landing.css b/apps/website/src/styles/landing.css index 044124d50..efbfdce90 100644 --- a/apps/website/src/styles/landing.css +++ b/apps/website/src/styles/landing.css @@ -269,47 +269,16 @@ gap: 2px; } } -.wp-success { - font-family: var(--font-inter); - font-size: var(--text-body); - color: #1a7a40; - margin-bottom: 16px; -} -.wp-success-link { - color: var(--color-accent); -} .wp-form { - display: flex; - gap: 8px; - flex-wrap: wrap; - max-width: 480px; -} -.wp-email-input { - flex: 1 1 240px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 12px 14px; - font-family: var(--font-inter); - font-size: var(--text-body); - color: var(--color-text-primary); - outline: none; -} -.wp-error { - margin-top: 12px; - color: var(--color-angular-red); - font-size: 14px; -} -.wp-error-link { - color: var(--color-accent); + max-width: 520px; } .wp-already { - margin-top: 12px; + margin-top: 4px; font-size: 13px; color: var(--color-text-muted); font-family: var(--font-inter); } -.wp-already-link { +.wp-already a { color: var(--color-accent); text-decoration: underline; }