From bffd2cc32a69886fd7cd1eab22badca73b8446f6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 09:06:40 -0700 Subject: [PATCH 01/10] feat(website): contact page band layout styles; drop the old contact-form rules Co-Authored-By: Claude Fable 5.1 --- apps/website/src/styles/marketing.css | 50 ------------- apps/website/src/styles/pages.css | 73 +++++++++++++++---- .../src/styles/style-contracts.spec.ts | 9 +++ 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/apps/website/src/styles/marketing.css b/apps/website/src/styles/marketing.css index 4164fba9b..124f3aedc 100644 --- a/apps/website/src/styles/marketing.css +++ b/apps/website/src/styles/marketing.css @@ -641,43 +641,6 @@ line-height: 1.2; } -/* ContactForm — components/contact/ContactForm.tsx */ -.contact-form-sent { - color: var(--color-text-primary); - padding: 24px; -} -.contact-form { - display: flex; - flex-direction: column; - gap: 16px; - max-width: 480px; -} -.contact-form-label { - font-size: var(--text-body); - color: var(--color-text-primary); -} -.contact-form-optional { - color: var(--color-text-muted); -} -.contact-form-input { - display: block; - width: 100%; - padding: 10px 12px; - font-size: var(--text-body); - font-family: var(--font-inter); - color: var(--color-text-primary); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 6px; - margin-top: 4px; -} -.contact-form-textarea { - resize: vertical; -} -.contact-form-error { - color: #c00; -} - /* AltChannelRow — components/contact/AltChannelRow.tsx */ .contact-alt-row { display: flex; @@ -696,19 +659,6 @@ color: var(--color-text-muted); } -/* SlaCard — components/contact/SlaCard.tsx */ -.sla-card { - padding: 20px; - max-width: 480px; -} -.sla-card-text { - margin: 0; - color: var(--color-text-primary); - font-size: var(--text-body); - font-family: var(--font-inter); - line-height: var(--text-body--line-height); -} - /* GitHubStarsPill — components/contact/GitHubStarsPill.tsx */ .gh-stars-pill-link { text-decoration: none; diff --git a/apps/website/src/styles/pages.css b/apps/website/src/styles/pages.css index 560d62084..415db171a 100644 --- a/apps/website/src/styles/pages.css +++ b/apps/website/src/styles/pages.css @@ -1157,12 +1157,6 @@ } /* Contact page — app/contact/page.tsx */ -.contact-page-inner { - max-width: 720px; -} -.contact-page-eyebrow-spaced { - margin-bottom: 16px; -} .contact-page-h1 { font-family: var(--font-garamond); font-size: var(--text-h1); @@ -1173,23 +1167,72 @@ margin-bottom: 16px; letter-spacing: -0.02em; } -.contact-page-subtitle { +.contact-band { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 48px; + align-items: start; +} +.contact-band-eyebrow { + margin-bottom: 16px; +} +.contact-band-lede { font-family: var(--font-inter); font-size: var(--text-body-lg); line-height: var(--text-body-lg--line-height); color: var(--color-text-secondary); - margin: 0; - margin-bottom: 24px; - max-width: 60ch; + margin: 0 0 24px; + max-width: 48ch; } -.contact-page-sla-wrap { - margin-bottom: 24px; +.contact-band-note { + font-family: var(--font-inter); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + color: var(--color-text-primary); + border-left: 3px solid var(--color-accent); + padding: 6px 0 6px 14px; + margin: 0 0 24px; } -.contact-page-links-row { - margin-top: 32px; +.contact-band-channels { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; +} +.contact-band-channels-label { + font-family: var(--font-mono); + font-size: var(--text-eyebrow); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0; +} +.contact-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.contact-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-text-primary); + text-decoration: none; + background: var(--color-surface); +} +.contact-chip:hover { + border-color: var(--color-accent-border-hover); + color: var(--color-accent); +} +@media (max-width: 900px) { + .contact-band { + grid-template-columns: minmax(0, 1fr); + gap: 28px; + } } /* Not-found page — app/not-found.tsx */ diff --git a/apps/website/src/styles/style-contracts.spec.ts b/apps/website/src/styles/style-contracts.spec.ts index a3519d075..2ca79d7e5 100644 --- a/apps/website/src/styles/style-contracts.spec.ts +++ b/apps/website/src/styles/style-contracts.spec.ts @@ -205,6 +205,15 @@ const CONTRACTS: StyleContract[] = [ gap: /gap:/, }, }, + { + file: 'pages.css', + selector: '.contact-band', + why: 'Heading column and form card are grid siblings; without the grid the card drops below the heading and the page reads as the old single column.', + requires: { + display: /display:\s*grid/, + 'grid-template-columns': /grid-template-columns:/, + }, + }, ]; function baseDeclarationsFor(css: string, selector: string): string { From 08df2dece65a387150dab8ed3f98ec20ef364605 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 09:09:41 -0700 Subject: [PATCH 02/10] feat(website): rebuild ContactForm on the kit with an enterprise intent Co-Authored-By: Claude Fable 5.1 --- .../components/contact/ContactForm.spec.tsx | 58 +++- .../src/components/contact/ContactForm.tsx | 260 +++++++++--------- 2 files changed, 179 insertions(+), 139 deletions(-) diff --git a/apps/website/src/components/contact/ContactForm.spec.tsx b/apps/website/src/components/contact/ContactForm.spec.tsx index a28ff9f36..fdaae97ad 100644 --- a/apps/website/src/components/contact/ContactForm.spec.tsx +++ b/apps/website/src/components/contact/ContactForm.spec.tsx @@ -30,7 +30,7 @@ function fill(fields: { company?: string; message?: string; }): void { - fireEvent.change(screen.getByLabelText(/email/i), { + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: fields.email }, }); if (fields.name !== undefined) { @@ -44,14 +44,14 @@ function fill(fields: { }); } if (fields.message !== undefined) { - fireEvent.change(screen.getByLabelText(/message/i), { + fireEvent.change(screen.getByLabelText(/what are you shipping/i), { target: { value: fields.message }, }); } } function send(): void { - fireEvent.click(screen.getByRole('button', { name: /^send$/i })); + fireEvent.click(screen.getByRole('button', { name: /send to brian/i })); } function sentBody(fetchMock: ReturnType, call: number) { @@ -134,7 +134,7 @@ describe('ContactForm growth policy', () => { render(); const disclosure = screen.getByText(formPolicy.disclosures.contact); - const button = screen.getByRole('button', { name: /^send$/i }); + const button = screen.getByRole('button', { name: /send to brian/i }); expect(disclosure.id).toBeTruthy(); expect(button.getAttribute('aria-describedby')).toBe(disclosure.id); @@ -241,6 +241,54 @@ describe('ContactForm growth policy', () => { await waitFor(() => expect(screen.getByRole('button', { name: /refresh page/i })).toBeTruthy() ); - expect(screen.queryByText(/we'll be in touch/i)).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('validates the email on blur, names the fix, and blocks submit until fixed', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render(); + const email = screen.getByLabelText(/work email/i); + fireEvent.change(email, { target: { value: 'jane@acme' } }); + fireEvent.blur(email); + expect(screen.getByText('Enter a full address, like jordan@acme.dev.')).toBeTruthy(); + send(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(email); + fireEvent.change(email, { target: { value: 'jane@acme.com' } }); + expect(screen.queryByText('Enter a full address, like jordan@acme.dev.')).toBeNull(); + }); + + it('in enterprise intent posts the pricing form kind with the timeline and entry point', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + render(); + + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'jane@acme.com' } }); + fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } }); + fireEvent.change(screen.getByLabelText(/timeline/i), { target: { value: 'this_quarter' } }); + fireEvent.change(screen.getByLabelText(/tell us about your use case/i), { target: { value: 'Volume seats.' } }); + fireEvent.click(screen.getByRole('button', { name: /request a conversation/i })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(sentBody(fetchMock, 0)).toMatchObject({ + form_kind: 'pricing', + email: 'jane@acme.com', + company: 'Acme', + timeline: 'this_quarter', + message: 'Volume seats.', + }); + expect(trackMock).toHaveBeenCalledWith( + 'marketing:lead_form_submit', + expect.objectContaining({ surface: 'pricing', entry_point: 'pricing_tier_enterprise' }) + ); + }); + + it('in enterprise intent requires a timeline and says so', () => { + vi.stubGlobal('fetch', vi.fn()); + render(); + fireEvent.change(screen.getByLabelText(/work email/i), { target: { value: 'jane@acme.com' } }); + fireEvent.click(screen.getByRole('button', { name: /request a conversation/i })); + expect(screen.getByText('Choose a timeline so we can route this.')).toBeTruthy(); }); }); diff --git a/apps/website/src/components/contact/ContactForm.tsx b/apps/website/src/components/contact/ContactForm.tsx index cb579f469..0eccb0b32 100644 --- a/apps/website/src/components/contact/ContactForm.tsx +++ b/apps/website/src/components/contact/ContactForm.tsx @@ -1,175 +1,167 @@ 'use client'; -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { Button } from '../ui/Button'; -import { track } from '../../lib/analytics/client'; import { analyticsEvents } from '../../lib/analytics/events'; import type { PublicFormPolicy } from '../../lib/growth/form-policy'; +import { FORM_POLICY_REFRESH_MESSAGE } from '../../lib/growth/form-client'; import { - FORM_POLICY_REFRESH_MESSAGE, - growthFormRequestSnapshot, - type GrowthFormRequestSnapshot, -} from '../../lib/growth/form-client'; + Field, + FormStatus, + Select, + SubmitButton, + TextArea, + TextInput, + emailError, + requiredError, + useGrowthForm, +} from '../form'; -type Status = 'idle' | 'sending' | 'sent' | 'error' | 'stale'; +export type ContactIntent = 'contact' | 'enterprise'; -export function ContactForm({ - formPolicy, -}: { +const TIMELINES = [ + ['this_quarter', 'This quarter'], + ['next_quarter', 'Next quarter'], + ['6_plus_months', '6+ months'], + ['exploring', 'Just exploring'], +] as const; + +type Timeline = (typeof TIMELINES)[number][0]; + +interface ContactFormProps { formPolicy: PublicFormPolicy; -}) { - const [status, setStatus] = useState('idle'); + /** `enterprise` adds the timeline field and posts the pricing form kind. */ + intent?: ContactIntent; + /** The CTA that brought the visitor here; reported to analytics. */ + entryPoint?: string; +} + +const FOUNDER_EMAIL = 'brian@threadplane.ai'; + +export function ContactForm({ formPolicy, intent = 'contact', entryPoint }: ContactFormProps) { + const enterprise = intent === 'enterprise'; const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [company, setCompany] = useState(''); + const [timeline, setTimeline] = useState(''); const [message, setMessage] = useState(''); - const submissionSnapshot = useRef(null); + const [emailMessage, setEmailMessage] = useState(null); + const [timelineMessage, setTimelineMessage] = useState(null); + + const form = useGrowthForm({ + route: '/api/leads', + formPolicy, + events: { + submit: analyticsEvents.marketingLeadFormSubmit, + success: analyticsEvents.marketingLeadFormSuccess, + fail: analyticsEvents.marketingLeadFormFail, + }, + analytics: { + surface: enterprise ? 'pricing' : 'contact', + source_section: 'contact-form', + ...(entryPoint ? { entry_point: entryPoint } : {}), + }, + }); const disclosureId = 'contact-form-growth-disclosure'; + const timelineError = 'Choose a timeline so we can route this.'; - async function handleSubmit(e: React.FormEvent) { + function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!email) return; - setStatus('sending'); - track(analyticsEvents.marketingLeadFormSubmit, { - surface: 'contact', - source_section: 'contact-form', - }); - try { - const snapshot = growthFormRequestSnapshot(submissionSnapshot.current, { - form_kind: 'contact', - email, - ...(name ? { name } : {}), - ...(company ? { company } : {}), - ...(message ? { message } : {}), - }); - submissionSnapshot.current = snapshot; - const res = await fetch('/api/leads', { - 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; - setStatus('stale'); - return; - } - if (res.status >= 400 && res.status < 500) { - submissionSnapshot.current = null; - } - if (res.ok) { - submissionSnapshot.current = null; - track(analyticsEvents.marketingLeadFormSuccess, { - surface: 'contact', - source_section: 'contact-form', - }); - setStatus('sent'); - } else { - track(analyticsEvents.marketingLeadFormFail, { - surface: 'contact', - source_section: 'contact-form', - error_reason: 'api_error', - }); - setStatus('error'); - } - } catch { - track(analyticsEvents.marketingLeadFormFail, { - surface: 'contact', - source_section: 'contact-form', - error_reason: 'network_error', - }); - setStatus('error'); + const emailProblem = emailError(email); + const timelineProblem = enterprise ? requiredError(timeline, timelineError) : null; + setEmailMessage(emailProblem); + setTimelineMessage(timelineProblem); + if (emailProblem) { + document.getElementById('contact-email')?.focus(); + return; + } + if (timelineProblem) { + document.getElementById('contact-timeline')?.focus(); + return; } + void form.submit({ + form_kind: enterprise ? 'pricing' : 'contact', + email: email.trim(), + ...(name ? { name } : {}), + ...(company ? { company } : {}), + ...(enterprise && timeline ? { timeline } : {}), + ...(message ? { message } : {}), + }); } - if (status === 'stale') { + if (form.status === 'stale') { return ( -
-

{FORM_POLICY_REFRESH_MESSAGE}

- -
+ ); } - if (status === 'sent') { - return ( -
- Thanks. We'll be in touch within one business day. -
- ); + if (form.status === 'sent') { + return ; } return ( -
- - - -