diff --git a/.env.example b/.env.example
index 90ceecb..f702b46 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,6 @@
-# ConvertKit (email newsletter integration)
-CONVERTKIT_API_KEY=your_convertkit_api_key_here
+# Multicorn API (for newsletter signup and other client-side API calls)
+# Default: https://api.multicorn.ai. Use http://localhost:8080 for local dev.
+NEXT_PUBLIC_API_URL=https://api.multicorn.ai
# Plausible Analytics (privacy-friendly analytics)
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=multicorn.ai
diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts
deleted file mode 100644
index 45844d9..0000000
--- a/app/api/subscribe/route.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { NextResponse } from 'next/server'
-
-const SUBSCRIBE_STATUSES = {
- success: 'success',
- already_subscribed: 'already_subscribed',
- error: 'error',
-} as const
-
-function isValidEmail(email: string): boolean {
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
-}
-
-interface KitSubscriberLookup {
- readonly total_subscribers: number
- readonly subscribers: readonly { readonly state: string }[]
-}
-
-export async function POST(request: Request) {
- try {
- const body: unknown = await request.json()
-
- if (!body || typeof body !== 'object' || !('email' in body)) {
- return NextResponse.json(
- { status: SUBSCRIBE_STATUSES.error, message: 'Email is required.' },
- { status: 400 },
- )
- }
-
- const { email } = body as { email: unknown }
-
- if (typeof email !== 'string' || !email.trim() || !isValidEmail(email)) {
- return NextResponse.json(
- { status: SUBSCRIBE_STATUSES.error, message: 'Please enter a valid email address.' },
- { status: 400 },
- )
- }
-
- const formId = process.env.NEXT_PUBLIC_CONVERTKIT_FORM_ID
- const apiKey = process.env.CONVERTKIT_API_KEY
- const apiSecret = process.env.CONVERTKIT_API_SECRET
-
- if (!formId || !apiKey || !apiSecret) {
- return NextResponse.json(
- { status: SUBSCRIBE_STATUSES.error, message: 'Email signup is not configured.' },
- { status: 500 },
- )
- }
-
- const lookupUrl = new URL('https://api.convertkit.com/v3/subscribers')
- lookupUrl.searchParams.set('api_secret', apiSecret)
- lookupUrl.searchParams.set('email_address', email)
-
- const lookupResponse = await fetch(lookupUrl.toString(), {
- signal: AbortSignal.timeout(10_000),
- })
-
- if (lookupResponse.ok) {
- const lookupData = (await lookupResponse.json()) as KitSubscriberLookup
- if (lookupData.total_subscribers > 0) {
- return NextResponse.json({ status: SUBSCRIBE_STATUSES.already_subscribed })
- }
- }
-
- const response = await fetch(`https://api.convertkit.com/v3/forms/${formId}/subscribe`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
- body: JSON.stringify({ api_key: apiKey, email }),
- signal: AbortSignal.timeout(10_000),
- })
-
- if (!response.ok) {
- const errorBody = await response.text()
- console.error('Kit API error:', response.status, errorBody)
- return NextResponse.json(
- { status: SUBSCRIBE_STATUSES.error, message: 'Subscription failed.' },
- { status: 502 },
- )
- }
-
- return NextResponse.json({ status: SUBSCRIBE_STATUSES.success })
- } catch (error) {
- console.error('Subscribe route error:', error)
- return NextResponse.json(
- { status: SUBSCRIBE_STATUSES.error, message: 'Something went wrong.' },
- { status: 500 },
- )
- }
-}
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx
index 89752cb..56a0b84 100644
--- a/app/blog/[slug]/page.tsx
+++ b/app/blog/[slug]/page.tsx
@@ -134,7 +134,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
Get the latest articles and product updates delivered to your inbox.
-
+
diff --git a/app/learn/ai-101/[slug]/page.tsx b/app/learn/ai-101/[slug]/page.tsx
index 9d9ca4b..3ed42fb 100644
--- a/app/learn/ai-101/[slug]/page.tsx
+++ b/app/learn/ai-101/[slug]/page.tsx
@@ -226,7 +226,7 @@ export default async function LearnArticlePage({ params }: LearnArticlePageProps
Get the latest articles and product updates delivered to your inbox.
-
+
diff --git a/app/learn/page.tsx b/app/learn/page.tsx
index 325a9b0..e0841ee 100644
--- a/app/learn/page.tsx
+++ b/app/learn/page.tsx
@@ -91,7 +91,7 @@ export default function LearnPage() {
for teams. Sign up below to get notified when new courses launch.
-
+
diff --git a/app/subscribed/page.tsx b/app/subscribed/page.tsx
index f5c719e..722f601 100644
--- a/app/subscribed/page.tsx
+++ b/app/subscribed/page.tsx
@@ -1,5 +1,67 @@
-import { ConfirmationPage } from '@/components/ConfirmationPage'
+import Link from 'next/link'
-export default function SubscribedPage() {
- return
+export default async function SubscribedPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ status?: string }>
+}) {
+ const { status } = await searchParams
+
+ const isSuccess = status === 'success'
+
+ return (
+
+
+
+ {isSuccess ? (
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+ {isSuccess ? "You're subscribed!" : 'Link expired'}
+
+
+
+ {isSuccess
+ ? "We'll email you about new blog posts, features, and SDK releases."
+ : 'This link has expired or was already used. Try subscribing again.'}
+
+
+
+ {isSuccess ? 'Back to multicorn.ai' : 'Subscribe again'}
+
+
+
+ )
}
diff --git a/app/unsubscribed/page.tsx b/app/unsubscribed/page.tsx
new file mode 100644
index 0000000..684948a
--- /dev/null
+++ b/app/unsubscribed/page.tsx
@@ -0,0 +1,33 @@
+export default function UnsubscribedPage() {
+ return (
+
+
+
+ )
+}
diff --git a/components/EmailSignupForm.tsx b/components/EmailSignupForm.tsx
index 5295d79..34b64bc 100644
--- a/components/EmailSignupForm.tsx
+++ b/components/EmailSignupForm.tsx
@@ -1,13 +1,15 @@
'use client'
import { useState, useRef } from 'react'
+import { usePathname } from 'next/navigation'
import { trackEvent } from '@/lib/plausible'
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.multicorn.ai'
+
const FORM_STATES = {
idle: 'idle',
submitting: 'submitting',
success: 'success',
- duplicate: 'duplicate',
error: 'error',
} as const
@@ -17,11 +19,22 @@ function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
-interface SubscribeResponse {
- readonly status: 'success' | 'already_subscribed' | 'error'
+function deriveSource(pathname: string): string {
+ if (pathname === '/' || pathname === '') return 'learn-landing'
+ if (pathname.startsWith('/blog') || pathname.startsWith('/learn')) return 'learn-blog'
+ if (pathname.startsWith('/docs') || pathname.startsWith('/shield')) return 'shield-docs'
+ if (pathname.startsWith('/pricing')) return 'shield-pricing'
+ return 'learn-other'
+}
+
+interface EmailSignupFormProps {
+ readonly source?: string
}
-export function EmailSignupForm() {
+export function EmailSignupForm({ source: sourceProp }: EmailSignupFormProps = {}) {
+ const pathname = usePathname()
+ const source = sourceProp ?? deriveSource(pathname ?? '')
+
const [formState, setFormState] = useState(FORM_STATES.idle)
const [email, setEmail] = useState('')
const [validationError, setValidationError] = useState('')
@@ -56,21 +69,22 @@ export function EmailSignupForm() {
setSubmitError('')
try {
- const response = await fetch('/api/subscribe', {
+ const response = await fetch(`${API_URL}/api/v1/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email }),
+ body: JSON.stringify({ email, source }),
})
- const data = (await response.json()) as SubscribeResponse
-
- if (data.status === 'already_subscribed') {
- setFormState(FORM_STATES.duplicate)
+ if (response.status === 429) {
+ setFormState(FORM_STATES.error)
+ setSubmitError('Too many requests. Please try again in a moment.')
return
}
if (!response.ok) {
- throw new Error('Subscription failed')
+ setFormState(FORM_STATES.error)
+ setSubmitError('Something went wrong. Please try again.')
+ return
}
setFormState(FORM_STATES.success)
@@ -87,26 +101,13 @@ export function EmailSignupForm() {
role="status"
className="rounded-lg border border-green/20 bg-green/5 px-6 py-4 text-center"
>
- You're on the list!
-
- Check your inbox (and spam folder) for a confirmation email.
+
+ Check your inbox to confirm your subscription.
)
}
- if (formState === FORM_STATES.duplicate) {
- return (
-
-
Looks like you're already subscribed.
-
Check your inbox for updates.
-
- )
- }
-
const isSubmitting = formState === FORM_STATES.submitting
return (
diff --git a/components/Hero.tsx b/components/Hero.tsx
index 1334129..667b46c 100644
--- a/components/Hero.tsx
+++ b/components/Hero.tsx
@@ -45,7 +45,7 @@ export function Hero() {
Get updates on Multicorn. No spam, ever.
-
+
diff --git a/components/LaunchPage.tsx b/components/LaunchPage.tsx
index 4823492..3f33535 100644
--- a/components/LaunchPage.tsx
+++ b/components/LaunchPage.tsx
@@ -1,8 +1,6 @@
import { EmailSignupForm } from '@/components/EmailSignupForm'
export function LaunchPage() {
- const hasSignup = !!process.env.NEXT_PUBLIC_CONVERTKIT_FORM_ID?.trim()
-
return (
@@ -29,12 +27,10 @@ export function LaunchPage() {
Multicorn Shield for agent permissions. Multicorn Learn for AI education.
- {hasSignup && (
-
- )}
+
diff --git a/lib/launchGatePaths.ts b/lib/launchGatePaths.ts
index fefaa25..2c3f202 100644
--- a/lib/launchGatePaths.ts
+++ b/lib/launchGatePaths.ts
@@ -1,4 +1,4 @@
-const PUBLIC_LAUNCH_PATHS = ['/confirmed', '/subscribed', '/blog'] as const
+const PUBLIC_LAUNCH_PATHS = ['/confirmed', '/subscribed', '/unsubscribed', '/blog'] as const
const PUBLIC_LAUNCH_PREFIXES = ['/policies/', '/blog/'] as const
export function isLaunchGatePublicPath(pathname: string): boolean {
@@ -14,5 +14,5 @@ export function isLaunchGatePublicPath(pathname: string): boolean {
}
export function isLaunchGateStandalonePath(pathname: string): boolean {
- return pathname === '/confirmed' || pathname === '/subscribed'
+ return pathname === '/confirmed' || pathname === '/subscribed' || pathname === '/unsubscribed'
}