Skip to content
Open
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: 8 additions & 0 deletions website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
# no-ops when unset.
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

# ---------------------------------------------------------------------------
# EmailOctopus (newsletter signup)
# ---------------------------------------------------------------------------
# Create an API key at https://emailoctopus.com/api-keys and grab the list
# ID from the list URL. /api/subscribe returns 501 until both are set.
EMAIL_OCTOPUS_API_KEY=
EMAIL_OCTOPUS_LIST_ID=
38 changes: 38 additions & 0 deletions website/src/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from "next/server"
import { isEmailOctopusConfigured, subscribeToList } from "@/lib/email-octopus"
import { withCapture } from "@/lib/telemetry/posthog-server"

type Body = { email?: unknown }

export const POST = withCapture(async (request: NextRequest) => {
if (!isEmailOctopusConfigured()) {
return NextResponse.json({ error: "not_configured" }, { status: 501 })
}

let body: Body
try {
body = (await request.json()) as Body
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 })
}

if (typeof body.email !== "string") {
return NextResponse.json({ error: "missing_email" }, { status: 400 })
}

const result = await subscribeToList(body.email)
if (result.ok) {
return NextResponse.json({ ok: true })
}

switch (result.reason) {
case "invalid_email":
return NextResponse.json({ error: "invalid_email" }, { status: 400 })
case "already_subscribed":
return NextResponse.json({ ok: true, alreadySubscribed: true })
case "rate_limited":
return NextResponse.json({ error: "rate_limited" }, { status: 429 })
default:
return NextResponse.json({ error: "upstream_error" }, { status: 502 })
}
}, "/api/subscribe")
145 changes: 145 additions & 0 deletions website/src/app/join/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Metadata } from "next"
import Link from "next/link"
import { ArrowLeft, Mic, RadioTower, ShieldCheck } from "lucide-react"
import { BrandWordmark } from "@/components/brand/brand-system"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { JoinForm } from "@/components/join/join-form"

const REPO_URL = "https://github.com/yagudaev/voiceclaw"
const RELEASES_URL = "https://github.com/yagudaev/voiceclaw/releases"

export const metadata: Metadata = {
title: "Join VoiceClaw",
description:
"Get launch notes, TestFlight invites, and build updates for VoiceClaw — the open-source voice layer for your agent.",
}

export default function JoinPage() {
return (
<main className="min-h-screen bg-[var(--brand-paper)] text-[var(--brand-ink)]">
<Header />
<section className="border-b border-[var(--brand-line-strong)] px-5 py-20 sm:px-8">
<div className="mx-auto max-w-4xl">
<p className="font-mono text-xs uppercase tracking-[0.28em] text-[var(--brand-accent)]">
Join
</p>
<h1 className="mt-6 font-serif text-5xl leading-none sm:text-7xl">
Get on the list.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-[var(--brand-muted)] sm:text-xl">
Drop your email and we&apos;ll send launch notes, TestFlight invites,
and the occasional build update. Voice for the agent you already
trust.
</p>

<div className="mt-10">
<JoinForm />
</div>

<div className="mt-10 grid gap-4 sm:grid-cols-3">
<InfoTile
icon={<Mic className="size-5" />}
title="Natural voice in front"
body="Low-latency voice sessions with transcript continuity and clear live state."
/>
<InfoTile
icon={<RadioTower className="size-5" />}
title="iPhone TestFlight"
body="Members get TestFlight invites first while App Store review wraps up."
/>
<InfoTile
icon={<ShieldCheck className="size-5" />}
title="Open source"
body="Run the relay yourself, inspect the code, and keep provider keys under your control."
/>
</div>

<p className="mt-10 text-sm text-[var(--brand-muted)]">
Already have a Mac?{" "}
<Link
href="/download"
className="font-semibold text-[var(--brand-ink)] underline decoration-[var(--brand-accent)] underline-offset-4"
>
Download the macOS app
</Link>
.
</p>
</div>
</section>
<Footer />
</main>
)
}

function Header() {
return (
<header className="sticky top-0 z-50 border-b border-[var(--brand-line-strong)] bg-[var(--brand-paper)]/90 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:px-8">
<Link href="/" aria-label="VoiceClaw home">
<BrandWordmark />
</Link>
<nav className="flex items-center gap-2 text-sm text-[var(--brand-muted)] sm:gap-5">
<Link
href="/"
className="inline-flex items-center gap-2 hover:text-[var(--brand-ink)]"
>
<ArrowLeft className="size-4" />
<span className="hidden sm:inline">Home</span>
</Link>
<ThemeSwitcher />
</nav>
</div>
</header>
)
}

function Footer() {
return (
<footer className="border-t border-[var(--brand-line-strong)] bg-[var(--brand-paper)] px-5 py-8 sm:px-8">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-5 text-sm text-[var(--brand-muted)] sm:flex-row sm:items-center">
<BrandWordmark />
<div className="flex flex-wrap gap-5">
<a
href={RELEASES_URL}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--brand-ink)]"
>
Release notes
</a>
<Link href="/brand" className="hover:text-[var(--brand-ink)]">
Brand guidelines
</Link>
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--brand-ink)]"
>
GitHub
</a>
</div>
</div>
</footer>
)
}

function InfoTile({
icon,
title,
body,
}: {
icon: React.ReactNode
title: string
body: string
}) {
return (
<article className="rounded-md border border-[var(--brand-line-strong)] bg-[var(--brand-panel)] p-5 shadow-[var(--brand-shadow)]">
<div className="mb-4 flex size-10 items-center justify-center rounded-md bg-[var(--brand-accent-wash)] text-[var(--brand-accent)]">
{icon}
</div>
<h3 className="text-base font-semibold text-[var(--brand-ink)]">{title}</h3>
<p className="mt-2 text-sm leading-6 text-[var(--brand-muted)]">{body}</p>
</article>
)
}
50 changes: 35 additions & 15 deletions website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link"
import {
ArrowRight,
Download,
Mail,
Mic,
PlayCircle,
RadioTower,
Expand All @@ -27,6 +28,7 @@ import {

const REPO_URL = "https://github.com/yagudaev/voiceclaw"
const MAC_DOWNLOAD_URL = "/download"
const JOIN_URL = "/join"
const TESTFLIGHT_SIGNUP_URL = ""
const DEMO_EMBED_URL = "https://www.youtube.com/embed/iAS7vj2vRaA"
const HERO_BARS = [26, 44, 58, 42, 24, 34, 52, 78, 50, 31, 66, 86, 60, 38, 54, 82, 48, 72]
Expand Down Expand Up @@ -71,14 +73,13 @@ function Header({
<ThemeSwitcher />
<TrackCtaLink
ctaLocation="header"
ctaLabel="Download Mac"
href={MAC_DOWNLOAD_URL}
aria-label="Download for Mac"
title={downloadTitle(macRelease)}
className="inline-flex items-center gap-2 rounded-md border border-[var(--brand-line-strong)] bg-[var(--brand-panel)] px-3 py-2 text-[var(--brand-ink)] shadow-[var(--brand-shadow)] transition hover:border-[var(--brand-accent)]"
ctaLabel="Join"
href={JOIN_URL}
aria-label="Join the email list"
className="inline-flex items-center gap-2 rounded-md bg-[var(--brand-accent)] px-3 py-2 text-sm font-semibold text-primary-foreground transition hover:bg-[var(--brand-accent-hover)]"
>
<Download className="size-4" />
<span className="hidden sm:inline">Download Mac</span>
<Mail className="size-4" />
<span className="hidden sm:inline">Join</span>
</TrackCtaLink>
</nav>
</div>
Expand Down Expand Up @@ -112,20 +113,29 @@ function HeroSection({
OpenAI-compatible endpoint and it handles the mic, the route, and
the transcript while your agent does the real work.
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<div className="mt-9 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<TrackCtaLink
ctaLocation="hero"
ctaLabel="Join"
href={JOIN_URL}
className="inline-flex h-12 items-center justify-center gap-2 rounded-md bg-[var(--brand-accent)] px-5 text-sm font-semibold text-primary-foreground transition hover:bg-[var(--brand-accent-hover)]"
>
<Mail className="size-4" />
Join
</TrackCtaLink>
<TrackCtaLink
ctaLocation="hero"
ctaLabel="Download for Mac"
href={MAC_DOWNLOAD_URL}
title={downloadTitle(macRelease)}
className="inline-flex h-12 items-center justify-center gap-2 rounded-md bg-[var(--brand-accent)] px-5 text-sm font-semibold text-primary-foreground transition hover:bg-[var(--brand-accent-hover)]"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-[var(--brand-line-strong)] bg-[var(--brand-panel)] px-5 text-sm font-semibold text-[var(--brand-ink)] transition hover:border-[var(--brand-accent)]"
>
<Download className="size-4" />
Download for Mac
</TrackCtaLink>
<Link
href="#demo"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-[var(--brand-line-strong)] bg-[var(--brand-panel)] px-5 text-sm font-semibold text-[var(--brand-ink)] transition hover:border-[var(--brand-accent)]"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-transparent px-2 text-sm font-semibold text-[var(--brand-muted)] transition hover:text-[var(--brand-ink)]"
>
<PlayCircle className="size-4" />
Watch demo
Expand Down Expand Up @@ -377,20 +387,30 @@ function GetStartedSection({
Get started
</p>
<h2 className="mt-4 max-w-3xl font-serif text-5xl leading-none sm:text-6xl">
Start with the Mac app.
Get on the list.
</h2>
<p className="mt-6 max-w-2xl text-lg leading-8 text-[var(--brand-contrast-muted)]">
Download VoiceClaw for macOS, connect your agent endpoint, and start
talking. <TestFlightSignupNotice contrast />
Drop your email for launch notes, TestFlight invites, and build
updates. Already on a Mac? Grab the desktop app below.{" "}
<TestFlightSignupNotice contrast />
</p>
</div>
<div className="flex flex-col justify-end gap-3">
<TrackCtaLink
ctaLocation="get_started"
ctaLabel="Join"
href={JOIN_URL}
className="inline-flex h-12 items-center justify-center gap-2 rounded-md bg-[var(--brand-accent)] px-5 text-sm font-semibold text-primary-foreground transition hover:bg-[var(--brand-accent-hover)]"
>
<Mail className="size-4" />
Join
</TrackCtaLink>
<TrackCtaLink
ctaLocation="get_started"
ctaLabel="Download for Mac"
href={MAC_DOWNLOAD_URL}
title={downloadTitle(macRelease)}
className="inline-flex h-12 items-center justify-center gap-2 rounded-md bg-[var(--brand-contrast-fg)] px-5 text-sm font-semibold text-[var(--brand-contrast-bg)] transition hover:bg-[var(--brand-contrast-fg-hover)]"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-[var(--brand-contrast-line)] px-5 text-sm font-semibold text-[var(--brand-contrast-fg)] transition hover:border-[var(--brand-contrast-fg)]"
>
<Download className="size-4" />
Download for Mac
Expand All @@ -400,7 +420,7 @@ function GetStartedSection({
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-[var(--brand-contrast-line)] px-5 text-sm font-semibold text-[var(--brand-contrast-fg)] transition hover:border-[var(--brand-contrast-fg)]"
className="inline-flex h-12 items-center justify-center gap-2 rounded-md border border-transparent px-5 text-sm font-semibold text-[var(--brand-contrast-muted)] transition hover:text-[var(--brand-contrast-fg)]"
>
View source
<ArrowRight className="size-4" />
Expand Down
Loading