From 13924741a7201e04a961027b7f86ab7543bb19b8 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 18 Apr 2026 23:09:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(landing):=20add=20polished=20/download=20p?= =?UTF-8?q?age=20=E2=80=94=20no=20more=20dumping=20users=20on=20GitHub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users were clicking download, landing on the GitHub releases page, and filing confused comments along the lines of "I ended up on some blog site called GitHub." We now route every download CTA through a dedicated /download page that auto-triggers the platform-specific download and gives users a polished post-click experience with donate + docs + AI help prompts. - New /download page: - Big app logo + "Your download has started" messaging. - Auto-detects platform from ?platform=X or navigator.userAgent. - Programmatically clicks a hidden anchor to trigger the file download without leaving the page. - Platform-specific buttons as a visible fallback for "download not working" / manual-pick. - Personal donate spiel + Buy Me a Coffee button. - Resources grid: docs, DeepWiki ("got questions? ask AI"), GitHub. - Landing page download section cards now link to /download?platform=X instead of the asset URL directly. - /download/[platform] (used by README/docs links) now redirects to the /download page rather than straight to the asset or to GitHub on error. - Drops unused downloadLinks state from the landing page. Co-Authored-By: Claude Opus 4.7 (1M context) --- landing/src/app/download/[platform]/route.ts | 38 +-- landing/src/app/download/page.tsx | 286 +++++++++++++++++++ landing/src/app/page.tsx | 15 +- 3 files changed, 301 insertions(+), 38 deletions(-) create mode 100644 landing/src/app/download/page.tsx diff --git a/landing/src/app/download/[platform]/route.ts b/landing/src/app/download/[platform]/route.ts index af76c315..6c007c17 100644 --- a/landing/src/app/download/[platform]/route.ts +++ b/landing/src/app/download/[platform]/route.ts @@ -1,12 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { getLatestRelease } from '@/lib/releases'; export const dynamic = 'force-dynamic'; -const PLATFORM_MAP: Record< - string, - keyof Awaited>['downloadLinks'] -> = { +// Pretty URLs from README / docs (e.g. /download/mac-arm) are kept for +// compatibility, but we now always route through the /download page so users +// see context + a donate prompt + resources while the download kicks off. +// The page handles the actual file trigger itself — no more silent redirects +// to GitHub or direct asset URLs. +const PLATFORM_ALIAS: Record = { 'mac-arm': 'macArm', 'mac-intel': 'macIntel', windows: 'windows', @@ -14,29 +15,12 @@ const PLATFORM_MAP: Record< }; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ platform: string }> }, ) { const { platform } = await params; - const key = PLATFORM_MAP[platform]; - - if (!key) { - return NextResponse.json( - { error: `Unknown platform: ${platform}. Use: ${Object.keys(PLATFORM_MAP).join(', ')}` }, - { status: 404 }, - ); - } - - try { - const release = await getLatestRelease(); - const url = release.downloadLinks[key]; - - if (!url) { - return NextResponse.json({ error: `No download available for ${platform}` }, { status: 404 }); - } - - return NextResponse.redirect(url); - } catch { - return NextResponse.redirect(`https://github.com/jamiepine/voicebox/releases/latest`); - } + const normalized = PLATFORM_ALIAS[platform]; + const target = new URL('/download', request.url); + if (normalized) target.searchParams.set('platform', normalized); + return NextResponse.redirect(target, 307); } diff --git a/landing/src/app/download/page.tsx b/landing/src/app/download/page.tsx new file mode 100644 index 00000000..087b9d4d --- /dev/null +++ b/landing/src/app/download/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { + ArrowLeft, + Bot, + Coffee, + Download as DownloadIcon, + FileText, + Github, +} from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons'; +import { Button } from '@/components/ui/button'; +import { DONATE_URL, GITHUB_REPO } from '@/lib/constants'; +import type { DownloadLinks } from '@/lib/releases'; + +type Platform = keyof DownloadLinks; + +type PlatformMeta = { + key: Platform; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; +}; + +const PLATFORMS: PlatformMeta[] = [ + { key: 'macArm', label: 'macOS', description: 'Apple Silicon', icon: AppleIcon }, + { key: 'macIntel', label: 'macOS', description: 'Intel (x64)', icon: AppleIcon }, + { key: 'windows', label: 'Windows', description: '64-bit (MSI)', icon: WindowsIcon }, + { key: 'linux', label: 'Linux', description: 'AppImage', icon: LinuxIcon }, +]; + +function detectPlatform(): Platform | null { + if (typeof navigator === 'undefined') return null; + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return 'windows'; + if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux'; + if (/Mac/i.test(ua)) { + // Apple Silicon Safari reports "Intel" for compat; default to ARM since + // M-series is the majority. Users can click the Intel button if needed. + return 'macArm'; + } + return null; +} + +function parseQueryPlatform(search: string): Platform | null { + const params = new URLSearchParams(search); + const raw = params.get('platform'); + if (!raw) return null; + // Accept both camelCase and hyphenated forms (/download/mac-arm → ?platform=mac-arm). + const normalized = raw + .toLowerCase() + .replace(/[-_\s]/g, '') + .replace('macarm', 'macArm') + .replace('macintel', 'macIntel'); + const valid: Platform[] = ['macArm', 'macIntel', 'windows', 'linux']; + return (valid as string[]).includes(normalized) ? (normalized as Platform) : null; +} + +export default function DownloadPage() { + const [links, setLinks] = useState(null); + const [linksError, setLinksError] = useState(false); + const [platform, setPlatform] = useState(null); + const [triggered, setTriggered] = useState(false); + + useEffect(() => { + const fromQuery = parseQueryPlatform(window.location.search); + setPlatform(fromQuery ?? detectPlatform()); + }, []); + + useEffect(() => { + let cancelled = false; + fetch('/api/releases') + .then((r) => { + if (!r.ok) throw new Error(`releases ${r.status}`); + return r.json(); + }) + .then((data) => { + if (cancelled) return; + if (data.downloadLinks) setLinks(data.downloadLinks as DownloadLinks); + }) + .catch(() => { + if (!cancelled) setLinksError(true); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (triggered || !links || !platform) return; + const url = links[platform]; + if (!url) return; + + const a = document.createElement('a'); + a.href = url; + a.rel = 'noopener'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTriggered(true); + }, [triggered, links, platform]); + + const activeMeta = useMemo( + () => PLATFORMS.find((p) => p.key === platform) ?? null, + [platform], + ); + + return ( +
+ {/* Minimal branded header */} +
+
+ + Voicebox + Voicebox + + + + Back to voicebox.sh + +
+
+ +
+ {/* Hero */} +
+ Voicebox +
+ {triggered ? ( + <> +

+ Your download has started. +

+

+ {activeMeta + ? `Downloading Voicebox for ${activeMeta.label} (${activeMeta.description}). Check your downloads folder.` + : 'Check your downloads folder for Voicebox.'} +

+ + ) : ( + <> +

+ Download Voicebox +

+

+ {linksError + ? "We couldn't reach the release server. Pick your platform manually below." + : 'Pick your platform to get started.'} +

+ + )} +
+
+ + {/* Platform buttons — always visible as a fallback */} + + + {/* Donate — prominent, heartfelt, post-click context */} +
+
+
+
+ + + Hi from the maintainer + +
+

+ Jamie here — Voicebox is a side project. +

+

+ I build and maintain Voicebox in my spare time. It's completely + free, open source, runs entirely on your machine — no accounts, no + cloud, no subscriptions, no upsells. If it saves you an ElevenLabs + bill or just made your day, a coffee genuinely helps me keep + shipping updates, adding new models, and fixing bugs. Every little + bit keeps the lights on. +

+ +
+
+ + {/* Resources */} + +
+
+ ); +} diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx index 2d34c37a..0e9c857c 100644 --- a/landing/src/app/page.tsx +++ b/landing/src/app/page.tsx @@ -17,12 +17,9 @@ import {Navbar} from "@/components/Navbar"; import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons"; import {TutorialsSection} from "@/components/TutorialsSection"; import {VoiceCreator} from "@/components/VoiceCreator"; -import {DOWNLOAD_LINKS, GITHUB_REPO} from "@/lib/constants"; -import type {DownloadLinks} from "@/lib/releases"; +import {GITHUB_REPO} from "@/lib/constants"; export default function Home() { - const [downloadLinks, setDownloadLinks] = - useState(DOWNLOAD_LINKS); const [version, setVersion] = useState(null); const [totalDownloads, setTotalDownloads] = useState(null); @@ -33,7 +30,6 @@ export default function Home() { return res.json(); }) .then((data) => { - if (data.downloadLinks) setDownloadLinks(data.downloadLinks); if (data.version) setVersion(data.version); if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads); }) @@ -403,8 +399,7 @@ export default function Home() {