From 4079ac37f5d6d99dad3318638b28944bbd256b72 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:10:31 -0700 Subject: [PATCH 01/50] feat(web): add annual Pro pricing --- skills/cmux-billing/SKILL.md | 2 +- web/app/[locale]/components/pro-cta-link.tsx | 14 ++- web/app/[locale]/dashboard/billing/page.tsx | 76 +++++++++++- web/app/[locale]/posthog.tsx | 13 +-- web/app/[locale]/pricing/page.tsx | 59 +++++++++- web/app/api/billing/checkout/route.ts | 10 +- web/app/app-pricing/page.tsx | 104 ++++++++++++++++- web/app/components/checkout-navigation.tsx | 9 ++ .../components/pricing-interval-selector.tsx | 109 ++++++++++++++++++ web/app/components/pricing-shared.tsx | 5 + web/app/lib/billing.ts | 11 ++ web/app/lib/posthog-client.ts | 16 +++ web/messages/en.json | 8 +- web/messages/ja.json | 8 +- web/scripts/stripe/provision-live.sh | 23 +++- web/services/billing/plans.ts | 30 +++++ web/services/billing/stripe.ts | 13 +-- web/tests/app-pricing-page.test.tsx | 21 ++++ web/tests/billing-checkout-route.test.ts | 19 ++- web/tests/billing-links.test.ts | 17 +++ web/tests/dashboard-billing-page.test.tsx | 37 +++++- web/tests/pricing-page.test.tsx | 29 ++++- web/tests/pro-pricing.test.ts | 38 ++++++ 23 files changed, 617 insertions(+), 54 deletions(-) create mode 100644 web/app/components/pricing-interval-selector.tsx create mode 100644 web/app/lib/posthog-client.ts create mode 100644 web/services/billing/plans.ts create mode 100644 web/tests/pro-pricing.test.ts diff --git a/skills/cmux-billing/SKILL.md b/skills/cmux-billing/SKILL.md index f31a24aae65a..cff245fc4d4e 100644 --- a/skills/cmux-billing/SKILL.md +++ b/skills/cmux-billing/SKILL.md @@ -26,7 +26,7 @@ Read before changing billing, pricing, Stripe, Pro entitlement, checkout, webhoo ## Test-mode resources -Product `prod_UpIQRE6cj0nFjs`. Lookup keys `cmux-pro-monthly` ($30/mo) and `cmux-pro-yearly` ($240/yr). Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. +Product `prod_UpIQRE6cj0nFjs`. New checkouts use `cmux-pro-monthly` ($30/mo) and `cmux-pro-yearly-288` ($288/yr, equivalent to $24/mo). Keep `cmux-pro-yearly` ($240/yr) active for grandfathered subscriptions. Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. ## Feature flags diff --git a/web/app/[locale]/components/pro-cta-link.tsx b/web/app/[locale]/components/pro-cta-link.tsx index 9834aa21b9db..ebc452f305de 100644 --- a/web/app/[locale]/components/pro-cta-link.tsx +++ b/web/app/[locale]/components/pro-cta-link.tsx @@ -1,6 +1,6 @@ "use client"; -import posthog from "posthog-js"; +import { posthog } from "../../lib/posthog-client"; import { pricingActionClassName, type PricingActionSize, @@ -9,19 +9,26 @@ import { CheckoutPendingContent, useCheckoutRedirect, } from "../../components/checkout-navigation"; +import { + PRO_PRICING_USD, + type ProBillingInterval, +} from "../../../services/billing/plans"; export function ProCtaLink({ checkoutHref, children, size = "default", location = "pricing_page", + interval = "month", }: { checkoutHref: string; children: React.ReactNode; size?: PricingActionSize; location?: string; + interval?: ProBillingInterval; }) { const { pending, start } = useCheckoutRedirect(); + const pricing = PRO_PRICING_USD[interval]; return ( + ) : !status.isPro ? ( ) : subscription ? ( @@ -213,9 +228,11 @@ function FreePlan({ t }: { t: Awaited> }) { function FreePlanUpsell({ t, pricingT, + interval, }: { t: Awaited>; pricingT: Awaited>; + interval: "month" | "year"; }) { const proFeatures = visibleProFeatures({ base: pricingT.raw("pro.features") as string[], @@ -223,6 +240,17 @@ function FreePlanUpsell({ hostedNetworking: pricingT.raw("pro.hostedNetworkingFeatures") as string[], }); const teamFeatures = pricingT.raw("team.features") as string[]; + const proPricing = PRO_PRICING_USD[interval]; + const proCheckoutURL = withCheckoutInterval(PRO_CHECKOUT_URL, interval); + const proPrice = interval === "year" + ? `$${proPricing.monthlyEquivalent}` + : pricingT("pro.price"); + const annualPriceDetail = interval === "year" + ? pricingT("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + }) + : undefined; return (
@@ -235,14 +263,43 @@ function FreePlanUpsell({

{t("free.upsellTitle")}

{t("free.upsellBody")}

+
- {pricingT("pro.cta")} + + {pricingT("pro.cta")} +

{pricingT("pro.featuresLead")}

@@ -344,6 +401,9 @@ function StripePlan({ )} {canManageBilling ? ( + // This API route creates a Stripe portal session and must perform a + // full document navigation rather than a Next.js client transition. + // eslint-disable-next-line @next/next/no-html-link-for-pages
; + searchParams?: Promise>; }) { const { locale } = await params; + const query = searchParams ? await searchParams : {}; const t = await getTranslations({ locale, namespace: "pricing" }); const snapshot = await currentPlanSnapshot(); + const interval = proBillingInterval(firstParam(query.interval)); + const proPricing = PRO_PRICING_USD[interval]; + const proCheckoutURL = withCheckoutInterval(PRO_CHECKOUT_URL, interval); + const annualPriceDetail = interval === "year" + ? t("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + }) + : undefined; + const proPrice = interval === "year" + ? `$${proPricing.monthlyEquivalent}` + : t("pro.price"); + const compareProPrice = interval === "year" + ? t("annualComparePrice", { + monthly: PRO_PRICING_USD.year.monthlyEquivalent, + annual: PRO_PRICING_USD.year.billedAmount, + }) + : `${t("pro.price")}${t("perMonth")}`; const freeFeatures = t.raw("free.features") as string[]; const proBaseFeatures = t.raw("pro.features") as string[]; @@ -114,6 +144,18 @@ export default async function PricingPage({ {/* Title */}

{t("title")}

+ {/* Tier cards */}
@@ -133,8 +175,9 @@ export default async function PricingPage({ {/* Pro */} {t("currentPlan")} @@ -149,7 +192,7 @@ export default async function PricingPage({
) : ( - + {t("pro.cta")} )} @@ -197,7 +240,7 @@ export default async function PricingPage({ }} prices={{ free: t("free.price"), - pro: `${t("pro.price")}${t("perMonth")}`, + pro: compareProPrice, team: `${t("team.price")}${t("perUserMonth")}`, enterprise: t("enterprise.price"), }} @@ -212,9 +255,10 @@ export default async function PricingPage({ {t("currentPlan")} ) : ( {t("pro.cta")} @@ -311,3 +355,8 @@ async function currentPlanSnapshot(): Promise<{ const status = await resolveProPlanStatus(user); return { isPro: status.isPro, billingManagement: status.billingManagement }; } + +function firstParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts index 63e01ab7e6ab..5c8dbcb03b7d 100644 --- a/web/app/api/billing/checkout/route.ts +++ b/web/app/api/billing/checkout/route.ts @@ -19,8 +19,8 @@ import { resolveProPrice, resolveTeamPrice, stripe, - type ProBillingInterval, } from "../../../../services/billing/stripe"; +import { proBillingInterval } from "../../../../services/billing/plans"; export const dynamic = "force-dynamic"; @@ -100,15 +100,17 @@ async function stripeProCheckout( request.nextUrl.searchParams.get("cmux_scheme"), request, ); - const interval = checkoutInterval(request.nextUrl.searchParams.get("interval")); + const interval = proBillingInterval(request.nextUrl.searchParams.get("interval")); const successUrl = `${request.nextUrl.origin}/api/billing/complete` + `?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`; const cancelUrl = new URL("/pricing?billing=cancelled", request.nextUrl.origin); + cancelUrl.searchParams.set("interval", interval); const metadata = { stackUserId: user.id, plan: "pro", app: "cmux", + billingInterval: interval, }; try { @@ -303,10 +305,6 @@ function checkoutPlan(raw: string | null): "pro" | "team" | null { return null; } -function checkoutInterval(raw: string | null): ProBillingInterval { - return raw === "year" ? "year" : "month"; -} - async function checkoutStackServerApp(): Promise { const { getStackServerApp, isStackConfigured } = await import("../../../lib/stack"); if (!isStackConfigured()) return null; diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx index 7ee86736ff27..094a02bbc4aa 100644 --- a/web/app/app-pricing/page.tsx +++ b/web/app/app-pricing/page.tsx @@ -29,6 +29,11 @@ import { type SizeRow, } from "../components/pricing-shared"; import { CheckoutButton } from "../components/checkout-navigation"; +import { PricingIntervalSelector } from "../components/pricing-interval-selector"; +import { + PRO_PRICING_USD, + proBillingInterval, +} from "../../services/billing/plans"; const ENTERPRISE_CTA_URL = "/enterprise"; const pricing = enMessages.pricing; @@ -52,7 +57,14 @@ export default async function AppPricingPage({ appPricingRequest(headersList), ); const appStorePaymentGated = isAppStoreDistributionMode(params); - const proCheckoutURL = appPricingCheckoutURL("pro", requestOrigin, cmuxScheme); + const interval = proBillingInterval(firstParam(params.interval)); + const proPricing = PRO_PRICING_USD[interval]; + const proCheckoutURL = appPricingCheckoutURL( + "pro", + requestOrigin, + cmuxScheme, + interval, + ); const teamCheckoutURL = appPricingCheckoutURL("team", requestOrigin, cmuxScheme); const banner = appPricingBanner(params); const appearance = appPricingAppearance(params); @@ -65,6 +77,33 @@ export default async function AppPricingPage({ const compareRows = visibleCompareRows(pricing.compare.rows as CompareRow[]); const sizeRows = pricing.sizes.rows as SizeRow[]; const faqItems = visibleFaqItems(pricing.faq.items as FaqItem[]); + const annualPriceDetail = interval === "year" + ? pricingMessage(pricing.annualPriceDetail, { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + }) + : undefined; + const proPrice = interval === "year" + ? `$${proPricing.monthlyEquivalent}` + : pricing.pro.price; + const compareProPrice = interval === "year" + ? pricingMessage(pricing.annualComparePrice, { + monthly: PRO_PRICING_USD.year.monthlyEquivalent, + annual: PRO_PRICING_USD.year.billedAmount, + }) + : `${pricing.pro.price}${pricing.perMonth}`; + const checkoutAnalytics = { + event: "cmuxterm_pro_cta_clicked", + properties: { + location: "app_pricing", + checkout: true, + interval, + currency: "usd", + billed_amount_usd: proPricing.billedAmount, + monthly_equivalent_usd: proPricing.monthlyEquivalent, + discount_percent: proPricing.discountPercent, + }, + }; return ( <> @@ -82,6 +121,18 @@ export default async function AppPricingPage({ {banner ? : null}

{pricing.title}

+
{pricing.currentPlan} @@ -129,7 +181,12 @@ export default async function AppPricingPage({ ) : appStorePaymentGated ? ( {pricing.billingUnavailable} ) : ( - {pricing.pro.cta} + + {pricing.pro.cta} + )}

{pricing.pro.featuresLead} @@ -183,7 +240,7 @@ export default async function AppPricingPage({ }} prices={{ free: pricing.free.price, - pro: `${pricing.pro.price}${pricing.perMonth}`, + pro: compareProPrice, team: `${pricing.team.price}${pricing.perUserMonth}`, enterprise: pricing.enterprise.price, }} @@ -201,7 +258,17 @@ export default async function AppPricingPage({ ) : appStorePaymentGated ? ( {pricing.billingUnavailable} ) : ( - + {pricing.pro.cta} ), @@ -331,6 +398,33 @@ function firstParam(value: string | string[] | undefined): string | null { return value ?? null; } +function appPricingIntervalHref( + params: Record, + interval: "month" | "year", +): string { + const next = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) next.append(key, item); + } else if (value !== undefined) { + next.set(key, value); + } + } + next.set("cmux_app", "1"); + next.set("interval", interval); + return `?${next.toString()}`; +} + +function pricingMessage( + message: string, + values: Record, +): string { + return message.replace(/\{(\w+)\}/g, (match, key: string) => { + const value = values[key]; + return value === undefined ? match : String(value); + }); +} + function BillingBanner({ banner }: { banner: BillingBannerModel }) { return (

) => void; + analytics?: { + event: string; + properties: Record; + }; }) { const { pending, start } = useCheckoutRedirect(); return ( @@ -120,6 +126,9 @@ export function CheckoutButton({ href={href} onClick={(event) => { onClick?.(event); + if (!event.defaultPrevented && analytics) { + posthog.capture(analytics.event, analytics.properties); + } start(href, event); }} aria-busy={pending} diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx new file mode 100644 index 000000000000..611a162f7a5f --- /dev/null +++ b/web/app/components/pricing-interval-selector.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useRef } from "react"; + +import { posthog } from "../lib/posthog-client"; +import { + PRO_PRICING_USD, + type ProBillingInterval, +} from "../../services/billing/plans"; + +type PricingSurface = "public_pricing" | "app_pricing" | "dashboard_billing"; + +export function PricingIntervalSelector({ + interval, + monthlyHref, + annualHref, + billingPeriodLabel, + monthlyLabel, + annualLabel, + savingsLabel, + surface, +}: { + interval: ProBillingInterval; + monthlyHref: string; + annualHref: string; + billingPeriodLabel: string; + monthlyLabel: string; + annualLabel: string; + savingsLabel: string; + surface: PricingSurface; +}) { + const capturedView = useRef(false); + const captureView = useCallback( + (node: HTMLDivElement | null) => { + if (!node || capturedView.current) return; + capturedView.current = true; + capturePricingEvent("cmuxterm_pricing_viewed", interval, surface); + }, + [interval, surface], + ); + + return ( +
+ capturePricingEvent("cmuxterm_pricing_interval_selected", "month", surface)} + > + {monthlyLabel} + + capturePricingEvent("cmuxterm_pricing_interval_selected", "year", surface)} + > + {annualLabel} + {savingsLabel} + +
+ ); +} + +function IntervalLink({ + href, + selected, + onSelect, + children, +}: { + href: string; + selected: boolean; + onSelect: () => void; + children: React.ReactNode; +}) { + return ( +
+ {children} + + ); +} + +function capturePricingEvent( + event: "cmuxterm_pricing_viewed" | "cmuxterm_pricing_interval_selected", + interval: ProBillingInterval, + surface: PricingSurface, +) { + const pricing = PRO_PRICING_USD[interval]; + posthog.capture(event, { + surface, + interval, + currency: "usd", + billed_amount_usd: pricing.billedAmount, + monthly_equivalent_usd: pricing.monthlyEquivalent, + discount_percent: pricing.discountPercent, + }); +} diff --git a/web/app/components/pricing-shared.tsx b/web/app/components/pricing-shared.tsx index 002b1824399a..07e497941d4a 100644 --- a/web/app/components/pricing-shared.tsx +++ b/web/app/components/pricing-shared.tsx @@ -66,12 +66,14 @@ export function PlanCard({ name, price, period, + priceDetail, badge, children, }: { name: string; price: string; period?: string; + priceDetail?: string; badge?: ReactNode; children: ReactNode; }) { @@ -83,6 +85,9 @@ export function PlanCard({ {price} {period ? {period} : null}
+ {priceDetail ? ( +

{priceDetail}

+ ) : null}
{children}
); diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts index 89f270a4dcfb..be6d8471ef2f 100644 --- a/web/app/lib/billing.ts +++ b/web/app/lib/billing.ts @@ -1,8 +1,10 @@ export const CHECKOUT_EXTERNAL_BROWSER_PARAM = "cmux_external_browser"; export const CHECKOUT_NATIVE_SCHEME_PARAM = "cmux_scheme"; export const CHECKOUT_PLAN_PARAM = "plan"; +export const CHECKOUT_INTERVAL_PARAM = "interval"; export const CHECKOUT_PATH = "/api/billing/checkout"; export type CheckoutPlan = "pro" | "team"; +export type CheckoutInterval = "month" | "year"; export const PRO_CHECKOUT_PATH = withCheckoutPlan(CHECKOUT_PATH, "pro"); export const TEAM_CHECKOUT_PATH = withCheckoutPlan(CHECKOUT_PATH, "team"); export const PRO_CHECKOUT_URL = withCheckoutExternalBrowserIntent(PRO_CHECKOUT_PATH); @@ -20,15 +22,24 @@ export function withCheckoutPlan(href: string, plan: CheckoutPlan): string { return withSearchParam(href, CHECKOUT_PLAN_PARAM, plan); } +export function withCheckoutInterval( + href: string, + interval: CheckoutInterval, +): string { + return withSearchParam(href, CHECKOUT_INTERVAL_PARAM, interval); +} + export function appPricingCheckoutURL( plan: CheckoutPlan, requestOrigin: string | null, cmuxScheme?: string | null, + interval?: CheckoutInterval, ): string { let href = withCheckoutExternalBrowserIntent( withCheckoutPlan(configuredAppPricingCheckoutURL(requestOrigin), plan), ); if (cmuxScheme) href = withSearchParam(href, CHECKOUT_NATIVE_SCHEME_PARAM, cmuxScheme); + if (plan === "pro" && interval) href = withCheckoutInterval(href, interval); return href; } diff --git a/web/app/lib/posthog-client.ts b/web/app/lib/posthog-client.ts new file mode 100644 index 000000000000..c2a8165cb641 --- /dev/null +++ b/web/app/lib/posthog-client.ts @@ -0,0 +1,16 @@ +"use client"; + +import posthog from "posthog-js"; + +if (typeof window !== "undefined") { + posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", { + api_host: "https://r.cmux.com", + ui_host: "https://us.posthog.com", + person_profiles: "identified_only", + capture_pageview: false, + capture_pageleave: true, + advanced_disable_feature_flags: true, + }); +} + +export { posthog }; diff --git a/web/messages/en.json b/web/messages/en.json index 7d37b43fac0e..43ea6fd07502 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -400,6 +400,12 @@ "title": "Pricing", "perMonth": "/month", "perUserMonth": "/user/month", + "billingPeriod": "Billing period", + "monthly": "Monthly", + "annual": "Annual", + "saveAnnual": "Save {discount}%", + "annualPriceDetail": "Billed ${amount} annually · save {discount}%", + "annualComparePrice": "${monthly}/month · ${annual}/year", "currentPlan": "Current plan", "manageBilling": "Manage billing", "free": { @@ -657,7 +663,7 @@ }, { "q": "How does billing work?", - "a": "Pro is $30 per month, or $240 per year (about $20 per month billed annually) and includes 20 active compute-hours per month on the 4 vCPU / 16 GB Cloud VM. Beyond that, cloud usage is billed per active compute-hour by VM size (see Cloud VM sizes above). Idle VMs suspend automatically, and running agents in parallel just uses more hours. The terminal and local agents stay free." + "a": "Pro is $30 per month, or $288 per year ($24 per month billed annually, a 20% savings), and includes 20 active compute-hours per month on the 4 vCPU / 16 GB Cloud VM. Beyond that, cloud usage is billed per active compute-hour by VM size (see Cloud VM sizes above). Idle VMs suspend automatically, and running agents in parallel just uses more hours. The terminal and local agents stay free." }, { "q": "Can I self-host the cloud?", diff --git a/web/messages/ja.json b/web/messages/ja.json index 632d4e6ee471..2928943f2a9a 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -400,6 +400,12 @@ "title": "料金", "perMonth": "/月", "perUserMonth": "/ユーザー/月", + "billingPeriod": "請求期間", + "monthly": "月払い", + "annual": "年払い", + "saveAnnual": "{discount}%割引", + "annualPriceDetail": "年額 ${amount} の一括払い · {discount}%割引", + "annualComparePrice": "${monthly}/月 · ${annual}/年", "currentPlan": "現在のプラン", "manageBilling": "請求を管理", "free": { @@ -657,7 +663,7 @@ }, { "q": "課金はどのように行われますか?", - "a": "Pro は月額 $30、または年額 $240(年払いでは月あたり約 $20)で、4 vCPU / 16 GB の Cloud VM で毎月20時間のアクティブ計算時間が含まれます。それを超えると、VM サイズに応じてアクティブな計算時間あたりで課金されます(上記の Cloud VM サイズを参照)。アイドルの VM は自動的に一時停止し、エージェントを並列実行するとより多くの時間を使うだけです。ターミナルとローカルエージェントは無料のままです。" + "a": "Pro は月額 $30、または年額 $288(年払いでは月あたり $24、20%割引)で、4 vCPU / 16 GB の Cloud VM で毎月20時間のアクティブ計算時間が含まれます。それを超えると、VM サイズに応じてアクティブな計算時間あたりで課金されます(上記の Cloud VM サイズを参照)。アイドルの VM は自動的に一時停止し、エージェントを並列実行するとより多くの時間を使うだけです。ターミナルとローカルエージェントは無料のままです。" }, { "q": "クラウドをセルフホストできますか?", diff --git a/web/scripts/stripe/provision-live.sh b/web/scripts/stripe/provision-live.sh index 1cff29be50ca..ebd2d0ab2ee7 100755 --- a/web/scripts/stripe/provision-live.sh +++ b/web/scripts/stripe/provision-live.sh @@ -86,7 +86,8 @@ ensure_price() { local unit_amount="$2" local interval="$3" local nickname="$4" - local response price_id + local response price_id existing_amount existing_currency existing_interval + local existing_product existing_active response="$( stripe_get "/prices" \ @@ -96,6 +97,21 @@ ensure_price() { price_id="$(jq -r '.data[0].id // empty' <<<"$response")" if [[ -n "$price_id" ]]; then + existing_amount="$(jq -r '.data[0].unit_amount // empty' <<<"$response")" + existing_currency="$(jq -r '.data[0].currency // empty' <<<"$response")" + existing_interval="$(jq -r '.data[0].recurring.interval // empty' <<<"$response")" + existing_product="$(jq -r '.data[0].product // empty' <<<"$response")" + existing_active="$(jq -r '.data[0].active // false' <<<"$response")" + if [[ + "$existing_amount" != "$unit_amount" || + "$existing_currency" != "usd" || + "$existing_interval" != "$interval" || + "$existing_product" != "$product_id" || + "$existing_active" != "true" + ]]; then + echo "Price ${lookup_key} exists with unexpected configuration: ${price_id}" >&2 + exit 1 + fi echo "Found price ${lookup_key}: ${price_id}" return 0 fi @@ -114,7 +130,10 @@ ensure_price() { } ensure_price "cmux-pro-monthly" "3000" "month" "cmux Pro Monthly" -ensure_price "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly" +# Keep the original $240 annual Price active for existing subscribers. Stripe +# Price amounts are immutable, so new annual checkouts use a new lookup key. +ensure_price "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly (Legacy)" +ensure_price "cmux-pro-yearly-288" "28800" "year" "cmux Pro Yearly" webhooks_response="$(stripe_get "/webhook_endpoints" --data-urlencode "limit=100")" webhook_id="$( diff --git a/web/services/billing/plans.ts b/web/services/billing/plans.ts new file mode 100644 index 000000000000..e952f6eee103 --- /dev/null +++ b/web/services/billing/plans.ts @@ -0,0 +1,30 @@ +export type ProBillingInterval = "month" | "year"; + +export const PRO_PRICING_USD = { + month: { + billedAmount: 30, + monthlyEquivalent: 30, + discountPercent: 0, + lookupKey: "cmux-pro-monthly", + }, + year: { + billedAmount: 288, + monthlyEquivalent: 24, + discountPercent: 20, + lookupKey: "cmux-pro-yearly-288", + }, +} as const satisfies Record< + ProBillingInterval, + { + billedAmount: number; + monthlyEquivalent: number; + discountPercent: number; + lookupKey: string; + } +>; + +export const LEGACY_PRO_YEARLY_LOOKUP_KEY = "cmux-pro-yearly"; + +export function proBillingInterval(value: string | null | undefined): ProBillingInterval { + return value === "year" ? "year" : "month"; +} diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts index 15eba657ae80..b7b0521bffa8 100644 --- a/web/services/billing/stripe.ts +++ b/web/services/billing/stripe.ts @@ -1,13 +1,12 @@ import Stripe from "stripe"; import { env } from "../../app/env"; +import { + PRO_PRICING_USD, + type ProBillingInterval, +} from "./plans"; -export type ProBillingInterval = "month" | "year"; - -const PRO_PRICE_LOOKUP_KEYS: Record = { - month: "cmux-pro-monthly", - year: "cmux-pro-yearly", -}; +export type { ProBillingInterval } from "./plans"; const TEAM_PRICE_LOOKUP_KEY = "cmux-team-monthly"; let stripeClient: Stripe | null = null; @@ -37,7 +36,7 @@ export async function resolveProPrice(interval: ProBillingInterval): Promise { expect(html).not.toContain("/api/billing/portal"); }); + test("renders annual pricing and preserves native checkout context", async () => { + const element = await AppPricingPage({ + searchParams: Promise.resolve({ + cmux_app: "1", + cmux_scheme: "cmux-dev-test", + appearance: "dark", + interval: "year", + }), + }); + const html = renderToStaticMarkup(element); + + expect(html).toContain("$24"); + expect(html).toContain("Billed $288 annually · save 20%"); + expect(html).toContain( + "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year", + ); + expect(html).toContain( + "?cmux_app=1&cmux_scheme=cmux-dev-test&appearance=dark&interval=month", + ); + }); + test("removes external purchase links in App Store distribution mode", async () => { const element = await AppPricingPage({ searchParams: Promise.resolve({ diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts index b079116dfc38..28467374de25 100644 --- a/web/tests/billing-checkout-route.test.ts +++ b/web/tests/billing-checkout-route.test.ts @@ -203,15 +203,25 @@ describe("billing checkout route", () => { mode: "subscription", line_items: [{ price: "price_month", quantity: 1 }], client_reference_id: "user-anonymous", - metadata: { stackUserId: "user-anonymous", plan: "pro", app: "cmux" }, + metadata: { + stackUserId: "user-anonymous", + plan: "pro", + app: "cmux", + billingInterval: "month", + }, subscription_data: { - metadata: { stackUserId: "user-anonymous", plan: "pro", app: "cmux" }, + metadata: { + stackUserId: "user-anonymous", + plan: "pro", + app: "cmux", + billingInterval: "month", + }, }, allow_promotion_codes: true, customer_email: undefined, success_url: "https://cmux.test/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux", - cancel_url: "https://cmux.test/pricing?billing=cancelled", + cancel_url: "https://cmux.test/pricing?billing=cancelled&interval=month", }); }); @@ -256,6 +266,9 @@ describe("billing checkout route", () => { expect(createdStripeSessions[0]).toMatchObject({ customer_email: "signed@example.com", line_items: [{ price: "price_year", quantity: 1 }], + metadata: { billingInterval: "year" }, + subscription_data: { metadata: { billingInterval: "year" } }, + cancel_url: "https://cmux.test/pricing?billing=cancelled&interval=year", }); }); diff --git a/web/tests/billing-links.test.ts b/web/tests/billing-links.test.ts index d97e7eebfe25..39c3964aa1fd 100644 --- a/web/tests/billing-links.test.ts +++ b/web/tests/billing-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { appPricingCheckoutURL, + withCheckoutInterval, withCheckoutExternalBrowserIntent, } from "../app/lib/billing"; @@ -36,6 +37,22 @@ describe("billing links", () => { ); }); + test("annual Pro checkout carries the selected billing interval", () => { + expect(withCheckoutInterval("/api/billing/checkout?plan=pro", "year")).toBe( + "/api/billing/checkout?plan=pro&interval=year", + ); + expect( + appPricingCheckoutURL( + "pro", + "http://localhost:9210", + "cmux-dev-test", + "year", + ), + ).toBe( + "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year", + ); + }); + test("app pricing checkout keeps an explicit origin override", () => { const previous = process.env.CMUX_APP_PRICING_CHECKOUT_URL; process.env.CMUX_APP_PRICING_CHECKOUT_URL = "https://billing.example/checkout"; diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx index 9cbcd2253348..bd550508e24d 100644 --- a/web/tests/dashboard-billing-page.test.tsx +++ b/web/tests/dashboard-billing-page.test.tsx @@ -96,7 +96,9 @@ describe("dashboard billing page", () => { expect(html).toContain("Free"); expect(html).toContain("You are currently on the Free plan."); expect(html).toContain("Upgrade when you need cloud agents or team billing."); - expect(html).toContain('href="/api/billing/checkout?plan=pro&cmux_external_browser=1"'); + expect(html).toContain( + 'href="/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=month"', + ); expect(html).toContain('href="/api/billing/checkout?plan=team&cmux_external_browser=1"'); expect(html).toContain("Get Pro"); expect(html).toContain("Get Teams"); @@ -105,6 +107,16 @@ describe("dashboard billing page", () => { expect(html).not.toContain("/api/billing/subscription"); }); + test("renders annual Pro pricing from the billing upsell", async () => { + const html = await renderBillingPage({ interval: "year" }); + + expect(html).toContain("$24"); + expect(html).toContain("Billed $288 annually · save 20%"); + expect(html).toContain( + 'href="/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=year"', + ); + }); + test("renders active Stripe Pro with cancel and portal actions", async () => { subscriptionRows = [stripeSubscriptionRow({ cancelAtPeriodEnd: false })]; customerRows = [{ id: "cus_123" }]; @@ -119,6 +131,25 @@ describe("dashboard billing page", () => { expect(html).toContain('href="/api/billing/portal"'); }); + test("labels new and grandfathered annual Stripe prices", async () => { + subscriptionRows = [ + stripeSubscriptionRow({ + cancelAtPeriodEnd: false, + lookupKey: "cmux-pro-yearly-288", + }), + ]; + customerRows = [{ id: "cus_123" }]; + expect(await renderBillingPage()).toContain("$288/year"); + + subscriptionRows = [ + stripeSubscriptionRow({ + cancelAtPeriodEnd: false, + lookupKey: "cmux-pro-yearly", + }), + ]; + expect(await renderBillingPage()).toContain("$240/year"); + }); + test("renders pending cancellation with resume and end-date copy", async () => { subscriptionRows = [stripeSubscriptionRow({ cancelAtPeriodEnd: true })]; customerRows = [{ id: "cus_123" }]; @@ -242,11 +273,13 @@ function stripeSubscriptionRow({ plan = "pro", scope = "user", seats = null, + lookupKey = "cmux-pro-monthly", }: { cancelAtPeriodEnd: boolean; plan?: string; scope?: string; seats?: number | null; + lookupKey?: string; }) { return { id: "sub_123", @@ -262,7 +295,7 @@ function stripeSubscriptionRow({ data: [ { price: { - lookup_key: "cmux-pro-monthly", + lookup_key: lookupKey, }, }, ], diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx index 3981156dc479..2f5088b08717 100644 --- a/web/tests/pricing-page.test.tsx +++ b/web/tests/pricing-page.test.tsx @@ -108,13 +108,30 @@ describe("localized pricing page", () => { expect(html).toContain("Manage billing"); expect(html).toContain("Current plan"); }); + + test("renders the annual price and sends annual checkout intent", async () => { + const element = await PricingPage({ + params: Promise.resolve({ locale: "en" }), + searchParams: Promise.resolve({ interval: "year" }), + }); + const html = renderToStaticMarkup(element); + + expect(html).toContain("$24"); + expect(html).toContain("Billed $288 annually · save 20%"); + expect(html).toContain("$24/month · $288/year"); + expect(html).toContain( + "/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=year", + ); + }); }); function translator(namespace?: string) { const root = namespace ? valueAtPath(enMessages, namespace) : enMessages; - const t = (key: string) => String(valueAtPath(root, key)); + const t = (key: string, values?: Record) => + interpolate(String(valueAtPath(root, key)), values); t.raw = (key: string) => valueAtPath(root, key); - t.rich = (key: string) => String(valueAtPath(root, key)); + t.rich = (key: string, values?: Record) => + interpolate(String(valueAtPath(root, key)), values); return t; } @@ -126,3 +143,11 @@ function valueAtPath(root: unknown, path: string): unknown { return path; }, root); } + +function interpolate(message: string, values?: Record) { + if (!values) return message; + return Object.entries(values).reduce( + (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)), + message, + ); +} diff --git a/web/tests/pro-pricing.test.ts b/web/tests/pro-pricing.test.ts new file mode 100644 index 000000000000..f591cb3eeff7 --- /dev/null +++ b/web/tests/pro-pricing.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { + LEGACY_PRO_YEARLY_LOOKUP_KEY, + PRO_PRICING_USD, + proBillingInterval, +} from "../services/billing/plans"; + +describe("Pro pricing", () => { + test("prices annual Pro at $288 with a 20% discount", () => { + expect(PRO_PRICING_USD.year).toEqual({ + billedAmount: 288, + monthlyEquivalent: 24, + discountPercent: 20, + lookupKey: "cmux-pro-yearly-288", + }); + expect(PRO_PRICING_USD.month.billedAmount * 12).toBe(360); + expect(PRO_PRICING_USD.year.billedAmount).toBe( + PRO_PRICING_USD.month.billedAmount * + 12 * + (1 - PRO_PRICING_USD.year.discountPercent / 100), + ); + }); + + test("keeps the original annual lookup key reserved for grandfathered subscriptions", () => { + expect(LEGACY_PRO_YEARLY_LOOKUP_KEY).toBe("cmux-pro-yearly"); + expect(PRO_PRICING_USD.year.lookupKey).not.toBe( + LEGACY_PRO_YEARLY_LOOKUP_KEY, + ); + }); + + test("defaults unknown intervals to monthly", () => { + expect(proBillingInterval("year")).toBe("year"); + expect(proBillingInterval("month")).toBe("month"); + expect(proBillingInterval("annual")).toBe("month"); + expect(proBillingInterval(null)).toBe("month"); + }); +}); From a61a0ab1a9978a3ed9f1a9890b2f3fd182f57f18 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:06:08 -0700 Subject: [PATCH 02/50] fix(web): toggle pricing without navigation --- web/app/[locale]/components/pro-cta-link.tsx | 53 +-- web/app/[locale]/dashboard/billing/page.tsx | 167 +++++----- web/app/[locale]/pricing/page.tsx | 306 +++++++++--------- web/app/app-pricing/page.tsx | 304 ++++++++--------- .../components/pricing-interval-selector.tsx | 148 +++++++-- web/app/components/pricing-shared.tsx | 12 +- web/tests/app-pricing-page.test.tsx | 5 +- web/tests/pricing-page.test.tsx | 8 +- web/tests/pro-cta-link.test.tsx | 43 ++- 9 files changed, 560 insertions(+), 486 deletions(-) diff --git a/web/app/[locale]/components/pro-cta-link.tsx b/web/app/[locale]/components/pro-cta-link.tsx index ebc452f305de..2a98bb8c1de8 100644 --- a/web/app/[locale]/components/pro-cta-link.tsx +++ b/web/app/[locale]/components/pro-cta-link.tsx @@ -1,58 +1,29 @@ "use client"; -import { posthog } from "../../lib/posthog-client"; import { - pricingActionClassName, - type PricingActionSize, -} from "../../components/pricing-shared"; -import { - CheckoutPendingContent, - useCheckoutRedirect, -} from "../../components/checkout-navigation"; -import { - PRO_PRICING_USD, - type ProBillingInterval, -} from "../../../services/billing/plans"; + PricingCheckoutButton, + type ProCheckoutHrefs, +} from "../../components/pricing-interval-selector"; +import type { PricingActionSize } from "../../components/pricing-shared"; export function ProCtaLink({ - checkoutHref, + checkoutHrefs, children, size = "default", location = "pricing_page", - interval = "month", }: { - checkoutHref: string; + checkoutHrefs: ProCheckoutHrefs; children: React.ReactNode; size?: PricingActionSize; location?: string; - interval?: ProBillingInterval; }) { - const { pending, start } = useCheckoutRedirect(); - const pricing = PRO_PRICING_USD[interval]; return ( - { - posthog.capture("cmuxterm_pro_cta_clicked", { - location, - checkout: true, - interval, - currency: "usd", - billed_amount_usd: pricing.billedAmount, - monthly_equivalent_usd: pricing.monthlyEquivalent, - discount_percent: pricing.discountPercent, - }); - start(checkoutHref, event); - }} - aria-busy={pending} - className={`${pricingActionClassName("primary", size)} relative`} - style={{ - color: "var(--background)", - textDecoration: "none", - pointerEvents: pending ? "none" : undefined, - }} + - {children} - + {children} + ); } diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx index 2cd59f706ee1..b37287fd6d4f 100644 --- a/web/app/[locale]/dashboard/billing/page.tsx +++ b/web/app/[locale]/dashboard/billing/page.tsx @@ -15,8 +15,13 @@ import { PrimaryLink, visibleProFeatures, } from "@/app/components/pricing-shared"; -import { CheckoutButton } from "@/app/components/checkout-navigation"; -import { PricingIntervalSelector } from "@/app/components/pricing-interval-selector"; +import { + PricingAnnualDetail, + PricingCheckoutButton, + PricingIntervalProvider, + PricingIntervalSelector, + PricingIntervalValue, +} from "@/app/components/pricing-interval-selector"; import { cloudDb } from "@/db/client"; import { stripeCustomers, stripeSubscriptions } from "@/db/schema"; import { Link } from "@/i18n/navigation"; @@ -240,93 +245,85 @@ function FreePlanUpsell({ hostedNetworking: pricingT.raw("pro.hostedNetworkingFeatures") as string[], }); const teamFeatures = pricingT.raw("team.features") as string[]; - const proPricing = PRO_PRICING_USD[interval]; - const proCheckoutURL = withCheckoutInterval(PRO_CHECKOUT_URL, interval); - const proPrice = interval === "year" - ? `$${proPricing.monthlyEquivalent}` - : pricingT("pro.price"); - const annualPriceDetail = interval === "year" - ? pricingT("annualPriceDetail", { - amount: PRO_PRICING_USD.year.billedAmount, - discount: PRO_PRICING_USD.year.discountPercent, - }) - : undefined; + const proCheckoutHrefs = { + month: withCheckoutInterval(PRO_CHECKOUT_URL, "month"), + year: withCheckoutInterval(PRO_CHECKOUT_URL, "year"), + }; + const annualPriceDetail = pricingT("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + }); return ( -
-
-

{t("free.name")}

-

{t("free.body")}

-
- -
-
-

{t("free.upsellTitle")}

-

{t("free.upsellBody")}

- -
-
- - +
+
+

{t("free.name")}

+

{t("free.body")}

+
+ +
+
+

{t("free.upsellTitle")}

+

{t("free.upsellBody")}

+ +
+
+ + } + period={pricingT("perMonth")} + priceDetail={ + {annualPriceDetail} + } > - {pricingT("pro.cta")} - -

{pricingT("pro.featuresLead")}

- -
- - + {pricingT("pro.cta")} + +

{pricingT("pro.featuresLead")}

+ +
+ + + {pricingT("team.cta")} +

{pricingT("team.featuresLead")}

+ +
+
+
+ +
+

{t("free.testflightTitle")}

+

{t("free.testflightBody")}

+ - {pricingT("team.cta")} -

{pricingT("team.featuresLead")}

- - -
-
- -
-

{t("free.testflightTitle")}

-

{t("free.testflightBody")}

- - {t("free.testflightCta")} - -
-
+ {t("free.testflightCta")} + + +
+ ); } diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx index 6251215fe8ef..ee440ca377f1 100644 --- a/web/app/[locale]/pricing/page.tsx +++ b/web/app/[locale]/pricing/page.tsx @@ -39,7 +39,12 @@ import { type SizeRow, } from "../../components/pricing-shared"; import { CheckoutButton } from "../../components/checkout-navigation"; -import { PricingIntervalSelector } from "../../components/pricing-interval-selector"; +import { + PricingAnnualDetail, + PricingIntervalProvider, + PricingIntervalSelector, + PricingIntervalValue, +} from "../../components/pricing-interval-selector"; import { PRO_PRICING_USD, proBillingInterval, @@ -96,23 +101,18 @@ export default async function PricingPage({ const t = await getTranslations({ locale, namespace: "pricing" }); const snapshot = await currentPlanSnapshot(); const interval = proBillingInterval(firstParam(query.interval)); - const proPricing = PRO_PRICING_USD[interval]; - const proCheckoutURL = withCheckoutInterval(PRO_CHECKOUT_URL, interval); - const annualPriceDetail = interval === "year" - ? t("annualPriceDetail", { - amount: PRO_PRICING_USD.year.billedAmount, - discount: PRO_PRICING_USD.year.discountPercent, - }) - : undefined; - const proPrice = interval === "year" - ? `$${proPricing.monthlyEquivalent}` - : t("pro.price"); - const compareProPrice = interval === "year" - ? t("annualComparePrice", { - monthly: PRO_PRICING_USD.year.monthlyEquivalent, - annual: PRO_PRICING_USD.year.billedAmount, - }) - : `${t("pro.price")}${t("perMonth")}`; + const proCheckoutHrefs = { + month: withCheckoutInterval(PRO_CHECKOUT_URL, "month"), + year: withCheckoutInterval(PRO_CHECKOUT_URL, "year"), + }; + const annualPriceDetail = t("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + }); + const annualComparePrice = t("annualComparePrice", { + monthly: PRO_PRICING_USD.year.monthlyEquivalent, + annual: PRO_PRICING_USD.year.billedAmount, + }); const freeFeatures = t.raw("free.features") as string[]; const proBaseFeatures = t.raw("pro.features") as string[]; @@ -142,141 +142,151 @@ export default async function PricingPage({ - {/* Title */} -

{t("title")}

- + + {/* Title */} +

{t("title")}

+ - {/* Tier cards */} -
- {/* Free */} - - {t("free.cta")} -

- {t("free.featuresLead")} -

- -
+ {/* Tier cards */} +
+ {/* Free */} + + {t("free.cta")} +

+ {t("free.featuresLead")} +

+ +
- {/* Pro */} - {t("currentPlan")} - ) : null - } - > - {snapshot.isPro ? ( -
- {t("currentPlan")} - - {t("manageBilling")} - -
- ) : ( - - {t("pro.cta")} - - )} -

{t("pro.featuresLead")}

- -
+ {/* Pro */} + + } + period={t("perMonth")} + priceDetail={ + {annualPriceDetail} + } + badge={ + snapshot.isPro ? ( + {t("currentPlan")} + ) : null + } + > + {snapshot.isPro ? ( +
+ {t("currentPlan")} + + {t("manageBilling")} + +
+ ) : ( + + {t("pro.cta")} + + )} +

{t("pro.featuresLead")}

+ +
- {/* Team */} - - {t("team.cta")} -

{t("team.featuresLead")}

- -
+ {/* Team */} + + {t("team.cta")} +

{t("team.featuresLead")}

+ +
- {/* Enterprise */} - - - {t("enterprise.cta")} - -

- {t("enterprise.featuresLead")} -

- -
-
+ {/* Enterprise */} + + + {t("enterprise.cta")} + +

+ {t("enterprise.featuresLead")} +

+ +
+
- {/* Compare plans. Header row is sticky under the 48px h-12 site header. - Horizontal scrolling is mobile-only so desktop keeps the page as the - sticky scroll container. */} -
- - {t("free.cta")} - - ), - pro: ( - snapshot.isPro ? ( - {t("currentPlan")} - ) : ( - - {t("pro.cta")} - - ) - ), - team: ( - - {t("team.cta")} - - ), - enterprise: ( - - {t("enterprise.cta")} - - ), - }} - /> -
+ {/* Compare plans. Header row is sticky under the 48px h-12 site header. + Horizontal scrolling is mobile-only so desktop keeps the page as the + sticky scroll container. */} +
+ + ), + team: `${t("team.price")}${t("perUserMonth")}`, + enterprise: t("enterprise.price"), + }} + actions={{ + free: ( + + {t("free.cta")} + + ), + pro: ( + snapshot.isPro ? ( + {t("currentPlan")} + ) : ( + + {t("pro.cta")} + + ) + ), + team: ( + + {t("team.cta")} + + ), + enterprise: ( + + {t("enterprise.cta")} + + ), + }} + /> +
+
{/* Cloud VM sizes */} @@ -120,113 +104,118 @@ export default async function AppPricingPage({
{banner ? : null} -

{pricing.title}

- - -
- {pricing.currentPlan} - ) : null - } - > - {snapshot.planId === FREE_PLAN_ID ? ( - {pricing.currentPlan} - ) : ( - - {pricing.free.cta} - - )} -

- {pricing.free.featuresLead} -

- -
+ +

{pricing.title}

+ - {pricing.currentPlan} - ) : null - } - > - {snapshot.isPro ? ( -
+
+ {pricing.currentPlan} + ) : null + } + > + {snapshot.planId === FREE_PLAN_ID ? ( {pricing.currentPlan} - {appStorePaymentGated ? null : ( - - {pricing.manageBilling} - - )} -
- ) : appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - - {pricing.pro.cta} - - )} -

- {pricing.pro.featuresLead} -

- - + ) : ( + + {pricing.free.cta} + + )} +

+ {pricing.free.featuresLead} +

+ + - - {appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - {pricing.team.cta} - )} -

- {pricing.team.featuresLead} -

- -
+ + } + period={pricing.perMonth} + priceDetail={ + {annualPriceDetail} + } + badge={ + snapshot.isPro ? ( + {pricing.currentPlan} + ) : null + } + > + {snapshot.isPro ? ( +
+ {pricing.currentPlan} + {appStorePaymentGated ? null : ( + + {pricing.manageBilling} + + )} +
+ ) : appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + + {pricing.pro.cta} + + )} +

+ {pricing.pro.featuresLead} +

+ +
+ + + {appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + {pricing.team.cta} + )} +

+ {pricing.team.featuresLead} +

+ +
- - {appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - - {pricing.enterprise.cta} - - )} -

- {pricing.enterprise.featuresLead} -

- -
-
+ + {appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + + {pricing.enterprise.cta} + + )} +

+ {pricing.enterprise.featuresLead} +

+ +
+
+ ), team: `${pricing.team.price}${pricing.perUserMonth}`, enterprise: pricing.enterprise.price, }} @@ -258,19 +252,13 @@ export default async function AppPricingPage({ ) : appStorePaymentGated ? ( {pricing.billingUnavailable} ) : ( - {pricing.pro.cta} - + ), team: appStorePaymentGated ? ( {pricing.billingUnavailable} @@ -289,6 +277,7 @@ export default async function AppPricingPage({ }} />
+ , - interval: "month" | "year", -): string { - const next = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) { - for (const item of value) next.append(key, item); - } else if (value !== undefined) { - next.set(key, value); - } - } - next.set("cmux_app", "1"); - next.set("interval", interval); - return `?${next.toString()}`; -} - function pricingMessage( message: string, values: Record, diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx index 611a162f7a5f..66b9f191631c 100644 --- a/web/app/components/pricing-interval-selector.tsx +++ b/web/app/components/pricing-interval-selector.tsx @@ -1,34 +1,63 @@ "use client"; -import { useCallback, useRef } from "react"; +import { + createContext, + useCallback, + useContext, + useRef, + useState, + type ReactNode, +} from "react"; import { posthog } from "../lib/posthog-client"; import { PRO_PRICING_USD, type ProBillingInterval, } from "../../services/billing/plans"; +import { CheckoutButton } from "./checkout-navigation"; +import type { PricingActionSize } from "./pricing-shared"; type PricingSurface = "public_pricing" | "app_pricing" | "dashboard_billing"; +export type ProCheckoutHrefs = Record; + +type PricingIntervalContextValue = { + interval: ProBillingInterval; + setInterval: (interval: ProBillingInterval) => void; +}; + +const PricingIntervalContext = + createContext(null); + +export function PricingIntervalProvider({ + initialInterval, + children, +}: { + initialInterval: ProBillingInterval; + children: ReactNode; +}) { + const [interval, setInterval] = useState(initialInterval); + + return ( + + {children} + + ); +} export function PricingIntervalSelector({ - interval, - monthlyHref, - annualHref, billingPeriodLabel, monthlyLabel, annualLabel, savingsLabel, surface, }: { - interval: ProBillingInterval; - monthlyHref: string; - annualHref: string; billingPeriodLabel: string; monthlyLabel: string; annualLabel: string; savingsLabel: string; surface: PricingSurface; }) { + const { interval, setInterval } = usePricingInterval(); const capturedView = useRef(false); const captureView = useCallback( (node: HTMLDivElement | null) => { @@ -46,40 +75,103 @@ export function PricingIntervalSelector({ role="group" aria-label={billingPeriodLabel} > - capturePricingEvent("cmuxterm_pricing_interval_selected", "month", surface)} + onSelect={() => { + setInterval("month"); + capturePricingEvent( + "cmuxterm_pricing_interval_selected", + "month", + surface, + ); + }} > {monthlyLabel} - - + capturePricingEvent("cmuxterm_pricing_interval_selected", "year", surface)} + onSelect={() => { + setInterval("year"); + capturePricingEvent( + "cmuxterm_pricing_interval_selected", + "year", + surface, + ); + }} > {annualLabel} {savingsLabel} - +
); } -function IntervalLink({ - href, +export function PricingIntervalValue({ + monthly, + annual, +}: { + monthly: ReactNode; + annual: ReactNode; +}) { + const { interval } = usePricingInterval(); + return interval === "year" ? annual : monthly; +} + +export function PricingAnnualDetail({ children }: { children: ReactNode }) { + const { interval } = usePricingInterval(); + if (interval !== "year") return null; + return

{children}

; +} + +export function PricingCheckoutButton({ + hrefs, + children, + location, + size = "default", +}: { + hrefs: ProCheckoutHrefs; + children: ReactNode; + location: string; + size?: PricingActionSize; +}) { + const { interval } = usePricingInterval(); + const pricing = PRO_PRICING_USD[interval]; + + return ( + + {children} + + ); +} + +function IntervalButton({ selected, onSelect, children, }: { - href: string; selected: boolean; onSelect: () => void; - children: React.ReactNode; + children: ReactNode; }) { return ( - {children} - + ); } +function usePricingInterval() { + const context = useContext(PricingIntervalContext); + if (!context) { + throw new Error( + "Pricing interval controls must be wrapped in PricingIntervalProvider", + ); + } + return context; +} + function capturePricingEvent( event: "cmuxterm_pricing_viewed" | "cmuxterm_pricing_interval_selected", interval: ProBillingInterval, diff --git a/web/app/components/pricing-shared.tsx b/web/app/components/pricing-shared.tsx index 07e497941d4a..1a6c7223048f 100644 --- a/web/app/components/pricing-shared.tsx +++ b/web/app/components/pricing-shared.tsx @@ -71,9 +71,9 @@ export function PlanCard({ children, }: { name: string; - price: string; + price: ReactNode; period?: string; - priceDetail?: string; + priceDetail?: ReactNode; badge?: ReactNode; children: ReactNode; }) { @@ -85,9 +85,7 @@ export function PlanCard({ {price} {period ? {period} : null}
- {priceDetail ? ( -

{priceDetail}

- ) : null} + {priceDetail}
{children}
); @@ -182,7 +180,7 @@ export function PricingCompareTable({ }: { rows: CompareRow[]; names: Record; - prices: Record; + prices: Record; actions?: Partial>; stickyTopClassName?: string; }) { @@ -301,7 +299,7 @@ function ColumnHead({ action, }: { name: string; - price: string; + price: ReactNode; action?: ReactNode; }) { return ( diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx index 3c468dd13651..82f024ec1212 100644 --- a/web/tests/app-pricing-page.test.tsx +++ b/web/tests/app-pricing-page.test.tsx @@ -113,9 +113,8 @@ describe("app pricing page", () => { expect(html).toContain( "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year", ); - expect(html).toContain( - "?cmux_app=1&cmux_scheme=cmux-dev-test&appearance=dark&interval=month", - ); + expect(html).toContain('