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
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=
4 changes: 2 additions & 2 deletions apps/website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hello@cacheplane.io>"
RESEND_NOTIFY_TO=hello@cacheplane.io
RESEND_FROM="Agent UI for Angular <hello@cacheplane.ai>"
RESEND_NOTIFY_TO=hello@cacheplane.ai

# Loops.so (https://loops.so — free tier: 1,000 contacts)
LOOPS_API_KEY=
96 changes: 96 additions & 0 deletions apps/website/e2e/website.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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();
Expand Down
Binary file modified apps/website/public/whitepaper.pdf
Binary file not shown.
Binary file modified apps/website/public/whitepapers/angular.pdf
Binary file not shown.
Binary file modified apps/website/public/whitepapers/chat.pdf
Binary file not shown.
Binary file modified apps/website/public/whitepapers/render.pdf
Binary file not shown.
5 changes: 2 additions & 3 deletions apps/website/scripts/refresh-whitepaper-covers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
192 changes: 192 additions & 0 deletions apps/website/src/app/api/leads/route.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <hello@cacheplane.ai>',
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 <hello@cacheplane.ai>',
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 <hello@cacheplane.ai>',
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 <hello@cacheplane.ai>',
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',
});
});
});
12 changes: 9 additions & 3 deletions apps/website/src/app/api/leads/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() : '';

Expand All @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions apps/website/src/components/landing/WhitePaperBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion scripts/examples-middleware.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading