Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ba89f13
feat(chat): ship-readiness polish — Tailwind, auto-scroll, markdown, …
blove Apr 6, 2026
2dccb0a
Rebrand to Angular Stream Resource (#28)
blove Apr 6, 2026
65f13df
feat(website): add narrative sections, pilot-to-prod page, and rebran…
blove Apr 6, 2026
1c9e7b6
fix(website): replace unsourced stats with verified Gartner and Stack…
blove Apr 6, 2026
16d37f7
merge: resolve conflicts with main
blove Apr 6, 2026
40a967b
docs: add website audit and lead generation specs
blove Apr 6, 2026
5e52aeb
docs: add implementation plans for lead gen and website audit
blove Apr 6, 2026
b8e8ba2
chore: install resend and react-email dependencies
blove Apr 6, 2026
e53d86d
feat: add shared resend module with audience helper
blove Apr 6, 2026
fd13886
feat: add lead notification email template
blove Apr 6, 2026
7f16a19
feat: add whitepaper download email template
blove Apr 6, 2026
e41dd9f
feat: add newsletter welcome email template
blove Apr 6, 2026
c948d1a
feat: wire /api/leads to Resend email + audience
blove Apr 6, 2026
c33e2e1
feat: wire /api/whitepaper-signup to Resend email delivery
blove Apr 6, 2026
4b5d310
feat: add /api/newsletter route with Resend welcome email
blove Apr 6, 2026
5c700cf
fix: convert email templates to plain HTML to avoid React dual-instan…
blove Apr 6, 2026
fba8c65
fix: lazy-init Resend client to gracefully handle missing API key
blove Apr 6, 2026
3092ca4
fix: make ChatFeaturesSection responsive on mobile
blove Apr 6, 2026
150ca98
fix: stack FairComparisonSection rows vertically on mobile
blove Apr 6, 2026
1aaaec2
fix: increase touch targets to meet WCAG 44px minimum
blove Apr 6, 2026
5975f8f
fix: enforce 12px minimum font size on progress bar labels
blove Apr 6, 2026
b65d9e2
feat: add social proof badge strip below stats
blove Apr 6, 2026
fcfe07b
feat: add newsletter signup form to footer
blove Apr 6, 2026
5f62f73
feat: restructure white paper section with soft gate
blove Apr 6, 2026
32b2221
feat: add OpenGraph and Twitter Card meta tags
blove Apr 6, 2026
888701c
merge: resolve ProblemSection conflict with main (keep our stat + fon…
blove Apr 6, 2026
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
6 changes: 6 additions & 0 deletions apps/website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ ANTHROPIC_API_KEY=your-anthropic-api-key-here

# Optional: override Claude model for docs generation (default: claude-sonnet-4-6)
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
24 changes: 24 additions & 0 deletions apps/website/emails/lead-notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
interface LeadNotificationProps {
name: string;
email: string;
company: string;
message: string;
ts: string;
}

export function leadNotificationHtml({ name, email, company, message, ts }: LeadNotificationProps): string {
return `<!DOCTYPE html><html><head></head>
<body style="font-family:Inter,Arial,sans-serif;background-color:#f4f4f5;padding:40px 0;margin:0">
<div style="max-width:520px;margin:0 auto;background-color:#fff;border-radius:12px;padding:32px 40px;border:1px solid #e4e4e7">
<p style="font-size:11px;font-family:monospace;text-transform:uppercase;letter-spacing:0.08em;color:#004090;font-weight:700;margin:0 0 8px">New Lead</p>
<p style="font-size:20px;font-weight:700;color:#1a1a2e;margin:8px 0 4px">${esc(name)}</p>
<p style="font-size:14px;color:#71717a;margin:0 0 16px">${esc(email)}${company ? ` — ${esc(company)}` : ''}</p>
${message ? `<hr style="border:none;border-top:1px solid #e4e4e7;margin:16px 0"/><p style="font-size:14px;color:#3f3f46;line-height:1.6;margin:0">${esc(message)}</p>` : ''}
<hr style="border:none;border-top:1px solid #e4e4e7;margin:16px 0"/>
<p style="font-size:11px;color:#a1a1aa;margin:0">Received ${esc(ts)}</p>
</div></body></html>`;
}

function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
12 changes: 12 additions & 0 deletions apps/website/emails/newsletter-welcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function newsletterWelcomeHtml(): string {
return `<!DOCTYPE html><html><head></head>
<body style="font-family:Inter,Arial,sans-serif;background-color:#f4f4f5;padding:40px 0;margin:0">
<div style="max-width:520px;margin:0 auto;background-color:#fff;border-radius:12px;padding:32px 40px;border:1px solid #e4e4e7">
<p style="font-size:11px;font-family:monospace;text-transform:uppercase;letter-spacing:0.08em;color:#004090;font-weight:700;margin:0 0 12px">Angular Stream Resource</p>
<p style="font-size:22px;font-weight:700;color:#1a1a2e;margin:0 0 8px">Welcome to Angular Stream Resource updates</p>
<p style="font-size:14px;color:#3f3f46;line-height:1.6;margin:0 0 24px">You'll receive updates on new capabilities, production patterns, and Angular agent best practices. We keep it focused and infrequent — no spam.</p>
<a href="https://stream-resource.dev/docs" style="background-color:#004090;color:#fff;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:700;text-decoration:none;display:inline-block">Explore the Docs</a>
<hr style="border:none;border-top:1px solid #e4e4e7;margin:24px 0 16px"/>
<p style="font-size:12px;color:#a1a1aa;line-height:1.5;margin:0">Angular Stream Resource — Signal-native streaming for LangGraph.</p>
</div></body></html>`;
}
20 changes: 20 additions & 0 deletions apps/website/emails/whitepaper-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const DOWNLOAD_URL = 'https://stream-resource.dev/whitepaper.pdf';

export function whitepaperDownloadHtml(name?: string): string {
return `<!DOCTYPE html><html><head></head>
<body style="font-family:Inter,Arial,sans-serif;background-color:#f4f4f5;padding:40px 0;margin:0">
<div style="max-width:520px;margin:0 auto;background-color:#fff;border-radius:12px;padding:32px 40px;border:1px solid #e4e4e7">
<p style="font-size:11px;font-family:monospace;text-transform:uppercase;letter-spacing:0.08em;color:#004090;font-weight:700;margin:0 0 12px">Angular Stream Resource</p>
<p style="font-size:22px;font-weight:700;color:#1a1a2e;margin:0 0 8px">Your Angular Agent Readiness Guide</p>
<p style="font-size:14px;color:#3f3f46;line-height:1.6;margin:0 0 24px">${name ? `Hi ${esc(name)}, t` : 'T'}he guide covers six production-readiness dimensions: streaming state, thread persistence, tool-call rendering, human approval flows, generative UI, and deterministic testing.</p>
<div style="text-align:center;margin:0 0 24px">
<a href="${DOWNLOAD_URL}" style="background-color:#004090;color:#fff;padding:14px 32px;border-radius:10px;font-size:14px;font-weight:700;text-decoration:none;display:inline-block">Download the Guide</a>
</div>
<hr style="border:none;border-top:1px solid #e4e4e7;margin:16px 0"/>
<p style="font-size:12px;color:#a1a1aa;line-height:1.5;margin:0">Angular Stream Resource — Signal-native streaming for LangGraph.</p>
</div></body></html>`;
}

function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
41 changes: 41 additions & 0 deletions apps/website/lib/resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Resend } from 'resend';

/** Lazy-init Resend client — returns null when API key is missing (dev without keys). */
let _resend: Resend | null = null;
function getResend(): Resend | null {
if (_resend) return _resend;
const key = process.env.RESEND_API_KEY;
if (!key) return null;
_resend = new Resend(key);
return _resend;
}

export const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID || '';
export const FROM = process.env.RESEND_FROM || 'Angular Stream Resource <hello@cacheplane.io>';
export const NOTIFY_TO = process.env.RESEND_NOTIFY_TO || 'hello@cacheplane.io';

/** Send an email via Resend. No-ops when API key is missing. */
export async function sendEmail(opts: { from: string; to: string; subject: string; html: string }) {
const client = getResend();
if (!client) {
console.info('[resend] skipped (no API key):', opts.subject);
return;
}
await client.emails.send(opts);
}

/** Add a contact to the Resend audience. Fails silently. */
export async function addToAudience(email: string, firstName?: string) {
if (!AUDIENCE_ID) return;
const client = getResend();
if (!client) return;
try {
await client.contacts.create({
audienceId: AUDIENCE_ID,
email,
firstName: firstName || undefined,
});
} catch (err) {
console.error('[resend] addToAudience failed:', err);
}
}
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"next": "~16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"resend": "^6.10.0",
"shiki": "*",
"tailwind-merge": "^2.5.0"
}
Expand Down
46 changes: 37 additions & 9 deletions apps/website/src/app/api/leads/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { sendEmail, FROM, NOTIFY_TO, addToAudience } from '../../../../lib/resend';
import { leadNotificationHtml } from '../../../../emails/lead-notification';

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 };
const { name, email, company, message } = body;
// Validate types and cap lengths to prevent log injection
const sanitize = (v: unknown, max = 500): string =>
typeof v === 'string' ? v.slice(0, max).trim() : '';

const safeName = sanitize(name, 200);
const safeEmail = sanitize(email, 320);
const safeCompany = sanitize(company, 200);
const safeMessage = sanitize(message, 2000);
const name = sanitize(body.name, 200);
const email = sanitize(body.email, 320);
const company = sanitize(body.company, 200);
const message = sanitize(body.message, 2000);

if (!safeName || !safeEmail) {
if (!name || !email) {
return NextResponse.json({ error: 'name and email required' }, { status: 400 });
}
// In production: send to CRM / email service
console.info('[lead]', { name: safeName, email: safeEmail, company: safeCompany, message: safeMessage, ts: new Date().toISOString() });

const ts = new Date().toISOString();

// NDJSON backup (always writes, even if Resend fails)
try {
fs.mkdirSync(path.dirname(LEADS_FILE), { recursive: true });
fs.appendFileSync(LEADS_FILE, JSON.stringify({ name, email, company, message, ts }) + '\n', 'utf8');
} catch (err) {
console.error('[leads] NDJSON write failed:', err);
}

// Resend: email notification + audience (best-effort)
try {
await Promise.all([
sendEmail({
from: FROM,
to: NOTIFY_TO,
subject: `New lead: ${name}${company ? ` at ${company}` : ''}`,
html: leadNotificationHtml({ name, email, company, message, ts }),
}),
addToAudience(email, name),
]);
} catch (err) {
console.error('[resend] lead notification failed:', err);
}

return NextResponse.json({ ok: true });
}
35 changes: 35 additions & 0 deletions apps/website/src/app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail, FROM, addToAudience } from '../../../../lib/resend';
import { newsletterWelcomeHtml } from '../../../../emails/newsletter-welcome';

export async function POST(req: NextRequest) {
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const email = (body.email || '').trim().slice(0, 320);

if (!email || !email.includes('@')) {
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
}

// Resend: welcome email + audience (best-effort)
try {
await Promise.all([
sendEmail({
from: FROM,
to: email,
subject: 'Welcome to Angular Stream Resource updates',
html: newsletterWelcomeHtml(),
}),
addToAudience(email),
]);
} catch (err) {
console.error('[resend] newsletter signup failed:', err);
}

return NextResponse.json({ ok: true });
}
29 changes: 23 additions & 6 deletions apps/website/src/app/api/whitepaper-signup/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// apps/website/src/app/api/whitepaper-signup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { sendEmail, FROM, addToAudience } from '../../../../lib/resend';
import { whitepaperDownloadHtml } from '../../../../emails/whitepaper-download';

const SIGNUPS_FILE = path.join(process.cwd(), 'data', 'whitepaper-signups.ndjson');

Expand All @@ -13,18 +14,34 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const { name = '', email = '' } = body;
const name = (body.name || '').trim();
const email = (body.email || '').trim();

if (!email || !email.includes('@')) {
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
}

const entry = JSON.stringify({ name: name.trim(), email: email.trim(), ts: new Date().toISOString() }) + '\n';
// NDJSON backup
try {
fs.mkdirSync(path.dirname(SIGNUPS_FILE), { recursive: true });
fs.appendFileSync(SIGNUPS_FILE, entry, 'utf8');
fs.appendFileSync(SIGNUPS_FILE, JSON.stringify({ name, email, ts: new Date().toISOString() }) + '\n', 'utf8');
} catch (err) {
console.error('[whitepaper] NDJSON write failed:', err);
}

// Resend: send PDF download email + add to audience (best-effort)
try {
await Promise.all([
sendEmail({
from: FROM,
to: email,
subject: 'Your Angular Agent Readiness Guide',
html: whitepaperDownloadHtml(name || undefined),
}),
addToAudience(email, name || undefined),
]);
} catch (err) {
console.error('Failed to write signup:', err);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
console.error('[resend] whitepaper email failed:', err);
}

return NextResponse.json({ ok: true });
Expand Down
11 changes: 11 additions & 0 deletions apps/website/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ const mono = JetBrains_Mono({
export const metadata: Metadata = {
title: 'Angular Stream Resource — Signal-Native Streaming for Angular + LangGraph',
description: 'The Enterprise Streaming Resource for LangChain and Angular. Signal-native streaming, thread persistence, and production patterns for Angular 20+.',
openGraph: {
title: 'Angular Stream Resource',
description: 'Signal-native streaming for LangGraph — production patterns your Angular team can own.',
type: 'website',
siteName: 'Angular Stream Resource',
},
twitter: {
card: 'summary_large_image',
title: 'Angular Stream Resource',
description: 'Signal-native streaming for LangGraph — production patterns your Angular team can own.',
},
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
Expand Down
2 changes: 2 additions & 0 deletions apps/website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ValueProps } from '../components/landing/ValueProps';
import { LangGraphShowcase } from '../components/landing/LangGraphShowcase';
import { DeepAgentsShowcase } from '../components/landing/DeepAgentsShowcase';
import { StatsStrip } from '../components/landing/StatsStrip';
import { SocialProof } from '../components/landing/SocialProof';
import { ProblemSection } from '../components/landing/ProblemSection';
import { FullStackSection } from '../components/landing/FullStackSection';
import { ChatFeaturesSection } from '../components/landing/ChatFeaturesSection';
Expand All @@ -28,6 +29,7 @@ export default async function HomePage() {
<HeroTwoCol />
{/* 2. Trust — quick credibility stats */}
<StatsStrip />
<SocialProof />
{/* 3. Problem — last-mile gap narrative */}
<ProblemSection />
{/* 4. Architecture — three-layer stack diagram */}
Expand Down
19 changes: 18 additions & 1 deletion apps/website/src/components/landing/ChatFeaturesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export function ChatFeaturesSection() {
</div>

{/* 3-col layout */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 440px 1fr', gap: '0 20px', maxWidth: 960, margin: '0 auto', alignItems: 'start' }}>
<div className="chat-features-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 440px 1fr', gap: '0 20px', maxWidth: 960, margin: '0 auto', alignItems: 'start' }}>

{/* Left callouts */}
<div id="feat-left" style={{ paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
Expand Down Expand Up @@ -440,6 +440,23 @@ export function ChatFeaturesSection() {
@keyframes sr-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.6)} }
@keyframes sr-blink { 0%,100%{opacity:1} 50%{opacity:0} }
@keyframes sr-bounce { 0%,80%,100%{transform:scale(.65);opacity:.5} 40%{transform:scale(1);opacity:1} }
@media (max-width: 767px) {
.chat-features-grid {
display: flex !important;
flex-direction: column !important;
gap: 16px !important;
padding: 0 !important;
}
#feat-left, #feat-right {
flex-direction: row !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
#feat-left > div, #feat-right > div {
flex: 1 1 calc(50% - 4px);
min-width: 140px;
}
}
`}</style>
</motion.section>
);
Expand Down
20 changes: 13 additions & 7 deletions apps/website/src/components/landing/CitationBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,32 @@ export function CitationBadge({ citation }: CitationBadgeProps) {
aria-expanded={open}
aria-haspopup="dialog"
style={{
width: 13,
height: 13,
minWidth: 44,
minHeight: 44,
margin: '-15px -15px',
borderRadius: '50%',
background: 'transparent',
border: `1px solid ${open ? 'rgba(0,64,144,0.45)' : 'rgba(0,64,144,0.2)'}`,
color: open ? 'rgba(0,64,144,0.7)' : 'rgba(0,64,144,0.35)',
fontSize: 7,
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 700,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
padding: 0,
transition: 'border-color 0.15s ease, color 0.15s ease',
transition: 'color 0.15s ease',
flexShrink: 0,
}}
>
i
<span style={{
width: 13, height: 13, borderRadius: '50%',
border: `1px solid ${open ? 'rgba(0,64,144,0.45)' : 'rgba(0,64,144,0.2)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, lineHeight: 1,
transition: 'border-color 0.15s ease',
}}>
i
</span>
</button>

{open && (
Expand Down
Loading
Loading