diff --git a/website/.env.example b/website/.env.example index 2a1ca550..36084942 100644 --- a/website/.env.example +++ b/website/.env.example @@ -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= diff --git a/website/src/app/api/subscribe/route.ts b/website/src/app/api/subscribe/route.ts new file mode 100644 index 00000000..9023f733 --- /dev/null +++ b/website/src/app/api/subscribe/route.ts @@ -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") diff --git a/website/src/app/join/page.tsx b/website/src/app/join/page.tsx new file mode 100644 index 00000000..587a4c1c --- /dev/null +++ b/website/src/app/join/page.tsx @@ -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 ( +
+
+
+
+

+ Join +

+

+ Get on the list. +

+

+ Drop your email and we'll send launch notes, TestFlight invites, + and the occasional build update. Voice for the agent you already + trust. +

+ +
+ +
+ +
+ } + title="Natural voice in front" + body="Low-latency voice sessions with transcript continuity and clear live state." + /> + } + title="iPhone TestFlight" + body="Members get TestFlight invites first while App Store review wraps up." + /> + } + title="Open source" + body="Run the relay yourself, inspect the code, and keep provider keys under your control." + /> +
+ +

+ Already have a Mac?{" "} + + Download the macOS app + + . +

+
+
+
+ ) +} + +function Header() { + return ( +
+
+ + + + +
+
+ ) +} + +function Footer() { + return ( + + ) +} + +function InfoTile({ + icon, + title, + body, +}: { + icon: React.ReactNode + title: string + body: string +}) { + return ( +
+
+ {icon} +
+

{title}

+

{body}

+
+ ) +} diff --git a/website/src/app/page.tsx b/website/src/app/page.tsx index 0167a818..e125b626 100644 --- a/website/src/app/page.tsx +++ b/website/src/app/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { ArrowRight, Download, + Mail, Mic, PlayCircle, RadioTower, @@ -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] @@ -71,14 +73,13 @@ function Header({ - - Download Mac + + Join @@ -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.

-
+
+ + + Join + Download for Mac Watch demo @@ -377,20 +387,30 @@ function GetStartedSection({ Get started

- Start with the Mac app. + Get on the list.

- Download VoiceClaw for macOS, connect your agent endpoint, and start - talking. + Drop your email for launch notes, TestFlight invites, and build + updates. Already on a Mac? Grab the desktop app below.{" "} +

+ + + Join + Download for Mac @@ -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 diff --git a/website/src/components/join/join-form.tsx b/website/src/components/join/join-form.tsx new file mode 100644 index 00000000..7a76be52 --- /dev/null +++ b/website/src/components/join/join-form.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState, type FormEvent } from "react" +import { ArrowRight, CheckCircle2 } from "lucide-react" +import { capture } from "@/lib/telemetry/posthog-client" + +type Status = + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "success"; alreadySubscribed: boolean } + | { kind: "error"; message: string } + +const ERROR_COPY: Record = { + invalid_email: "That email doesn't look right. Try again.", + not_configured: "Signup isn't live yet. Check back soon.", + rate_limited: "Too many tries. Give it a minute and retry.", + upstream_error: "Something went wrong on our end. Try again.", + network_error: "Couldn't reach the server. Check your connection.", +} + +export function JoinForm() { + const [email, setEmail] = useState("") + const [status, setStatus] = useState({ kind: "idle" }) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (status.kind === "submitting") return + + setStatus({ kind: "submitting" }) + capture("join_submitted", { location: "join_page" }) + + let response: Response + try { + response = await fetch("/api/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + } catch { + setStatus({ kind: "error", message: ERROR_COPY.network_error }) + return + } + + const payload = (await safeJson(response)) as + | { ok?: boolean; alreadySubscribed?: boolean; error?: string } + | null + + if (response.ok && payload?.ok) { + capture("join_succeeded", { + location: "join_page", + already_subscribed: Boolean(payload.alreadySubscribed), + }) + setStatus({ + kind: "success", + alreadySubscribed: Boolean(payload.alreadySubscribed), + }) + setEmail("") + return + } + + const code = payload?.error ?? "upstream_error" + capture("join_failed", { location: "join_page", error: code }) + setStatus({ kind: "error", message: ERROR_COPY[code] ?? ERROR_COPY.upstream_error }) + } + + if (status.kind === "success") { + return ( +
+
+
+ +
+
+

+ {status.alreadySubscribed ? "Already on the list" : "You're in"} +

+

+ {status.alreadySubscribed + ? "We already have your email." + : "Welcome aboard."} +

+

+ We'll send launch notes, TestFlight invites, and the occasional + build update. No noise. +

+
+
+
+ ) + } + + const submitting = status.kind === "submitting" + + return ( +
+ +
+ setEmail(e.target.value)} + disabled={submitting} + aria-invalid={status.kind === "error" || undefined} + aria-describedby={status.kind === "error" ? "join-error" : undefined} + className="h-12 flex-1 rounded-md border border-[var(--brand-line-strong)] bg-[var(--brand-panel-strong)] px-4 text-base text-[var(--brand-ink)] placeholder:text-[var(--brand-muted)] focus:border-[var(--brand-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--brand-accent-wash)] disabled:opacity-60" + /> + +
+ {status.kind === "error" && ( + + )} +

+ We use your email only to send VoiceClaw updates. Unsubscribe any time. +

+
+ ) +} + +async function safeJson(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} diff --git a/website/src/lib/email-octopus.ts b/website/src/lib/email-octopus.ts new file mode 100644 index 00000000..2938cdd3 --- /dev/null +++ b/website/src/lib/email-octopus.ts @@ -0,0 +1,82 @@ +const EMAIL_OCTOPUS_BASE = "https://api.emailoctopus.com" + +export type SubscribeResult = + | { ok: true } + | { ok: false; reason: "not_configured" | "invalid_email" | "already_subscribed" | "rate_limited" | "upstream_error"; status?: number; message?: string } + +export function isEmailOctopusConfigured(): boolean { + return Boolean(process.env.EMAIL_OCTOPUS_API_KEY && process.env.EMAIL_OCTOPUS_LIST_ID) +} + +export async function subscribeToList(email: string): Promise { + const apiKey = process.env.EMAIL_OCTOPUS_API_KEY + const listId = process.env.EMAIL_OCTOPUS_LIST_ID + if (!apiKey || !listId) { + return { ok: false, reason: "not_configured" } + } + + const trimmed = email.trim().toLowerCase() + if (!isPlausibleEmail(trimmed)) { + return { ok: false, reason: "invalid_email" } + } + + const url = `${EMAIL_OCTOPUS_BASE}/lists/${encodeURIComponent(listId)}/contacts` + let response: Response + try { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ email_address: trimmed, status: "subscribed" }), + cache: "no-store", + }) + } catch (err) { + return { + ok: false, + reason: "upstream_error", + message: err instanceof Error ? err.message : "fetch_failed", + } + } + + if (response.ok) { + return { ok: true } + } + + const payload = (await safeJson(response)) as { error?: { code?: string; message?: string } } | null + const code = payload?.error?.code + + if (response.status === 409 || code === "MEMBER_EXISTS_WITH_EMAIL_ADDRESS") { + return { ok: false, reason: "already_subscribed", status: response.status } + } + if (response.status === 429) { + return { ok: false, reason: "rate_limited", status: response.status } + } + if (response.status === 400 && code === "INVALID_PARAMETERS") { + return { ok: false, reason: "invalid_email", status: response.status } + } + + return { + ok: false, + reason: "upstream_error", + status: response.status, + message: payload?.error?.message, + } +} + +function isPlausibleEmail(value: string): boolean { + if (value.length < 3 || value.length > 254) return false + const at = value.indexOf("@") + if (at <= 0 || at !== value.lastIndexOf("@")) return false + const dot = value.lastIndexOf(".") + return dot > at + 1 && dot < value.length - 1 +} + +async function safeJson(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +}