diff --git a/.env.example b/.env.example index 16e79bcf..e988e844 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,9 @@ POSTHOG_HOST=https://us.i.posthog.com POSTHOG_PROJECT_ID= # @ngaf/telemetry (libs/telemetry) -# Default ingest URL points to the Cacheplane website reverse proxy. Self-hosters +# Default ingest URL points to the ThreadPlane website reverse proxy. Self-hosters # can redirect to their own ingest. See libs/telemetry/README.md. -# NGAF_TELEMETRY_INGEST_URL=https://cacheplane.ai/api/ingest +# NGAF_TELEMETRY_INGEST_URL=https://threadplane.ai/api/ingest # NGAF_TELEMETRY_SAMPLE_RATE=1.0 # DO_NOT_TRACK=1 # cross-vendor opt-out # NGAF_TELEMETRY_DISABLED=1 # package-specific opt-out @@ -19,11 +19,11 @@ NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN= NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL=false # Cockpit iframe → cockpit-shell /ingest proxy (Spec 1D). -# Production: full absolute URL (e.g. https://cockpit.cacheplane.ai/ingest). +# Production: full absolute URL (e.g. https://cockpit.threadplane.ai/ingest). # Leave empty in dev to let RunMode derive it from window.location.origin. NEXT_PUBLIC_COCKPIT_INGEST_HOST= # CORS origin allowed to POST to cockpit's /ingest from iframes (Spec 1D). -# Production: https://examples.cacheplane.ai +# Production: https://examples.threadplane.ai # Leave empty in dev — wildcard '*' is used. NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN= diff --git a/apps/website/.env.example b/apps/website/.env.example index 55153d89..ce2ca8ae 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -15,8 +15,8 @@ ANTHROPIC_MODEL=claude-sonnet-4-6 # Resend (https://resend.com — free tier: 3,000 emails/month) RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx RESEND_AUDIENCE_ID=aud_xxxxxxxxxxxxxxxxxxxxxxxxxxxx -RESEND_FROM="Angular Stream Resource " -RESEND_NOTIFY_TO=hello@cacheplane.io +RESEND_FROM="Agent UI for Angular " +RESEND_NOTIFY_TO=hello@cacheplane.ai # Loops.so (https://loops.so — free tier: 1,000 contacts) LOOPS_API_KEY= diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index d99e768e..8e4bb59d 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -33,6 +33,102 @@ test('pricing page lead form validates required fields', async ({ page }) => { await expect(page.locator('form').first()).toBeVisible(); }); +test('contact page submits a lead payload and renders success state', async ({ page }) => { + let leadPayload: Record | undefined; + await page.route('**/api/leads', async (route) => { + leadPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto('/contact?source=e2e_contact&track=enterprise'); + const contactForm = page.locator('main form').first(); + await contactForm.getByRole('textbox', { name: 'Email', exact: true }).fill('jane@acme.com'); + await contactForm.getByRole('textbox', { name: 'Name' }).fill('Jane Smith'); + await contactForm.getByRole('textbox', { name: 'Company' }).fill('Acme'); + await contactForm.getByRole('textbox', { name: 'Message' }).fill('We are evaluating Agent UI for Angular.'); + await contactForm.getByRole('button', { name: 'Send' }).click(); + + await expect(page.getByText("Thanks. We'll be in touch within one business day.")).toBeVisible(); + expect(leadPayload).toMatchObject({ + email: 'jane@acme.com', + name: 'Jane Smith', + company: 'Acme', + message: 'We are evaluating Agent UI for Angular.', + source_page: 'e2e_contact', + track: 'enterprise', + }); +}); + +test('pricing lead form posts to /api/leads and renders success state', async ({ page }) => { + let leadPayload: Record | undefined; + await page.route('**/api/leads', async (route) => { + leadPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto('/pricing#lead-form'); + await page.getByLabel('Name').fill('Jane Smith'); + await page.getByLabel('Work email').fill('jane@acme.com'); + await page.getByLabel('Company').fill('Acme'); + await page.getByLabel('Tell us about your use case').fill('Volume seats and security review.'); + await page.getByRole('button', { name: 'Get in touch' }).click(); + + await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible(); + expect(leadPayload).toMatchObject({ + email: 'jane@acme.com', + name: 'Jane Smith', + company: 'Acme', + message: 'Volume seats and security review.', + }); +}); + +test('footer newsletter form posts to /api/newsletter and renders success state', async ({ page }) => { + let payload: Record | undefined; + await page.route('**/api/newsletter', async (route) => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto('/'); + const footer = page.locator('footer'); + await footer.getByLabel('Email address').fill('reader@acme.com'); + await footer.getByRole('button', { name: 'Subscribe' }).click(); + + await expect(page.getByText("✓ You're subscribed!")).toBeVisible(); + expect(payload).toEqual({ email: 'reader@acme.com' }); +}); + +test('whitepaper signup form posts to /api/whitepaper-signup and renders success state', async ({ page }) => { + let payload: Record | undefined; + await page.route('**/api/whitepaper-signup', async (route) => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + 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 expect(page.getByText(/check your inbox/i)).toBeVisible(); + expect(payload).toEqual({ email: 'reader@acme.com', paper: 'chat' }); +}); + test('docs page renders sidebar and content', async ({ page }) => { await page.goto('/docs/agent/getting-started/introduction'); await expect(page.locator('aside').first()).toBeVisible(); diff --git a/apps/website/public/whitepaper.pdf b/apps/website/public/whitepaper.pdf index 1295bdff..e16c53e5 100644 Binary files a/apps/website/public/whitepaper.pdf and b/apps/website/public/whitepaper.pdf differ diff --git a/apps/website/public/whitepapers/angular.pdf b/apps/website/public/whitepapers/angular.pdf index a439dc90..3a252030 100644 Binary files a/apps/website/public/whitepapers/angular.pdf and b/apps/website/public/whitepapers/angular.pdf differ diff --git a/apps/website/public/whitepapers/chat.pdf b/apps/website/public/whitepapers/chat.pdf index 7edb5528..a5c19f98 100644 Binary files a/apps/website/public/whitepapers/chat.pdf and b/apps/website/public/whitepapers/chat.pdf differ diff --git a/apps/website/public/whitepapers/render.pdf b/apps/website/public/whitepapers/render.pdf index 3559958d..8cd4aded 100644 Binary files a/apps/website/public/whitepapers/render.pdf and b/apps/website/public/whitepapers/render.pdf differ diff --git a/apps/website/scripts/refresh-whitepaper-covers.ts b/apps/website/scripts/refresh-whitepaper-covers.ts index 4abbccf4..8e6d9211 100644 --- a/apps/website/scripts/refresh-whitepaper-covers.ts +++ b/apps/website/scripts/refresh-whitepaper-covers.ts @@ -61,10 +61,9 @@ const LEGACY_GRADIENT_RE = function refreshHtml(html: string, paper: Paper): string { let out = html; - if (!LEGACY_GRADIENT_RE.test(out)) { - throw new Error(`No legacy gradient found in ${paper.htmlPath}`); + if (LEGACY_GRADIENT_RE.test(out)) { + out = out.replace(LEGACY_GRADIENT_RE, `background:${paper.newGradient}`); } - out = out.replace(LEGACY_GRADIENT_RE, `background:${paper.newGradient}`); // Cover footer: #888 → #8b8fa3 (textMuted) out = out.replace(/font-size:13px;color:#888/g, 'font-size:13px;color:#8b8fa3'); diff --git a/apps/website/src/app/api/leads/route.spec.ts b/apps/website/src/app/api/leads/route.spec.ts new file mode 100644 index 00000000..d011fe78 --- /dev/null +++ b/apps/website/src/app/api/leads/route.spec.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendEmailMock = vi.hoisted(() => vi.fn()); +const addToAudienceMock = vi.hoisted(() => vi.fn()); +const loopsUpsertContactMock = vi.hoisted(() => vi.fn()); +const loopsSendEventMock = vi.hoisted(() => vi.fn()); +const scheduleWhitepaperDripMock = vi.hoisted(() => vi.fn()); +const captureLeadConversionMock = vi.hoisted(() => vi.fn()); +const captureLeadQualifiedMock = vi.hoisted(() => vi.fn()); +const captureNewsletterConversionMock = vi.hoisted(() => vi.fn()); +const captureWhitepaperConversionMock = vi.hoisted(() => vi.fn()); +const mkdirSyncMock = vi.hoisted(() => vi.fn()); +const appendFileSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock('fs', () => ({ + default: { + mkdirSync: mkdirSyncMock, + appendFileSync: appendFileSyncMock, + }, +})); + +vi.mock('../../../../lib/resend', () => ({ + FROM: 'Agent UI for Angular ', + NOTIFY_TO: 'hello@cacheplane.ai', + sendEmail: sendEmailMock, + addToAudience: addToAudienceMock, +})); + +vi.mock('../../../../lib/loops', () => ({ + loopsUpsertContact: loopsUpsertContactMock, + loopsSendEvent: loopsSendEventMock, +})); + +vi.mock('../../../../lib/drip', () => ({ + scheduleWhitepaperDrip: scheduleWhitepaperDripMock, +})); + +vi.mock('../../../lib/analytics/server', () => ({ + captureLeadConversion: captureLeadConversionMock, + captureLeadQualified: captureLeadQualifiedMock, + captureNewsletterConversion: captureNewsletterConversionMock, + captureWhitepaperConversion: captureWhitepaperConversionMock, +})); + +import { POST as postLead } from './route'; +import { POST as postNewsletter } from '../newsletter/route'; +import { POST as postWhitepaperSignup } from '../whitepaper-signup/route'; + +function jsonRequest(path: string, body: unknown): Request { + return new Request(`https://threadplane.ai${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + referer: 'https://threadplane.ai/pricing', + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + sendEmailMock.mockResolvedValue(undefined); + addToAudienceMock.mockResolvedValue(undefined); + loopsUpsertContactMock.mockResolvedValue(undefined); + loopsSendEventMock.mockResolvedValue(undefined); + scheduleWhitepaperDripMock.mockResolvedValue(undefined); + captureLeadConversionMock.mockResolvedValue(undefined); + captureLeadQualifiedMock.mockResolvedValue(undefined); + captureNewsletterConversionMock.mockResolvedValue(undefined); + captureWhitepaperConversionMock.mockResolvedValue(undefined); +}); + +describe('/api/leads', () => { + it('persists the lead, notifies the team, syncs audience systems, and records analytics', async () => { + const response = await postLead(jsonRequest('/api/leads', { + name: 'Jane Smith', + email: 'jane@acme.com', + company: 'Acme', + message: 'We are evaluating Agent UI for Angular.', + }) as never); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(appendFileSyncMock).toHaveBeenCalledWith( + expect.stringContaining('data/leads.ndjson'), + expect.stringContaining('"email":"jane@acme.com"'), + 'utf8', + ); + expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ + from: 'Agent UI for Angular ', + to: 'hello@cacheplane.ai', + subject: 'New lead: Jane Smith at Acme', + html: expect.stringContaining('jane@acme.com'), + })); + expect(addToAudienceMock).toHaveBeenCalledWith('jane@acme.com', 'Jane Smith'); + expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'jane@acme.com', + firstName: 'Jane Smith', + source: 'lead-form', + properties: { company: 'Acme' }, + })); + expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'jane@acme.com', + eventName: 'lead_submitted', + })); + expect(captureLeadConversionMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'jane@acme.com', + company: 'Acme', + sourcePage: '/pricing', + })); + expect(captureLeadQualifiedMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'jane@acme.com', + company: 'Acme', + sourcePage: '/pricing', + })); + }); + + it('rejects malformed lead emails before sending or persisting anything', async () => { + const response = await postLead(jsonRequest('/api/leads', { email: 'not-an-email' }) as never); + + expect(response.status).toBe(400); + expect(sendEmailMock).not.toHaveBeenCalled(); + expect(addToAudienceMock).not.toHaveBeenCalled(); + expect(appendFileSyncMock).not.toHaveBeenCalled(); + }); +}); + +describe('/api/newsletter', () => { + it('sends the welcome email, adds the contact to Resend, and records analytics', async () => { + const response = await postNewsletter(jsonRequest('/api/newsletter', { email: 'reader@acme.com' }) as never); + + expect(response.status).toBe(200); + expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ + from: 'Agent UI for Angular ', + to: 'reader@acme.com', + subject: 'Welcome to Agent UI for Angular updates', + })); + expect(addToAudienceMock).toHaveBeenCalledWith('reader@acme.com'); + expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'reader@acme.com', + source: 'newsletter', + })); + expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'reader@acme.com', + eventName: 'newsletter_subscribed', + })); + expect(captureNewsletterConversionMock).toHaveBeenCalledWith({ + email: 'reader@acme.com', + sourcePage: '/pricing', + }); + }); +}); + +describe('/api/whitepaper-signup', () => { + it('sends the requested download, schedules drip, syncs the audience, and records analytics', async () => { + const response = await postWhitepaperSignup(jsonRequest('/api/whitepaper-signup', { + name: 'Reader', + email: 'reader@acme.com', + paper: 'chat', + }) as never); + + expect(response.status).toBe(200); + expect(appendFileSyncMock).toHaveBeenCalledWith( + expect.stringContaining('data/whitepaper-signups.ndjson'), + expect.stringContaining('"paper":"chat"'), + 'utf8', + ); + expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({ + from: 'Agent UI for Angular ', + to: 'reader@acme.com', + subject: 'Your Enterprise Guide to Agent Chat Interfaces', + html: expect.stringContaining('https://threadplane.ai/whitepapers/chat.pdf'), + })); + expect(scheduleWhitepaperDripMock).toHaveBeenCalledWith('reader@acme.com', 'chat'); + expect(addToAudienceMock).toHaveBeenCalledWith('reader@acme.com', 'Reader'); + expect(loopsUpsertContactMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'reader@acme.com', + firstName: 'Reader', + source: 'whitepaper-chat', + })); + expect(loopsSendEventMock).toHaveBeenCalledWith(expect.objectContaining({ + email: 'reader@acme.com', + eventName: 'whitepaper_downloaded', + properties: { paper: 'chat' }, + })); + expect(captureWhitepaperConversionMock).toHaveBeenCalledWith({ + email: 'reader@acme.com', + paper: 'chat', + sourcePage: '/pricing', + }); + }); +}); diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index 27a90559..f8cd7dd1 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -10,7 +10,13 @@ import { getSourcePage } from '@ngaf/telemetry/shared'; const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson'); export async function POST(req: NextRequest) { - const body = await req.json() as { name?: unknown; email?: unknown; company?: unknown; message?: unknown }; + let body: { name?: unknown; email?: unknown; company?: unknown; message?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const sanitize = (v: unknown, max = 500): string => typeof v === 'string' ? v.slice(0, max).trim() : ''; @@ -19,8 +25,8 @@ export async function POST(req: NextRequest) { const company = sanitize(body.company, 200); const message = sanitize(body.message, 2000); - if (!email) { - return NextResponse.json({ error: 'email required' }, { status: 400 }); + if (!email || !email.includes('@')) { + return NextResponse.json({ error: 'Valid email required' }, { status: 400 }); } const ts = new Date().toISOString(); diff --git a/apps/website/src/components/landing/WhitePaperBlock.tsx b/apps/website/src/components/landing/WhitePaperBlock.tsx index 991d95ba..c5dae093 100644 --- a/apps/website/src/components/landing/WhitePaperBlock.tsx +++ b/apps/website/src/components/landing/WhitePaperBlock.tsx @@ -44,11 +44,12 @@ export function WhitePaperBlock({ paper = 'overview' }: WhitePaperBlockProps = { paper, }); try { - await fetch('/api/whitepaper-signup', { + const res = await fetch('/api/whitepaper-signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, paper }), }); + if (!res.ok) throw new Error('whitepaper_signup_failed'); track(analyticsEvents.marketingWhitepaperSignupSuccess, { surface: 'home_whitepaper', source_section: 'whitepaper-block', diff --git a/scripts/examples-middleware.ts b/scripts/examples-middleware.ts index 0cc5d638..b2ef4444 100644 --- a/scripts/examples-middleware.ts +++ b/scripts/examples-middleware.ts @@ -1,5 +1,5 @@ /** - * Vercel Serverless Function proxy for the cockpit-examples deployment. + * Vercel Serverless Function proxy for the threadplane-examples deployment. * * Thin wrapper around scripts/langgraph-proxy.ts that adds the * examples-specific Referer-based backend resolution. Today there's diff --git a/scripts/langgraph-proxy.ts b/scripts/langgraph-proxy.ts index d8f139a7..4b387079 100644 --- a/scripts/langgraph-proxy.ts +++ b/scripts/langgraph-proxy.ts @@ -6,7 +6,7 @@ * `LANGSMITH_API_KEY`, streams SSE responses chunk-by-chunk, and * forwards all other content types verbatim. * - * Shared between `scripts/examples-middleware.ts` (cockpit-examples + * Shared between `scripts/examples-middleware.ts` (threadplane-examples * deployment) and `scripts/demo-middleware.ts` (canonical demo * deployment). Per-deployment specifics — like the examples' * Referer-based backend routing — are passed in via `ProxyConfig`. @@ -45,7 +45,7 @@ export interface ProxyConfig { * it unset. */ readonly checkRateLimit?: (ip: string) => Promise<{ allowed: boolean; retryAfterSec: number; count: number }>; /** Origins to allow via CORS. If undefined, legacy wildcard `*` behavior - * preserved (used by cockpit-examples). Each entry is a full origin + * preserved (used by threadplane-examples). Each entry is a full origin * string, e.g. `https://demo.threadplane.ai`. Match is exact-string. */ readonly allowedOrigins?: readonly string[]; /** Maximum request body size in bytes. If undefined, no cap (legacy @@ -86,7 +86,7 @@ export function createProxyHandler(config: ProxyConfig = {}): (req: VercelReques return async function handler(req, res) { // CORS — echo matching Origin when allowedOrigins is configured; - // otherwise legacy * behavior preserved for cockpit-examples. + // otherwise legacy * behavior preserved for threadplane-examples. res.setHeader('access-control-allow-methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('access-control-allow-headers', 'content-type, x-api-key, authorization');