diff --git a/apps/desktop/src/components/license/activate.tsx b/apps/desktop/src/components/license/activate.tsx index cd65e4df..5a7ad2a8 100644 --- a/apps/desktop/src/components/license/activate.tsx +++ b/apps/desktop/src/components/license/activate.tsx @@ -114,6 +114,16 @@ export function LicenseKeyForm({ ); } +export function KeyDivider({ className }: { className?: string }) { + return ( +
+ + Already have a key? + +
+ ); +} + /** Everything it takes to get a licence: the trial, the plans, a key, and help when the key is lost. */ export function ActivatePanel({ source, @@ -143,14 +153,10 @@ export function ActivatePanel({

- {opened ? "Finish the checkout in your browser, then paste your licence key below." : "7 days free, then $39 a month. Cancel any time."} + {opened ? "Finish the checkout in your browser, then paste your licence key below." : "14 days free, then $39 a month. Cancel any time."}

-
- - Already have a key? - -
+ diff --git a/apps/desktop/src/components/license/license-banner.tsx b/apps/desktop/src/components/license/license-banner.tsx index b694da16..6e19d877 100644 --- a/apps/desktop/src/components/license/license-banner.tsx +++ b/apps/desktop/src/components/license/license-banner.tsx @@ -1,9 +1,9 @@ import { useState, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { differenceInCalendarDays, format } from "date-fns"; -import { CreditCard, KeyRound, Sparkles, X } from "lucide-react"; +import { CreditCard, Gift, KeyRound, Sparkles, X } from "lucide-react"; import type { LicenseState } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api"; @@ -12,6 +12,7 @@ import { openExternal } from "@/lib/desktop"; import { useLicense } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; +import { checkoutUrl } from "./activate"; const DAY_MS = 86_400_000; @@ -22,7 +23,13 @@ interface Notice { tone: "info" | "warning"; icon: ReactNode; text: ReactNode; - action: { label: string; run: () => void } | null; + action: Action | null; + secondary?: Action; +} + +interface Action { + label: string; + run: () => void; } const today = () => format(new Date(), "yyyy-MM-dd"); @@ -41,7 +48,7 @@ function inDays(iso: string): string { return days <= 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; } -function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () => void; refresh: () => void }): Notice | null { +function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () => void; refresh: () => void; trial: () => void }): Notice | null { if (s.blocked) return null; if (s.status === "trial" && s.trialEndsAt) { const left = new Date(s.trialEndsAt).getTime() - Date.now(); @@ -72,9 +79,10 @@ function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () return { kind: "grace", tone: "info", - icon: , - text: <>Add your licence key by {format(new Date(s.graceEndsAt), "MMMM d")}. Until then everything works as before., - action: { label: "Add key", run: actions.settings }, + icon: , + text: <>Free until {format(new Date(s.graceEndsAt), "MMMM d")}. Then add your licence key or start the 14-day free trial., + action: { label: "Start free trial", run: actions.trial }, + secondary: { label: "Add key", run: actions.settings }, }; } if (s.status === "unverified" && s.unverifiedUntil) { @@ -89,9 +97,10 @@ function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () return null; } -/** One slim line above the page when the licence needs attention soon; dismissed, it stays away for the day. */ +/** One slim line above the page when the licence needs attention soon (not on the licence page); dismissed, it stays away for the day. */ export function LicenseBanner() { const navigate = useNavigate(); + const { pathname } = useLocation(); const qc = useQueryClient(); const { data } = useLicense(); const [, setDismissed] = useState(0); @@ -101,9 +110,13 @@ export function LicenseBanner() { settings: () => navigate("/settings/license"), manage: () => data.manageUrl && void openExternal(data.manageUrl), refresh: () => refresh.mutate(), + trial: () => { + navigate("/settings/license"); + void openExternal(checkoutUrl("banner")); + }, }) : null; - const shown = notice && !dismissedToday(notice.kind) ? notice : null; + const shown = notice && !dismissedToday(notice.kind) && !pathname.startsWith("/settings/license") ? notice : null; const dismiss = (kind: Kind) => { try { @@ -135,6 +148,11 @@ export function LicenseBanner() { > {shown.icon}

{shown.text}

+ {shown.secondary && ( + + )} {shown.action && ( ) )} diff --git a/apps/desktop/src/components/onboarding/done-step.tsx b/apps/desktop/src/components/onboarding/done-step.tsx index 0ac2edb3..58f130c0 100644 --- a/apps/desktop/src/components/onboarding/done-step.tsx +++ b/apps/desktop/src/components/onboarding/done-step.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { format } from "date-fns"; import { motion } from "motion/react"; import { ArrowRight, Bot, CircleCheck, CircleDashed, Plug, ShieldCheck } from "lucide-react"; import { MASCOT_CHARACTER, MASCOT_COLOR, type LicenseState } from "@godmode/shared"; @@ -62,10 +63,11 @@ export function DoneStep({ ]; const license = summary.license; const licensed = !!license?.keyHint && !license.blocked && ["active", "trial", "past_due", "unverified"].includes(license.status); + const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? format(new Date(license.graceEndsAt), "MMM d") : null; items.push({ label: "Godmode Pro", - ok: licensed ? true : license?.blocked ? false : null, - detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : license?.status === "grace" ? "Add your key in Settings → License" : "Skipped", + ok: licensed || freeUntil ? true : license?.blocked ? false : null, + detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : freeUntil ? `Free until ${freeUntil}` : "Skipped", }); if (serverMode) items.push({ diff --git a/apps/desktop/src/components/settings/license-section.tsx b/apps/desktop/src/components/settings/license-section.tsx index 62f8dad6..0c09d516 100644 --- a/apps/desktop/src/components/settings/license-section.tsx +++ b/apps/desktop/src/components/settings/license-section.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { format, formatDistanceToNow } from "date-fns"; +import { differenceInCalendarDays, format, formatDistanceToNow } from "date-fns"; import { ExternalLink, KeyRound, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; import type { LicensePlan, LicenseState, LicenseStatus } from "@godmode/shared"; @@ -31,7 +31,7 @@ const STATUS: Record = { const day = (iso: string) => format(new Date(iso), "MMMM d, yyyy"); +function daysLeft(iso: string): string { + const days = differenceInCalendarDays(new Date(iso), new Date()); + return days <= 0 ? "ends today" : days === 1 ? "1 day left" : `${days} days left`; +} + export function LicenseSection() { const license = useLicense(); return ( @@ -98,15 +103,22 @@ function LicenseCard({ state: s }: { state: LicenseState }) { }); if (!s.keyHint) { + const freeWeek = s.status === "grace" && s.graceEndsAt ? s.graceEndsAt : null; return ( <> - {s.message && {s.message}} + {s.message && !freeWeek && {s.message}} } description="Every feature in every plan. Your agents, chats and logins stay on this computer." - actions={{STATUS[s.status].label}} + actions={{status.label}} > + {freeWeek && ( + + {day(freeWeek)} + · {daysLeft(freeWeek)} + + )}
{local ? : Add the licence key in Godmode on the computer itself.}
{!s.enforced && } diff --git a/apps/website/src/components/StickyCta.astro b/apps/website/src/components/StickyCta.astro index 57130b26..1c9dfe70 100644 --- a/apps/website/src/components/StickyCta.astro +++ b/apps/website/src/components/StickyCta.astro @@ -10,6 +10,7 @@ import { COUPON, PRICING } from '@/config/site'; {PRICING.trialDays} days free + diff --git a/apps/website/src/components/sections/Faq.astro b/apps/website/src/components/sections/Faq.astro index 05968320..751834af 100644 --- a/apps/website/src/components/sections/Faq.astro +++ b/apps/website/src/components/sections/Faq.astro @@ -27,8 +27,8 @@ const faqs: { key: string; q: string; a: string }[] = [ }, { key: 'trial', - q: 'How does the free trial work?', - a: `Pick monthly or yearly and start your ${PRICING.trialDays}-day trial with a card, Apple Pay or Google Pay. Nothing is charged today. Cancel in the billing portal (one click from the app or your welcome page) before the ${PRICING.trialDays} days are up and you pay nothing. Otherwise your plan starts automatically when the trial ends: $${monthly.price} a month or $${yearly.price} a year, renewing until you cancel.`, + q: 'Can I try it for free?', + a: `Yes, two ways. Download Godmode and use it free for 7 days: no card, no key, every feature. Or pick monthly or yearly and start your ${PRICING.trialDays}-day trial with a card, Apple Pay or Google Pay. Nothing is charged today. Cancel in the billing portal (one click from the app or your welcome page) before the ${PRICING.trialDays} days are up and you pay nothing. Otherwise your plan starts automatically when the trial ends: $${monthly.price} a month or $${yearly.price} a year, renewing until you cancel. Either way, everything you set up stays when you add your licence key.`, }, { key: 'claude', @@ -68,7 +68,7 @@ const faqs: { key: string; q: string; a: string }[] = [ { key: 'free', q: 'Godmode is open source. Why pay?', - a: `You don’t have to, if you can build software yourself. The whole app is on GitHub under the MIT License, free to build and change. Godmode Pro is for everyone else: the ready-to-install apps for Mac, Windows and Linux, automatic updates, the always-on version, and help from the people who build it. Your subscription also pays for Godmode’s development.`, + a: `You don’t have to, if you can build software yourself. The whole app is on GitHub under the MIT License, free to build and change, and your own build never asks for a key. Godmode Pro is for everyone else: the ready-to-install apps for Mac, Windows and Linux (free for 7 days, then with a Pro key), automatic updates, the always-on version, and help from the people who build it. Your subscription also pays for Godmode’s development.`, }, { key: 'cloud', diff --git a/apps/website/src/components/sections/FinalCta.astro b/apps/website/src/components/sections/FinalCta.astro index c95ea8a9..685768a5 100644 --- a/apps/website/src/components/sections/FinalCta.astro +++ b/apps/website/src/components/sections/FinalCta.astro @@ -27,6 +27,13 @@ import { COUPON, FOUNDING, PRICING, yearlySavings } from '@/config/site'; +

$0 today · price locked for life · founding offer ends in diff --git a/apps/website/src/components/sections/Hero.astro b/apps/website/src/components/sections/Hero.astro index cc733098..14d7473e 100644 --- a/apps/website/src/components/sections/Hero.astro +++ b/apps/website/src/components/sections/Hero.astro @@ -54,6 +54,13 @@ import recording from '@/assets/shots/hero-recording.jpg'; Watch it work +

  • Founder sets up your first {FOUNDING.setupAutomations} automations
  • @@ -62,7 +69,7 @@ import recording from '@/assets/shots/hero-recording.jpg';

- Mac and Windows + Mac, Windows and Linux No coding, just plain words Your passwords stay on your computer

diff --git a/apps/website/src/components/sections/Pricing.astro b/apps/website/src/components/sections/Pricing.astro index 77d70b39..50e5d89e 100644 --- a/apps/website/src/components/sections/Pricing.astro +++ b/apps/website/src/components/sections/Pricing.astro @@ -148,6 +148,16 @@ const guarantees = [ } +
+

+ + Not ready for a card? Download Godmode and use it free for 7 days. Add a licence key when you’re ready, and everything you set up stays. +

+ + Download free + +
+

Everything a founding seat gets you

diff --git a/apps/website/src/lib/entitlement.ts b/apps/website/src/lib/entitlement.ts index 59a68848..2aa1b71b 100644 --- a/apps/website/src/lib/entitlement.ts +++ b/apps/website/src/lib/entitlement.ts @@ -1,29 +1,4 @@ import { env } from 'cloudflare:workers'; -import type { AstroCookies } from 'astro'; -import { db } from './db'; -import { isLicenseKey } from './license'; -import { ORDER_COOKIE, verify } from './signed'; /** While the site runs on Stripe test keys, test-mode purchases count; with live keys only live ones do. */ export const minLivemode = () => (env.STRIPE_SECRET_KEY?.includes('_test_') ? 0 : 1); - -/** True when this browser holds a paid order (signed cookie) or the request carries a paid license key. */ -export async function entitled(cookies: AstroCookies, key: string | null): Promise { - const sessionId = await verify(cookies.get(ORDER_COOKIE)?.value); - if (sessionId) { - const row = await db() - .prepare(`SELECT 1 FROM orders WHERE session_id = ? AND status = 'paid' AND livemode >= ?`) - .bind(sessionId, minLivemode()) - .first(); - if (row) return true; - } - const normalized = key?.trim().toUpperCase() ?? ''; - if (isLicenseKey(normalized)) { - const row = await db() - .prepare(`SELECT 1 FROM orders WHERE license_key = ? AND status = 'paid' AND livemode >= ?`) - .bind(normalized, minLivemode()) - .first(); - if (row) return true; - } - return false; -} diff --git a/apps/website/src/pages/download/[platform].ts b/apps/website/src/pages/download/[platform].ts index d1718b55..4f23626c 100644 --- a/apps/website/src/pages/download/[platform].ts +++ b/apps/website/src/pages/download/[platform].ts @@ -1,26 +1,15 @@ import type { APIRoute } from 'astro'; import { assetRedirect, latestRelease, PLATFORMS } from '@/lib/releases'; -import { entitled } from '@/lib/entitlement'; import { allowed } from '@/lib/limit'; -import { keyFromCookie, setKeyCookie } from '@/lib/signed'; export const prerender = false; -/** /download/mac, /download/windows — licensed customers only (order cookie, key cookie, or a legacy ?key=). */ -export const GET: APIRoute = async ({ params, url, cookies, request }) => { +/** /download/mac, /download/windows, … — the latest release's file for that platform. No key needed: the app runs free for 7 days. */ +export const GET: APIRoute = async ({ params, request }) => { const platform = params.platform ?? ''; + if (!Object.hasOwn(PLATFORMS, platform)) return new Response('Unknown platform', { status: 404 }); const target = PLATFORMS[platform]; - if (!target) return new Response('Unknown platform', { status: 404 }); if (!(await allowed('CHECKOUT_LIMIT', request))) return new Response('Too many requests', { status: 429 }); - - const queryKey = url.searchParams.get('key'); - if (queryKey) await setKeyCookie(cookies, queryKey, url.protocol === 'https:'); - const key = queryKey ?? (await keyFromCookie(cookies)); - if (!(await entitled(cookies, key))) { - const back = new URLSearchParams({ platform }); - if (key) back.set('invalid', '1'); - return new Response(null, { status: 303, headers: { location: `/download?${back}`, 'cache-control': 'no-store' } }); - } try { const release = await latestRelease(); const asset = release.assets.find((a) => target.match.test(a.name)); diff --git a/apps/website/src/pages/download/file/[name].ts b/apps/website/src/pages/download/file/[name].ts index 53f3269b..d6a93572 100644 --- a/apps/website/src/pages/download/file/[name].ts +++ b/apps/website/src/pages/download/file/[name].ts @@ -1,6 +1,5 @@ import type { APIRoute } from 'astro'; import { assetRedirect, assetText, releaseFor } from '@/lib/releases'; -import { entitled } from '@/lib/entitlement'; import { allowed } from '@/lib/limit'; export const prerender = false; @@ -8,14 +7,11 @@ export const prerender = false; // The headless server binaries and their checksums, as fetched by /install.sh. const SERVER_FILE = /^godmode-(darwin|linux|windows)-(x64|arm64|x64-baseline)(\.exe)?(\.sha256)?$/; -/** /download/file/godmode-linux-x64?key=GM-…[&version=v0.2.0] — licensed customers only. */ -export const GET: APIRoute = async ({ params, url, cookies, request }) => { +/** /download/file/godmode-linux-x64[?version=v0.2.0]: only these release files, no key needed. */ +export const GET: APIRoute = async ({ params, url, request }) => { const name = params.name ?? ''; if (!SERVER_FILE.test(name)) return new Response('Not found', { status: 404 }); if (!(await allowed('CHECKOUT_LIMIT', request))) return new Response('Too many requests', { status: 429 }); - if (!(await entitled(cookies, url.searchParams.get('key')))) { - return new Response('A valid license key is required (GODMODE_LICENSE).', { status: 403 }); - } try { const release = await releaseFor(url.searchParams.get('version')); const asset = release.assets.find((a) => a.name === name); diff --git a/apps/website/src/pages/download/index.astro b/apps/website/src/pages/download/index.astro index 2b3af530..287d0151 100644 --- a/apps/website/src/pages/download/index.astro +++ b/apps/website/src/pages/download/index.astro @@ -25,74 +25,149 @@ const ua = Astro.request.headers.get('user-agent') ?? ''; const phone = /iPhone|iPad|iPod|Android/i.test(ua); const guess = /Windows/i.test(ua) ? 'windows' : /Linux/i.test(ua) && !phone ? 'linux' : 'mac'; const asked = Astro.url.searchParams.get('platform') ?? ''; -const selected = PLATFORMS[asked] ? asked : guess; -const invalid = Astro.url.searchParams.has('invalid'); +const selected = Object.hasOwn(PLATFORMS, asked) ? asked : guess; + +const apps = [ + { id: 'mac', name: 'macOS', note: 'Apple silicon', icon: 'apple' }, + { id: 'windows', name: 'Windows', note: '64-bit installer', icon: 'monitor' }, + { id: 'linux', name: 'Linux', note: 'AppImage', icon: 'terminal' }, +]; +const more = [ + { id: 'mac-intel', label: 'macOS Intel' }, + { id: 'windows-msi', label: 'Windows MSI' }, + { id: 'linux-deb', label: '.deb' }, + { id: 'linux-rpm', label: '.rpm' }, +]; +const servers = Object.entries(PLATFORMS) + .filter(([id]) => id.startsWith('server-')) + .map(([id, p]) => ({ id, label: p.label.replace('Headless server · ', '') })); +const install = `curl -fsSL ${SITE.url}/install.sh | sh`; +const pageUrl = `${SITE.url}/download`; +const handoff = `mailto:?subject=${encodeURIComponent('Download Godmode')}&body=${encodeURIComponent(`Godmode for Mac, Windows and Linux, free for 7 days:\n${pageUrl}\n`)}`; Astro.response.headers.set('cache-control', 'no-store'); Astro.response.headers.set('referrer-policy', 'same-origin'); --- - +