From fe04984909f06c59daabac7d9538e482309d9ad5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 14:03:27 -0700 Subject: [PATCH 01/16] docs(gtm): spec for positioning-and-risks (Spec 2) Replaces the homepage hero with the locked Direction-A copy, stands up the /contact enterprise CTA destination, applies four risk-cleanup copy changes (telemetry phrasing, compatibility matrix, A2UI v0.9, category sweep), and wires the two CTA tracks so the developer/enterprise funnel split becomes measurable in PostHog. One deviation from messaging.md's locked Contact-page field set: expands fields from "email + body only" to "email (required) + name, company, message (optional)" so Spec 1E's captureLeadQualified gate (which requires company) still works for contact-form submissions. messaging.md gets updated in the implementation PR. Co-Authored-By: Claude Opus 4.7 --- ...2026-05-16-positioning-and-risks-design.md | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 docs/superpowers/specs/gtm/2026-05-16-positioning-and-risks-design.md diff --git a/docs/superpowers/specs/gtm/2026-05-16-positioning-and-risks-design.md b/docs/superpowers/specs/gtm/2026-05-16-positioning-and-risks-design.md new file mode 100644 index 000000000..19c74102c --- /dev/null +++ b/docs/superpowers/specs/gtm/2026-05-16-positioning-and-risks-design.md @@ -0,0 +1,311 @@ +--- +workstream: positioning-and-risks +status: approved +owner: brian +phase: 1 +spec: docs/superpowers/specs/gtm/2026-05-16-positioning-and-risks-design.md +plan: docs/superpowers/plans/gtm/2026-05-16-positioning-and-risks.md +parent: docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md +--- + +# Spec 2 — Positioning & Risks (Design) + +> Replaces the homepage hero with the locked Direction-A copy, stands up the `/contact` enterprise CTA destination, applies four risk-cleanup copy changes, and wires the two CTA tracks so the developer/enterprise funnel split becomes measurable in PostHog. + +## 1. Goal + +Deliver Phase 1's developer-clarity foundation: + +1. Homepage hero communicates the durable category claim ("Agent UI for Angular") in 30 seconds with a working CTA fork (developer / enterprise). +2. `/contact` exists as the enterprise CTA destination per the locked Direction A.v2 spec. +3. The four risk-cleanup copy changes are deployed everywhere they appear: telemetry phrasing, real Angular compatibility matrix, A2UI v0.9, "Angular Agent Framework" → "Agent UI for Angular" category sweep. +4. Both CTA tracks emit `marketing:cta_click` with a stable `cta_id` so the developer-funnel and enterprise-funnel dashboards from Spec 1A populate. + +## 2. Context + +- Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md` §6 (Phase 1 critical path: 0 → 1 → 2 → 3 → 4). +- Locked content lives in `docs/gtm/messaging.md`: + - **Positioning statement** (durable). + - **Hero copy** (H1, subhead, primary CTA, secondary CTA, proof row, subline) — locked. + - **Contact page (Direction A.v2)** — locked. + - **Risk-cleanup copy changes (Spec 2)** — 4 items, locked. +- The current Hero in `apps/website/src/components/landing/Hero.tsx` reads "Build fullstack agentic Angular apps" — superseded by the locked H1. +- The `/contact` route does not exist. The existing `LeadForm` lives on `/pricing` and reuses the `/api/leads` route handler. +- Spec 1E shipped `captureLeadQualified` server-side. `/api/leads` already calls it after `captureLeadConversion`. Spec 2's contact form will POST to the same endpoint. +- The repo's "Chat" memory note (`feedback_chat_prefix_substring_overlap.md`) warns against blind `replace_all` on overlapping substrings; "Angular Agent Framework" is a longer unique phrase, so the same risk does not apply — but a per-file review remains the right discipline. + +### Deviation from messaging.md (call-out) + +`docs/gtm/messaging.md` Contact page §"Fields" locks: *"email + free-text body. No stack dropdown, no company size, no 'how did you hear.'"* This spec **expands** the field list to **email (required) + name, company, message (all optional)** so Spec 1E's `captureLeadQualified` gate (which requires `company` to fire) still works for contact-form submissions. The form remains a single short block — no progressive disclosure, no qualification dropdowns — but the optional fields, when filled, feed enterprise qualification. The messaging.md doc gets updated in this PR to reflect the new locked field set. + +## 3. Scope + +**In:** + +- **Hero rewrite** (`apps/website/src/components/landing/Hero.tsx`): + - Eyebrow: `Agent UI for Angular · MIT` (replaces `Angular Agent Framework · MIT`). + - H1: `Ship production agent UIs in Angular.` + - Subhead: `Signal-native chat, threads, interrupts, tool progress, and generative UI for LangGraph, AG-UI, and A2UI. MIT-licensed, self-hostable, app telemetry off by default, no React rewrite.` + - Primary CTA: button label `Install @ngaf/chat`; click copies `npm install @ngaf/chat` to clipboard and fires `marketing:cta_click` with `cta_id: 'hero_install'`, `track: 'developer'`, `surface: 'home'`. Brief visual confirmation on copy (existing `Pill`/toast pattern if available, otherwise a label flip for ~1.5s). + - Secondary CTA: button label `Talk to our engineers`; routes to `/contact?source=home_hero&track=enterprise` and fires `marketing:cta_click` with `cta_id: 'hero_talk_to_engineers'`, `track: 'enterprise'`, `surface: 'home'`. + - Proof row: `MIT · Angular-native Signals · LangGraph + AG-UI · A2UI-compatible · Self-hostable · App telemetry off by default`. + - Subline under proof row: `Not another backend agent runtime. Keep LangGraph, Genkit, Mastra, CrewAI, or your own service. Cacheplane solves the Angular UI layer.` + +- **New `/contact` route** (`apps/website/src/app/contact/page.tsx`): + - Server component. Page metadata (title, OG, twitter). + - Headline: `Talk to an engineer.` + - Subhead: `Tell us what you're shipping. We'll reply within one business day — usually with code, not a calendar invite.` + - SLA card: `Brian or someone on the team replies personally — from a real inbox, not noreply@. We read every message.` + - `` (new client component): email (required) + name (optional) + company (optional) + message (optional, textarea) + hidden attribution fields populated from URL params + `document.referrer`. POSTs to `/api/leads`. + - `` (new server component): fetches `https://api.github.com/repos/cacheplane/angular-agent-framework` once per build / 24h ISR; renders a pill with the star count + link. Graceful fallback to a "GitHub" pill (no count) on fetch failure. + - Alt-channel row below form: `docs · GitHub issues · Discord` (three links). + - On successful submit: inline success message; no redirect. + +- **Loosen `/api/leads` validation** (`apps/website/src/app/api/leads/route.ts`): + - Require email only. `name`, `company`, `message` all optional. + - Existing `LeadForm` on `/pricing` continues to send `name`; new contact form sends what users fill in. + - Resend notification body adapts: when `name` absent, subject becomes `New lead: ` instead of `New lead: at `. Body still shows whichever fields are present. + +- **Four risk-cleanup copy changes (full-repo sweep):** + - **Telemetry phrasing.** Sweep `apps/website` + `gtm.md` + `libs/*/README.md` for "No telemetry" (and close variants) → `App telemetry off by default` with a link to `libs/telemetry/README.md`. Per-file review, no blind replace. + - **Compatibility matrix.** Replace the "All Angular versions" claim on `/pricing` with a new `` component: + - Supported: Angular 20, 21 (matches `peerDependencies: "^20.0.0 || ^21.0.0"`) + - Experimental: (none — empty row labeled "—") + - Planned: Angular 22 (next major) + - Unsupported: Angular ≤19 + - **A2UI v0.9 sweep.** "A2UI v1" → "A2UI v0.9-compatible" everywhere across the full repo. Includes website copy, MDX docs, READMEs, `gtm.md`, package descriptions. + - **Category sweep.** "Angular Agent Framework" → "Agent UI for Angular" across the full repo. Excludes CHANGELOG.md and release-note files (historical record). Excludes class/identifier names (none exist). Per-file review. + +- **`marketing:cta_click` `cta_id` typing:** export a `CtaId` string union from `apps/website/src/lib/analytics/events.ts` and tighten `AnalyticsProperties.cta_id` to that union. Initial members: `'hero_install' | 'hero_talk_to_engineers'`. Future CTAs extend this list. + +- **`docs/gtm/messaging.md` update:** the Contact page §"Fields" line gets updated from "email + free-text body" to "email (required) + name, company, message (all optional)". The locked status remains; the field set is the only revision. + +**Out:** + +- Comparison pages (Spec 3). +- Cockpit activation recipes (Spec 4). +- New events. The `cta_id` property does the slicing on the existing `marketing:cta_click`. +- Adding a hero CTA tracking insight to PostHog. The dashboards-as-code pipeline (Spec 1A) handles tile-level work; this spec only ensures the underlying event property lands cleanly. +- Replacing or styling other landing sections (proof rows below hero, differentiator section, etc.). Codex PR #352 polished those; we keep them. +- Repo rename or README badge updates beyond the category-sweep text replacements already enumerated. + +## 4. Components + +### 4.1 Hero (`apps/website/src/components/landing/Hero.tsx`) + +Replace H1, subhead, eyebrow, both buttons, proof row, and add the subline. Layout, grid, and right-column collage from PR #352 stay intact. + +```tsx +'use client'; +import { useCallback, useState } from 'react'; +import { track } from '../../lib/analytics/client'; +import { analyticsEvents } from '../../lib/analytics/events'; +// ...existing imports + +function PrimaryInstallButton() { + const [copied, setCopied] = useState(false); + const onClick = useCallback(async () => { + track(analyticsEvents.marketingCtaClick, { + cta_id: 'hero_install', + track: 'developer', + surface: 'home', + }); + try { + await navigator.clipboard?.writeText('npm install @ngaf/chat'); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // silent fail — focus stays on event firing + } + }, []); + return ( + + ); +} +``` + +Secondary CTA is a plain ` + ); +} + +function SecondaryTalkButton() { + const onClick = useCallback(() => { + track(analyticsEvents.marketingCtaClick, { + cta_id: 'hero_talk_to_engineers', + track: 'enterprise', + surface: 'home', + }); + }, []); + + return ( + + ); +} + +export function Hero() { + return ( +
+ +
+ {/* Left column */} +
+ + Agent UI for Angular · MIT + +

+ Ship production agent UIs in Angular. +

+

+ Signal-native chat, threads, interrupts, tool progress, and generative UI for LangGraph, AG-UI, and A2UI. MIT-licensed, self-hostable, app telemetry off by default, no React rewrite. +

+
+ + +
+
+ MIT + Angular-native Signals + LangGraph + AG-UI + A2UI-compatible + Self-hostable + App telemetry off by default +
+

+ Not another backend agent runtime. Keep LangGraph, Genkit, Mastra, CrewAI, or your own service. Cacheplane solves the Angular UI layer. +

+
+ + {/* Right column — KEEP existing layered collage exactly as-is. Re-paste from + the prior version of this file (BrowserFrame x 2 with screenshots + code). */} + +
+
+
+ ); +} +``` + +**Important:** the right-column collage in the prior `Hero.tsx` is 70+ lines of `` markup. Preserve it verbatim. Don't re-author it. + +- [ ] **Step 2: Run the spec from Task 1.2 — see pass** + +```bash +cd apps/website && npx vitest run src/components/landing/Hero.spec.tsx +``` + +Expected: 3 tests passing. + +- [ ] **Step 3: Run the full landing test suite + build** + +```bash +cd apps/website && npx vitest run src/components/landing +npx nx run website:build +``` + +Expected: green. Pre-existing failures unrelated to Hero (e2e under wrong runner, open-in-cockpit JSX) may persist — not in scope. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/components/landing/Hero.tsx apps/website/src/components/landing/Hero.spec.tsx +git commit -m "$(cat <<'EOF' +feat(website): hero rewrite per Spec 2 — Direction A copy + tracked CTAs + +- H1: "Ship production agent UIs in Angular." +- Subhead per docs/gtm/messaging.md +- Primary CTA: copies "npm install @ngaf/chat", fires cta_id=hero_install + (track=developer) +- Secondary CTA: links to /contact?source=home_hero&track=enterprise, + fires cta_id=hero_talk_to_engineers (track=enterprise) +- Eyebrow updated to "Agent UI for Angular · MIT" +- Proof pills + subline match the locked copy + +Three tests cover both CTAs (event payload + clipboard write + link href). + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 2 — `/contact` route + supporting components + +### Task 2.1: `getGitHubStars` helper + spec + +**Files:** `apps/website/src/lib/github.ts` (NEW), `apps/website/src/lib/github.spec.ts` (NEW) + +- [ ] **Step 1: Write the failing test** + +Create `apps/website/src/lib/github.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { getGitHubStars } from './github'; + +const fetchMock = vi.hoisted(() => vi.fn()); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('getGitHubStars', () => { + it('returns the stargazers_count on 2xx', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ stargazers_count: 1234 }), + }); + const stars = await getGitHubStars('cacheplane/angular-agent-framework'); + expect(stars).toBe(1234); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/cacheplane/angular-agent-framework', + expect.objectContaining({ + next: { revalidate: 86400 }, + }), + ); + }); + + it('returns null on non-2xx', async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }); + expect(await getGitHubStars('owner/repo')).toBeNull(); + }); + + it('returns null when fetch throws', async () => { + fetchMock.mockRejectedValue(new Error('network')); + expect(await getGitHubStars('owner/repo')).toBeNull(); + }); + + it('returns null when payload lacks stargazers_count', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }); + expect(await getGitHubStars('owner/repo')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/website && npx vitest run src/lib/github.spec.ts +``` + +Expected: fails (`Cannot find module './github'`). + +- [ ] **Step 3: Implement** + +Create `apps/website/src/lib/github.ts`: + +```typescript +// SPDX-License-Identifier: MIT +export async function getGitHubStars( + repo = 'cacheplane/angular-agent-framework', +): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${repo}`, { + next: { revalidate: 86400 }, + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { stargazers_count?: number }; + return typeof data.stargazers_count === 'number' ? data.stargazers_count : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run, see pass** + +```bash +cd apps/website && npx vitest run src/lib/github.spec.ts +``` + +Expected: 4 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add apps/website/src/lib/github.ts apps/website/src/lib/github.spec.ts +git commit -m "$(cat <<'EOF' +feat(website): getGitHubStars helper with ISR + silent-fail fallback + +Server-only helper that fetches repo stargazer count via Next.js fetch +with 24h revalidate. Returns null on any failure (non-2xx, network +error, missing field) so callers can render a graceful fallback. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +### Task 2.2: `GitHubStarsPill` component + spec + +**Files:** `apps/website/src/components/contact/GitHubStarsPill.tsx` (NEW), `apps/website/src/components/contact/GitHubStarsPill.spec.tsx` (NEW) + +- [ ] **Step 1: Write the failing test** + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('../../lib/github', () => ({ + getGitHubStars: vi.fn(), +})); + +import { getGitHubStars } from '../../lib/github'; +import { GitHubStarsPill } from './GitHubStarsPill'; + +describe('GitHubStarsPill', () => { + it('renders with star count when fetch succeeds', async () => { + (getGitHubStars as ReturnType).mockResolvedValue(1234); + const el = await GitHubStarsPill(); + const { container } = render(el); + expect(container.textContent).toMatch(/1,234/); + expect(container.querySelector('a')?.getAttribute('href')) + .toBe('https://github.com/cacheplane/angular-agent-framework'); + }); + + it('renders fallback when fetch returns null', async () => { + (getGitHubStars as ReturnType).mockResolvedValue(null); + const el = await GitHubStarsPill(); + const { container } = render(el); + expect(container.textContent).toMatch(/GitHub/); + expect(container.textContent).not.toMatch(/\d/); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/website && npx vitest run src/components/contact/GitHubStarsPill.spec.tsx +``` + +- [ ] **Step 3: Implement** + +```typescript +// SPDX-License-Identifier: MIT +import { Pill } from '../ui/Pill'; +import { getGitHubStars } from '../../lib/github'; + +const REPO = 'cacheplane/angular-agent-framework'; +const REPO_URL = `https://github.com/${REPO}`; + +export async function GitHubStarsPill() { + const stars = await getGitHubStars(REPO); + const label = stars != null ? `★ ${stars.toLocaleString()} on GitHub` : 'GitHub'; + return ( + + {label} + + ); +} +``` + +- [ ] **Step 4: Run, see pass + commit** + +```bash +cd apps/website && npx vitest run src/components/contact/GitHubStarsPill.spec.tsx +``` + +Expected: 2 tests passing. + +```bash +git add apps/website/src/components/contact/GitHubStarsPill.tsx apps/website/src/components/contact/GitHubStarsPill.spec.tsx +git commit -m "$(cat <<'EOF' +feat(website): GitHubStarsPill server component with ISR-backed star count + +Renders "★ on GitHub" when the fetch succeeds; falls back to a +plain "GitHub" pill on null. Links to the repo regardless. ISR via +the getGitHubStars helper means at most one fetch per 24h. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +### Task 2.3: `ContactForm` component + spec + +**Files:** `apps/website/src/components/contact/ContactForm.tsx` (NEW), `apps/website/src/components/contact/ContactForm.spec.tsx` (NEW) + +- [ ] **Step 1: Write the failing test** + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const trackMock = vi.hoisted(() => vi.fn()); +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../lib/analytics/client', () => ({ track: trackMock })); + +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams('?source=home_hero&track=enterprise'), +})); + +beforeEach(() => { + trackMock.mockClear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + Object.defineProperty(document, 'referrer', { + value: 'https://cacheplane.ai/pricing', + configurable: true, + }); +}); + +describe('ContactForm', () => { + it('submits with email only and fires lead_form_submit + lead_form_success', async () => { + fetchMock.mockResolvedValue({ ok: true }); + const { ContactForm } = await import('./ContactForm'); + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'jane@acme.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: /send/i })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.email).toBe('jane@acme.com'); + expect(body.source_page).toBe('home_hero'); + expect(body.track).toBe('enterprise'); + expect(body.referrer_host).toBe('cacheplane.ai'); + + expect(trackMock).toHaveBeenCalledWith( + 'marketing:lead_form_submit', + expect.objectContaining({ surface: 'contact' }), + ); + expect(trackMock).toHaveBeenCalledWith( + 'marketing:lead_form_success', + expect.objectContaining({ surface: 'contact' }), + ); + }); + + it('submits with all optional fields populated', async () => { + fetchMock.mockResolvedValue({ ok: true }); + const { ContactForm } = await import('./ContactForm'); + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Jane Smith' } }); + fireEvent.change(screen.getByLabelText(/company/i), { target: { value: 'Acme' } }); + fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hi' } }); + fireEvent.click(screen.getByRole('button', { name: /send/i })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toMatchObject({ + email: 'jane@acme.com', + name: 'Jane Smith', + company: 'Acme', + message: 'Hi', + }); + }); + + it('fires lead_form_fail on non-2xx', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500 }); + const { ContactForm } = await import('./ContactForm'); + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'jane@acme.com' } }); + fireEvent.click(screen.getByRole('button', { name: /send/i })); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + 'marketing:lead_form_fail', + expect.objectContaining({ surface: 'contact' }), + ), + ); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/website && npx vitest run src/components/contact/ContactForm.spec.tsx +``` + +- [ ] **Step 3: Implement** + +```typescript +// SPDX-License-Identifier: MIT +'use client'; + +import { useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { tokens } from '@ngaf/design-tokens'; +import { Button } from '../ui/Button'; +import { track } from '../../lib/analytics/client'; +import { analyticsEvents } from '../../lib/analytics/events'; + +type Status = 'idle' | 'sending' | 'sent' | 'error'; + +function sanitizeReferrerHost(): string | undefined { + if (typeof document === 'undefined' || !document.referrer) return undefined; + try { + return new URL(document.referrer).hostname; + } catch { + return undefined; + } +} + +export function ContactForm() { + const params = useSearchParams(); + const [status, setStatus] = useState('idle'); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [company, setCompany] = useState(''); + const [message, setMessage] = useState(''); + + const sourcePage = params.get('source') ?? 'contact_direct'; + const trackParam = (params.get('track') ?? 'enterprise') as string; + const ctaId = params.get('cta_id') ?? undefined; + const paper = params.get('paper') ?? undefined; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!email) return; + setStatus('sending'); + track(analyticsEvents.marketingLeadFormSubmit, { + surface: 'contact', + source_section: 'contact-form', + }); + try { + const res = await fetch('/api/leads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + name: name || undefined, + company: company || undefined, + message: message || undefined, + source_page: sourcePage, + track: trackParam, + cta_id: ctaId, + paper, + referrer_host: sanitizeReferrerHost(), + }), + }); + if (res.ok) { + 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'); + } + } + + if (status === 'sent') { + return ( +
+ Thanks. We'll be in touch within one business day. +
+ ); + } + + const inputStyle: React.CSSProperties = { + display: 'block', + width: '100%', + padding: '10px 12px', + fontSize: tokens.typography.body.size, + fontFamily: tokens.typography.body.family, + color: tokens.colors.textPrimary, + background: tokens.surfaces.surface, + border: `1px solid ${tokens.surfaces.border}`, + borderRadius: 6, + marginTop: 4, + }; + + return ( +
+ + + +