diff --git a/apps/website/.env.example b/apps/website/.env.example index 9feb80dc2..c6957548a 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -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 " +RESEND_NOTIFY_TO=hello@cacheplane.io diff --git a/apps/website/emails/lead-notification.ts b/apps/website/emails/lead-notification.ts new file mode 100644 index 000000000..0edecf526 --- /dev/null +++ b/apps/website/emails/lead-notification.ts @@ -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 ` + +
+

New Lead

+

${esc(name)}

+

${esc(email)}${company ? ` — ${esc(company)}` : ''}

+ ${message ? `

${esc(message)}

` : ''} +
+

Received ${esc(ts)}

+
`; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/apps/website/emails/newsletter-welcome.ts b/apps/website/emails/newsletter-welcome.ts new file mode 100644 index 000000000..2873ca725 --- /dev/null +++ b/apps/website/emails/newsletter-welcome.ts @@ -0,0 +1,12 @@ +export function newsletterWelcomeHtml(): string { + return ` + +
+

Angular Stream Resource

+

Welcome to Angular Stream Resource updates

+

You'll receive updates on new capabilities, production patterns, and Angular agent best practices. We keep it focused and infrequent — no spam.

+ Explore the Docs +
+

Angular Stream Resource — Signal-native streaming for LangGraph.

+
`; +} diff --git a/apps/website/emails/whitepaper-download.ts b/apps/website/emails/whitepaper-download.ts new file mode 100644 index 000000000..6b8ff2eeb --- /dev/null +++ b/apps/website/emails/whitepaper-download.ts @@ -0,0 +1,20 @@ +const DOWNLOAD_URL = 'https://stream-resource.dev/whitepaper.pdf'; + +export function whitepaperDownloadHtml(name?: string): string { + return ` + +
+

Angular Stream Resource

+

Your Angular Agent Readiness Guide

+

${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.

+
+ Download the Guide +
+
+

Angular Stream Resource — Signal-native streaming for LangGraph.

+
`; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/apps/website/lib/resend.ts b/apps/website/lib/resend.ts new file mode 100644 index 000000000..2279f4359 --- /dev/null +++ b/apps/website/lib/resend.ts @@ -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 '; +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); + } +} diff --git a/apps/website/package.json b/apps/website/package.json index 432100f25..2fd1e5c36 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -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" } diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index 1045905eb..50ef6bb70 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -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 }); } diff --git a/apps/website/src/app/api/newsletter/route.ts b/apps/website/src/app/api/newsletter/route.ts new file mode 100644 index 000000000..de8ae91a0 --- /dev/null +++ b/apps/website/src/app/api/newsletter/route.ts @@ -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 }); +} diff --git a/apps/website/src/app/api/whitepaper-signup/route.ts b/apps/website/src/app/api/whitepaper-signup/route.ts index 46efa9ef3..89b12f227 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.ts @@ -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'); @@ -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 }); diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index 75cb5eb20..14c5d09a6 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -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 }) { diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index fd5feaeab..99b8f83bf 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -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'; @@ -28,6 +29,7 @@ export default async function HomePage() { {/* 2. Trust — quick credibility stats */} + {/* 3. Problem — last-mile gap narrative */} {/* 4. Architecture — three-layer stack diagram */} diff --git a/apps/website/src/components/landing/ChatFeaturesSection.tsx b/apps/website/src/components/landing/ChatFeaturesSection.tsx index 59d6c7096..8f97bd22b 100644 --- a/apps/website/src/components/landing/ChatFeaturesSection.tsx +++ b/apps/website/src/components/landing/ChatFeaturesSection.tsx @@ -361,7 +361,7 @@ export function ChatFeaturesSection() { {/* 3-col layout */} -
+
{/* Left callouts */}
@@ -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; + } + } `} ); diff --git a/apps/website/src/components/landing/CitationBadge.tsx b/apps/website/src/components/landing/CitationBadge.tsx index 4d0e36a2d..a37d35f9b 100644 --- a/apps/website/src/components/landing/CitationBadge.tsx +++ b/apps/website/src/components/landing/CitationBadge.tsx @@ -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 + + i + {open && ( diff --git a/apps/website/src/components/landing/FairComparisonSection.tsx b/apps/website/src/components/landing/FairComparisonSection.tsx index b6e51e5a1..31678b637 100644 --- a/apps/website/src/components/landing/FairComparisonSection.tsx +++ b/apps/website/src/components/landing/FairComparisonSection.tsx @@ -94,9 +94,9 @@ export function FairComparisonSection() { overflow: 'hidden', }} > - {/* Table header */} -