diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 764e29ec0c..4d1f0eda2f 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -18,8 +18,6 @@ export const env = createEnv({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), STRIPE_SECRET_KEY: z.string().min(1), STRIPE_WEBHOOK_SECRET: z.string().min(1), - STRIPE_MONTHLY_PRICE_ID: z.string().min(1), - STRIPE_YEARLY_PRICE_ID: z.string().min(1), OPENROUTER_API_KEY: z.string().min(1), DEEPGRAM_API_KEY: z.string().min(1), ASSEMBLYAI_API_KEY: z.string().min(1), diff --git a/apps/api/src/integration/index.ts b/apps/api/src/integration/index.ts index 97021e9093..bf4b6b8b18 100644 --- a/apps/api/src/integration/index.ts +++ b/apps/api/src/integration/index.ts @@ -1,4 +1,5 @@ export * from "./supabase"; export * from "./stripe"; export * from "./posthog"; +export * from "./pricing-config"; export * from "./restate"; diff --git a/apps/api/src/integration/pricing-config.ts b/apps/api/src/integration/pricing-config.ts new file mode 100644 index 0000000000..c73e1c0d2f --- /dev/null +++ b/apps/api/src/integration/pricing-config.ts @@ -0,0 +1,25 @@ +import { posthog } from "./posthog"; + +export type PricingConfig = { + monthly_price_id: string; + yearly_price_id: string; + monthly_display_price: number; + yearly_display_price: number; + monthly_original_price: number | null; + yearly_original_price: number | null; + promo_text: string | null; + promo_active: boolean; + save_percentage: string | null; +}; + +const FLAG_KEY = "pricing-config"; + +export async function getPricingConfig(): Promise { + const payload = await posthog.getFeatureFlagPayload(FLAG_KEY, ""); + if (payload && typeof payload === "object") { + return payload as PricingConfig; + } + throw new Error( + "pricing-config feature flag not found or has invalid payload", + ); +} diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts index 040d5d593a..b375c8451f 100644 --- a/apps/api/src/routes/billing.ts +++ b/apps/api/src/routes/billing.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { env } from "../env"; import type { AppBindings } from "../hono-bindings"; +import { getPricingConfig } from "../integration/pricing-config"; import { stripe } from "../integration/stripe"; import { supabaseAuthMiddleware } from "../middleware/supabase"; import { API_TAGS } from "./constants"; @@ -98,10 +99,11 @@ billing.post( return c.json({ error: "stripe_customer_id_missing" }, 500); } + const pricingConfig = await getPricingConfig(); const priceId = interval === "yearly" - ? env.STRIPE_YEARLY_PRICE_ID - : env.STRIPE_MONTHLY_PRICE_ID; + ? pricingConfig.yearly_price_id + : pricingConfig.monthly_price_id; try { await stripe.subscriptions.create( diff --git a/apps/web/package.json b/apps/web/package.json index 7517c8c4eb..8e1e42777f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "motion": "^11.18.2", "postgres": "^3.4.8", "posthog-js": "^1.336.4", + "posthog-node": "^5.24.11", "react": "^19.2.4", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.4", @@ -71,12 +72,12 @@ "zod": "^4.3.6" }, "devDependencies": { - "@hypr/plugin-auth": "workspace:*", "@argos-ci/playwright": "^6.4.1", "@content-collections/core": "^0.11.1", "@content-collections/mdx": "^0.2.2", "@content-collections/vite": "^0.2.8", "@dotenvx/dotenvx": "^1.52.0", + "@hypr/plugin-auth": "workspace:*", "@playwright/test": "^1.58.1", "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 32645e111a..bece3ce1fb 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -24,8 +24,6 @@ export const env = createEnv({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(), STRIPE_SECRET_KEY: requiredInProd(z.string().min(1)), - STRIPE_MONTHLY_PRICE_ID: requiredInProd(z.string().min(1)), - STRIPE_YEARLY_PRICE_ID: requiredInProd(z.string().min(1)), LOOPS_KEY: requiredInProd(z.string().min(1)), diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index d7745715f0..37c6c21835 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { getRpcCanStartTrial } from "@hypr/api-client"; import { createClient } from "@hypr/api-client/client"; -import { env, requireEnv } from "@/env"; +import { env } from "@/env"; +import { getPricingConfig } from "@/functions/pricing-config"; import { getStripeClient } from "@/functions/stripe"; import { getSupabaseServerClient } from "@/functions/supabase"; @@ -115,10 +116,11 @@ export const createCheckoutSession = createServerFn({ method: "POST" }) stripeCustomerId = newCustomer.id; } + const pricingConfig = await getPricingConfig(); const priceId = data.period === "yearly" - ? requireEnv(env.STRIPE_YEARLY_PRICE_ID, "STRIPE_YEARLY_PRICE_ID") - : requireEnv(env.STRIPE_MONTHLY_PRICE_ID, "STRIPE_MONTHLY_PRICE_ID"); + ? pricingConfig.yearly_price_id + : pricingConfig.monthly_price_id; const successParams = new URLSearchParams({ success: "true" }); if (data.scheme) { @@ -304,10 +306,7 @@ export const createTrialCheckoutSession = createServerFn({ payment_method_collection: "if_required", line_items: [ { - price: requireEnv( - env.STRIPE_MONTHLY_PRICE_ID, - "STRIPE_MONTHLY_PRICE_ID", - ), + price: (await getPricingConfig()).monthly_price_id, quantity: 1, }, ], diff --git a/apps/web/src/functions/pricing-config.ts b/apps/web/src/functions/pricing-config.ts new file mode 100644 index 0000000000..9c5a37bdc1 --- /dev/null +++ b/apps/web/src/functions/pricing-config.ts @@ -0,0 +1,42 @@ +import { createServerOnlyFn } from "@tanstack/react-start"; +import { PostHog } from "posthog-node"; + +import { env } from "@/env"; + +export type PricingConfig = { + monthly_price_id: string; + yearly_price_id: string; + monthly_display_price: number; + yearly_display_price: number; + monthly_original_price: number | null; + yearly_original_price: number | null; + promo_text: string | null; + promo_active: boolean; + save_percentage: string | null; +}; + +const FLAG_KEY = "pricing-config"; + +let posthogClient: PostHog | null = null; + +function getPostHogClient(): PostHog { + if (!posthogClient) { + posthogClient = new PostHog(env.VITE_POSTHOG_API_KEY!, { + host: env.VITE_POSTHOG_HOST, + }); + } + return posthogClient; +} + +export const getPricingConfig = createServerOnlyFn( + async (): Promise => { + const client = getPostHogClient(); + const payload = await client.getFeatureFlagPayload(FLAG_KEY, ""); + if (payload && typeof payload === "object") { + return payload as PricingConfig; + } + throw new Error( + "pricing-config feature flag not found or has invalid payload", + ); + }, +); diff --git a/apps/web/src/routes/_view/pricing.tsx b/apps/web/src/routes/_view/pricing.tsx index 6b54548bad..9d15046f4f 100644 --- a/apps/web/src/routes/_view/pricing.tsx +++ b/apps/web/src/routes/_view/pricing.tsx @@ -1,10 +1,13 @@ +import { useFeatureFlagPayload } from "@posthog/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { CheckCircle2, MinusCircle, XCircle } from "lucide-react"; +import { useMemo } from "react"; import { cn } from "@hypr/utils"; import { Image } from "@/components/image"; import { SlashSeparator } from "@/components/slash-separator"; +import type { PricingConfig } from "@/functions/pricing-config"; export const Route = createFileRoute("/_view/pricing")({ component: Component, @@ -14,6 +17,7 @@ interface PricingPlan { name: string; price: { monthly: number; yearly: number } | null; originalPrice?: { monthly: number; yearly: number }; + savePercentage?: string | null; description: string; popular?: boolean; features: Array<{ @@ -24,73 +28,99 @@ interface PricingPlan { }>; } -const pricingPlans: PricingPlan[] = [ +const FREE_PLAN_FEATURES: PricingPlan["features"] = [ + { label: "Local Transcription", included: true }, { - name: "Free", - price: null, - description: - "Fully functional with your own API keys. Perfect for individuals who want complete control.", - features: [ - { label: "Local Transcription", included: true }, - { - label: "Speaker Identification", - included: true, - comingSoon: true, - }, - { label: "Bring Your Own Key (STT & LLM)", included: true }, - { label: "Basic Sharing (Copy, PDF)", included: true }, - { label: "All Data Local", included: true }, - { label: "Templates & Chat", included: true }, - { label: "Integrations", included: false, comingSoon: true }, - { label: "Cloud Services (STT & LLM)", included: false }, - { label: "Cloud Sync", included: false }, - { label: "Shareable Links", included: false }, - ], + label: "Speaker Identification", + included: true, + comingSoon: true, }, + { label: "Bring Your Own Key (STT & LLM)", included: true }, + { label: "Basic Sharing (Copy, PDF)", included: true }, + { label: "All Data Local", included: true }, + { label: "Templates & Chat", included: true }, + { label: "Integrations", included: false, comingSoon: true }, + { label: "Cloud Services (STT & LLM)", included: false }, + { label: "Cloud Sync", included: false }, + { label: "Shareable Links", included: false }, +]; + +const PRO_PLAN_FEATURES: PricingPlan["features"] = [ + { label: "Everything in Free", included: true }, + { label: "Integrations", included: true, comingSoon: true }, + { label: "Cloud Services (STT & LLM)", included: true }, { - name: "Pro", - price: { - monthly: 8, - yearly: 59, - }, - originalPrice: { - monthly: 25, - yearly: 250, - }, - description: - "No API keys needed. Get cloud services, advanced sharing, and team features out of the box.", - popular: true, - features: [ - { label: "Everything in Free", included: true }, - { label: "Integrations", included: true, comingSoon: true }, - { label: "Cloud Services (STT & LLM)", included: true }, - { - label: "Cloud Sync", - included: true, - tooltip: "Select which notes to sync", - comingSoon: true, - }, - { - label: "Shareable Links", - included: true, - tooltip: "DocSend-like: view tracking, expiration, revocation", - comingSoon: true, - }, - ], + label: "Cloud Sync", + included: true, + tooltip: "Select which notes to sync", + comingSoon: true, + }, + { + label: "Shareable Links", + included: true, + tooltip: "DocSend-like: view tracking, expiration, revocation", + comingSoon: true, }, ]; +function buildPricingPlans(config: PricingConfig | null): PricingPlan[] { + return [ + { + name: "Free", + price: null, + description: + "Fully functional with your own API keys. Perfect for individuals who want complete control.", + features: FREE_PLAN_FEATURES, + }, + { + name: "Pro", + price: { + monthly: config?.monthly_display_price ?? 8, + yearly: config?.yearly_display_price ?? 59, + }, + originalPrice: + config?.monthly_original_price && config?.yearly_original_price + ? { + monthly: config.monthly_original_price, + yearly: config.yearly_original_price, + } + : undefined, + savePercentage: config?.save_percentage ?? null, + description: + "No API keys needed. Get cloud services, advanced sharing, and team features out of the box.", + popular: true, + features: PRO_PLAN_FEATURES, + }, + ]; +} + +function usePricingConfig(): PricingConfig | null { + const payload = useFeatureFlagPayload("pricing-config"); + if (payload && typeof payload === "object") { + return payload as PricingConfig; + } + return null; +} + function Component() { + const pricingConfig = usePricingConfig(); + const pricingPlans = useMemo( + () => buildPricingPlans(pricingConfig), + [pricingConfig], + ); + return (
- + {pricingConfig?.promo_active && pricingConfig.promo_text && ( + + )} - + @@ -100,7 +130,7 @@ function Component() { ); } -function TeamPricingBanner() { +function PromoBanner({ text }: { text: string }) { return (
- - Early Bird Discount: Get 68% off as we launch our new - version and help with migration —{" "} - offer ends February 28th - +
); } @@ -135,11 +161,11 @@ function HeroSection() { ); } -function PricingCardsSection() { +function PricingCardsSection({ plans }: { plans: PricingPlan[] }) { return (
- {pricingPlans.map((plan) => ( + {plans.map((plan) => ( ))}
@@ -190,7 +216,11 @@ function PricingCard({ plan }: { plan: PricingPlan }) { ${plan.originalPrice.yearly} )}{" "} - (save 76%) + {plan.savePercentage && ( + + (save {plan.savePercentage}) + + )}
) : ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7315d077dd..1f9b9a2e0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -963,6 +963,9 @@ importers: posthog-js: specifier: ^1.336.4 version: 1.336.4 + posthog-node: + specifier: ^5.24.11 + version: 5.24.11 react: specifier: ^19.2.4 version: 19.2.4 @@ -5541,6 +5544,9 @@ packages: '@posthog/core@1.17.0': resolution: {integrity: sha512-8pDNL+/u9ojzXloA5wILVDXBCV5daJ7w2ipCALQlEEZmL752cCKhRpbyiHn3tjKXh3Hy6aOboJneYa1JdlVHrQ==} + '@posthog/core@1.20.1': + resolution: {integrity: sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==} + '@posthog/react@1.7.0': resolution: {integrity: sha512-pM7GL7z/rKjiIwosbRiQA3buhLI6vUo+wg+T/ZrVZC7O5bVU07TfgNZTcuOj8E9dx7vDbfNrc1kjDN7PKMM8ug==} peerDependencies: @@ -13913,6 +13919,10 @@ packages: resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} engines: {node: '>=15.0.0'} + posthog-node@5.24.11: + resolution: {integrity: sha512-tDXbYyXJyh0oUEo1SumCzmXY0FZNB0avAq0uXMo6o6JinzwY8u5cygqAgUyMDIGG8u0p6tBHq++foqULXaPmiA==} + engines: {node: ^20.20.0 || >=22.22.0} + posthog-node@5.24.7: resolution: {integrity: sha512-IJ0Zj+v+eg/JQMZ75n0Hcp4NzuQzWcZjqFjcUQs6RhW2l5FiQIq09sKJMleXX33hYxD6sfjFsDTqugJlgeAohg==} engines: {node: ^20.20.0 || >=22.22.0} @@ -21027,6 +21037,10 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@posthog/core@1.20.1': + dependencies: + cross-spawn: 7.0.6 + '@posthog/react@1.7.0(@types/react@19.2.10)(posthog-js@1.336.4)(react@19.2.4)': dependencies: posthog-js: 1.336.4 @@ -31392,6 +31406,10 @@ snapshots: transitivePeerDependencies: - debug + posthog-node@5.24.11: + dependencies: + '@posthog/core': 1.20.1 + posthog-node@5.24.7: dependencies: '@posthog/core': 1.17.0