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 ( +
+
+
+ +
+ +

+ You've been unsubscribed +

+ +

Sorry to see you go.

+ + + Back to multicorn.ai + +
+
+ ) +} 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 && ( -
- -

Launching soon

-
- )} +
+ +

Launching soon

+
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' }