diff --git a/apps/cli/src/helpers/addons/mcp-setup.ts b/apps/cli/src/helpers/addons/mcp-setup.ts index dcdce4549..0ddb83ff3 100644 --- a/apps/cli/src/helpers/addons/mcp-setup.ts +++ b/apps/cli/src/helpers/addons/mcp-setup.ts @@ -187,6 +187,12 @@ function getAllMcpServers(config: ProjectConfig): McpServerDef[] { name: "polar", target: "https://mcp.polar.sh/mcp/polar-mcp", }, + { + key: "revenuecat", + label: "RevenueCat", + name: "revenuecat", + target: "https://mcp.revenuecat.ai/mcp", + }, ]; } @@ -261,6 +267,10 @@ export function getRecommendedMcpServers( recommendedServerKeys.push("polar"); } + if (config.payments === "revenuecat") { + recommendedServerKeys.push("revenuecat"); + } + return uniqueValues(recommendedServerKeys) .map((serverKey) => serversByKey.get(serverKey)) .filter((server): server is McpServerDef => server !== undefined); diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts index 6fdca6483..e677eb6d0 100644 --- a/apps/cli/src/helpers/core/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -141,6 +141,8 @@ export async function displayPostInstallInstructions( config.payments === "polar" && config.auth === "better-auth" ? getPolarInstructions(backend, packageManager) : ""; + const revenueCatInstructions = + config.payments === "revenuecat" ? getRevenueCatInstructions(backend, packageManager) : ""; const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : ""; @@ -237,6 +239,7 @@ export async function displayPostInstallInstructions( if (clerkInstructions) output += `\n${clerkInstructions.trim()}\n`; if (betterAuthConvexInstructions) output += `\n${betterAuthConvexInstructions.trim()}\n`; if (polarInstructions) output += `\n${polarInstructions.trim()}\n`; + if (revenueCatInstructions) output += `\n${revenueCatInstructions.trim()}\n`; if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`; @@ -606,6 +609,29 @@ function getPolarInstructions(backend: Backend, packageManager: string) { return `${pc.bold("Polar Payments Setup:")}\n${pc.cyan("•")} Get access token & product ID from ${pc.underline("https://sandbox.polar.sh/")}\n${pc.cyan("•")} Set POLAR_ACCESS_TOKEN in ${envPath}`; } +function getRevenueCatInstructions(backend: Backend, packageManager: string) { + const base = + `${pc.bold("RevenueCat Payments Setup:")}\n` + + `${pc.cyan("•")} Create a project, entitlement, and offering in ${pc.underline("https://app.revenuecat.com/")}\n` + + `${pc.cyan("•")} Set the public SDK keys in ${pc.white("apps/native/.env")}:\n` + + `${pc.white(" EXPO_PUBLIC_REVENUECAT_IOS_KEY=appl_your_ios_key")}\n` + + `${pc.white(" EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=goog_your_android_key")}\n` + + `${pc.white(" EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=pro")}`; + + if (backend === "convex") { + const cmd = packageManager === "npm" ? "npx" : packageManager; + return ( + `${base}\n` + + `${pc.cyan("•")} Set the webhook secret (min 32 chars) from ${pc.white("packages/backend")}:\n` + + `${pc.white(" cd packages/backend")}\n` + + `${pc.white(` ${cmd} convex env set REVENUECAT_WEBHOOK_AUTH your_webhook_secret`)}\n` + + `${pc.cyan("•")} Configure a RevenueCat webhook to ${pc.white("https:///webhooks/revenuecat")} using the same value as the Authorization header` + ); + } + + return base; +} + function getAlchemyDeployInstructions( runCmd: string, webDeploy: WebDeploy, diff --git a/apps/cli/src/prompts/payments.ts b/apps/cli/src/prompts/payments.ts index 628e0d8a6..246704d7f 100644 --- a/apps/cli/src/prompts/payments.ts +++ b/apps/cli/src/prompts/payments.ts @@ -7,7 +7,7 @@ export async function getPaymentsChoice( payments?: Payments, auth?: Auth, backend?: Backend, - _frontends?: Frontend[], + frontends?: Frontend[], previousValue?: Payments, ) { if (payments !== undefined) return payments; @@ -17,23 +17,41 @@ export async function getPaymentsChoice( } const isPolarCompatible = auth === "better-auth"; + const hasNativeFrontend = (frontends ?? []).some( + (frontend) => + frontend === "native-bare" || + frontend === "native-uniwind" || + frontend === "native-unistyles", + ); + const isRevenueCatCompatible = hasNativeFrontend; - if (!isPolarCompatible) { + if (!isPolarCompatible && !isRevenueCatCompatible) { return "none" as Payments; } - const options = [ - { + const options: Array<{ value: Payments; label: string; hint: string }> = []; + + if (isPolarCompatible) { + options.push({ value: "polar" as Payments, label: "Polar", hint: "Turn your software into a business. 6 lines of code.", - }, - { - value: "none" as Payments, - label: "None", - hint: "No payments integration", - }, - ]; + }); + } + + if (isRevenueCatCompatible) { + options.push({ + value: "revenuecat" as Payments, + label: "RevenueCat", + hint: "In-app subscriptions and cross-platform monetization for mobile.", + }); + } + + options.push({ + value: "none" as Payments, + label: "None", + hint: "No payments integration", + }); const response = await navigableSelect({ message: "Select payments provider", diff --git a/apps/cli/src/utils/compatibility-rules.ts b/apps/cli/src/utils/compatibility-rules.ts index 26d7284f3..4e46557ad 100644 --- a/apps/cli/src/utils/compatibility-rules.ts +++ b/apps/cli/src/utils/compatibility-rules.ts @@ -437,7 +437,7 @@ export function validatePaymentsCompatibility( payments: Payments | undefined, auth: Auth | undefined, _backend: Backend | undefined, - _frontends: Frontend[] = [], + frontends: Frontend[] = [], ): ValidationResult { if (!payments || payments === "none") return Result.ok(undefined); @@ -449,6 +449,15 @@ export function validatePaymentsCompatibility( } } + if (payments === "revenuecat") { + const { native } = splitFrontends(frontends); + if (native.length === 0) { + return validationErr( + "RevenueCat payments requires a native frontend (native-bare, native-uniwind, or native-unistyles). Please select a native frontend or choose a different payments provider.", + ); + } + } + return Result.ok(undefined); } diff --git a/apps/cli/test/matrix/cases.ts b/apps/cli/test/matrix/cases.ts index 9682b60ba..ead339016 100644 --- a/apps/cli/test/matrix/cases.ts +++ b/apps/cli/test/matrix/cases.ts @@ -30,7 +30,7 @@ export const MATRIX_NATIVE_FRONTENDS = [ ] as const; export const MATRIX_APIS = ["trpc", "orpc", "none"] as const; export const MATRIX_AUTHS = ["better-auth", "clerk", "none"] as const; -export const MATRIX_PAYMENTS = ["polar", "none"] as const; +export const MATRIX_PAYMENTS = ["polar", "revenuecat", "none"] as const; export const MATRIX_DB_SETUPS = [ "turso", "neon", @@ -321,6 +321,26 @@ export function createSmokeMatrixCases(): MatrixCase[] { } } + for (const convexAuth of ["none", "better-auth"] as const) { + pushUnique(configs, seen, { + payments: "revenuecat", + backend: "convex", + runtime: "none", + database: "none", + orm: "none", + api: "none", + auth: convexAuth, + frontend: ["native-bare"], + }); + } + + for (const nativeFrontend of MATRIX_NATIVE_FRONTENDS) { + pushUnique(configs, seen, { + payments: "revenuecat", + frontend: [nativeFrontend], + }); + } + for (const dbSetup of MATRIX_DB_SETUPS) { pushUnique(configs, seen, { dbSetup, diff --git a/apps/cli/test/matrix/oracle.ts b/apps/cli/test/matrix/oracle.ts index 35710df4a..a8d7abd27 100644 --- a/apps/cli/test/matrix/oracle.ts +++ b/apps/cli/test/matrix/oracle.ts @@ -31,6 +31,7 @@ export type MatrixRule = | "orm-mongodb-requires-mongoose-or-prisma" | "orm-requires-database" | "payments-polar-requires-better-auth" + | "payments-revenuecat-requires-native-frontend" | "runtime-none-requires-terminal-backend" | "server-deploy-requires-backend" | "server-deploy-requires-workers-runtime" @@ -63,6 +64,12 @@ const FULLSTACK_FRONTENDS: readonly Frontend[] = [ "astro", ] as const; +const NATIVE_FRONTENDS: readonly Frontend[] = [ + "native-bare", + "native-uniwind", + "native-unistyles", +] as const; + const CONVEX_INCOMPATIBLE_FRONTENDS: readonly Frontend[] = ["solid", "astro"] as const; const CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS: readonly Frontend[] = [ @@ -92,6 +99,10 @@ function hasWebFrontend(frontends: readonly Frontend[]) { return hasFrontend(frontends, WEB_FRONTENDS); } +function hasNativeFrontend(frontends: readonly Frontend[]) { + return hasFrontend(frontends, NATIVE_FRONTENDS); +} + function addRule(rules: Set, condition: boolean, rule: MatrixRule) { if (condition) rules.add(rule); } @@ -306,6 +317,12 @@ function validatePayments(config: ProjectConfig, rules: Set) { config.payments === "polar" && config.auth !== "better-auth", "payments-polar-requires-better-auth", ); + + addRule( + rules, + config.payments === "revenuecat" && !hasNativeFrontend(config.frontend), + "payments-revenuecat-requires-native-frontend", + ); } function validateExamples(config: ProjectConfig, rules: Set) { @@ -418,6 +435,9 @@ export function classifyMatrixError(message: string): MatrixRule | "unknown" { if (message.includes("Polar payments requires Better Auth")) { return "payments-polar-requires-better-auth"; } + if (message.includes("RevenueCat payments requires a native frontend")) { + return "payments-revenuecat-requires-native-frontend"; + } if (message.includes("'--runtime none' is only supported")) { return "runtime-none-requires-terminal-backend"; } diff --git a/apps/web/src/app/(home)/new/_components/utils.ts b/apps/web/src/app/(home)/new/_components/utils.ts index 034ae46ad..faab30cf8 100644 --- a/apps/web/src/app/(home)/new/_components/utils.ts +++ b/apps/web/src/app/(home)/new/_components/utils.ts @@ -89,6 +89,9 @@ export const hasClerkCompatibleFrontend = (webFrontend: string[], nativeFrontend ) || nativeFrontend.some((f) => ["native-bare", "native-uniwind", "native-unistyles"].includes(f)); +export const hasNativeFrontend = (nativeFrontend: string[]) => + nativeFrontend.some((f) => ["native-bare", "native-uniwind", "native-unistyles"].includes(f)); + export const hasClerkCompatibleBackend = (backend: string) => clerkSupportedBackends.includes(backend as (typeof clerkSupportedBackends)[number]); @@ -609,6 +612,15 @@ export const analyzeStackCompatibility = (stack: StackState): CompatibilityResul } } + if (nextStack.payments === "revenuecat" && !hasNativeFrontend(nextStack.nativeFrontend)) { + nextStack.payments = "none"; + changed = true; + changes.push({ + category: "payments", + message: "Payments set to 'None' (RevenueCat requires a native frontend)", + }); + } + // ============================================ // ADDONS CONSTRAINTS // ============================================ @@ -1081,6 +1093,12 @@ export const getDisabledReason = ( } } + if (category === "payments" && optionId === "revenuecat") { + if (!hasNativeFrontend(currentStack.nativeFrontend)) { + return "RevenueCat requires a native frontend (Expo)"; + } + } + // ============================================ // ADDONS CONSTRAINTS // ============================================ diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index bb1fd193c..9d4ad2d35 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -476,6 +476,14 @@ export const TECH_OPTIONS: Record< color: "from-purple-400 to-purple-600", default: false, }, + { + id: "revenuecat", + name: "RevenueCat", + description: "In-app subscriptions and cross-platform monetization for mobile.", + icon: `${ICON_BASE_URL}/revenuecat.svg`, + color: "from-red-400 to-red-600", + default: false, + }, { id: "none", name: "No Payments", diff --git a/packages/template-generator/src/processors/env-vars.ts b/packages/template-generator/src/processors/env-vars.ts index ce655a9b8..4a44b433c 100644 --- a/packages/template-generator/src/processors/env-vars.ts +++ b/packages/template-generator/src/processors/env-vars.ts @@ -206,6 +206,7 @@ function buildNativeVars( frontend: string[], backend: ProjectConfig["backend"], auth: ProjectConfig["auth"], + payments: ProjectConfig["payments"], ): EnvVariable[] { const hasAstro = frontend.includes("astro"); const hasSvelte = frontend.includes("svelte"); @@ -251,6 +252,26 @@ function buildNativeVars( }); } + if (payments === "revenuecat") { + vars.push( + { + key: "EXPO_PUBLIC_REVENUECAT_IOS_KEY", + value: "", + condition: true, + }, + { + key: "EXPO_PUBLIC_REVENUECAT_ANDROID_KEY", + value: "", + condition: true, + }, + { + key: "EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID", + value: "pro", + condition: true, + }, + ); + } + return vars; } @@ -363,6 +384,15 @@ function buildConvexBackendVars( ); } + if (payments === "revenuecat") { + vars.push({ + key: "REVENUECAT_WEBHOOK_AUTH", + value: "", + condition: true, + comment: "Shared secret for RevenueCat webhook authentication (min 32 characters)", + }); + } + return vars; } @@ -419,6 +449,15 @@ ${needsConvexSiteUrl ? "# npx convex env set CONVEX_SITE_URL https:///polar/events # Enable: product.created, product.updated, subscription.created, subscription.updated +`; + } + + if (payments === "revenuecat") { + commentBlocks += `# Set RevenueCat environment variables +# npx convex env set REVENUECAT_WEBHOOK_AUTH your_webhook_secret_min_32_chars +# Create a RevenueCat webhook at https:///webhooks/revenuecat +# Set the webhook Authorization header to the same REVENUECAT_WEBHOOK_AUTH value + `; } @@ -606,7 +645,7 @@ export function processEnvVariables(vfs: VirtualFileSystem, config: ProjectConfi const nativeDir = "apps/native"; if (vfs.directoryExists(nativeDir)) { const envPath = `${nativeDir}/.env`; - const nativeVars = buildNativeVars(frontend, backend, auth); + const nativeVars = buildNativeVars(frontend, backend, auth, payments); writeEnvFile(vfs, envPath, nativeVars); } } diff --git a/packages/template-generator/src/processors/payments-deps.ts b/packages/template-generator/src/processors/payments-deps.ts index 77fbc52fd..abc1074d0 100644 --- a/packages/template-generator/src/processors/payments-deps.ts +++ b/packages/template-generator/src/processors/payments-deps.ts @@ -72,4 +72,27 @@ export function processPaymentsDeps(vfs: VirtualFileSystem, config: ProjectConfi } } } + + if (payments === "revenuecat") { + const nativePath = "apps/native/package.json"; + const hasNativeFrontend = frontend.some((f) => + ["native-bare", "native-uniwind", "native-unistyles"].includes(f), + ); + + if (hasNativeFrontend && vfs.exists(nativePath)) { + addPackageDependency({ + vfs, + packagePath: nativePath, + dependencies: ["react-native-purchases"], + }); + } + + if (backend === "convex" && vfs.exists(backendPath)) { + addPackageDependency({ + vfs, + packagePath: backendPath, + dependencies: ["convex-revenuecat"], + }); + } + } } diff --git a/packages/template-generator/src/template-handlers/payments.ts b/packages/template-generator/src/template-handlers/payments.ts index 56f646760..ba859b734 100644 --- a/packages/template-generator/src/template-handlers/payments.ts +++ b/packages/template-generator/src/template-handlers/payments.ts @@ -17,6 +17,14 @@ export async function processPaymentsTemplates( const hasSvelteWeb = config.frontend.includes("svelte"); const hasSolidWeb = config.frontend.includes("solid"); + const nativeVariant = config.frontend.includes("native-bare") + ? "bare" + : config.frontend.includes("native-uniwind") + ? "uniwind" + : config.frontend.includes("native-unistyles") + ? "unistyles" + : null; + if (config.backend === "convex") { processTemplatesFromPrefix( vfs, @@ -25,7 +33,16 @@ export async function processPaymentsTemplates( "packages/backend", config, ); - return; + + if (config.payments === "revenuecat" && config.auth !== "better-auth") { + processTemplatesFromPrefix( + vfs, + templates, + "payments/revenuecat/convex/no-better-auth", + "packages/backend", + config, + ); + } } else if (config.backend !== "none") { processTemplatesFromPrefix( vfs, @@ -36,6 +53,25 @@ export async function processPaymentsTemplates( ); } + if (nativeVariant) { + processTemplatesFromPrefix( + vfs, + templates, + `payments/${config.payments}/native/base`, + "apps/native", + config, + ); + processTemplatesFromPrefix( + vfs, + templates, + `payments/${config.payments}/native/${nativeVariant}`, + "apps/native", + config, + ); + } + + if (config.backend === "convex") return; + if (hasReactWeb) { const reactFramework = config.frontend.find((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), diff --git a/packages/template-generator/src/templates.generated.ts b/packages/template-generator/src/templates.generated.ts index aaf493d23..56ac58cc1 100644 --- a/packages/template-generator/src/templates.generated.ts +++ b/packages/template-generator/src/templates.generated.ts @@ -2293,6 +2293,9 @@ import { httpAction } from "./_generated/server"; {{#if (eq payments "polar")}} import { polar } from "./polar"; {{/if}} +{{#if (eq payments "revenuecat")}} +import { revenuecat } from "./revenuecat"; +{{/if}} const http = httpRouter(); @@ -2337,6 +2340,10 @@ authComponent.registerRoutes(http, createAuth); polar.registerRoutes(http); {{/if}} +{{#if (eq payments "revenuecat")}} + +revenuecat.registerRoutes(http); +{{/if}} export default http; `], @@ -5597,6 +5604,10 @@ import { NAV_THEME } from "@/lib/constants"; import { authClient{{#if (eq payments "polar")}}, polarNativeClient{{/if}} } from "@/lib/auth-client"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -5784,6 +5795,11 @@ return ( )} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} @@ -6403,6 +6419,10 @@ import { StyleSheet } from "react-native-unistyles"; import { Container } from "@/components/container"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -6550,6 +6570,11 @@ export default function Home() { )} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} @@ -7144,6 +7169,10 @@ import { Ionicons } from "@expo/vector-icons"; import { Card, Chip, useThemeColor } from "heroui-native"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -7315,6 +7344,13 @@ return ( )} + + {{#if (eq payments "revenuecat")}} + + + + + {{/if}} ); } @@ -13967,6 +14003,9 @@ import betterAuth from "@convex-dev/better-auth/convex.config"; {{#if (eq payments "polar")}} import polar from "@convex-dev/polar/convex.config.js"; {{/if}} +{{#if (eq payments "revenuecat")}} +import revenuecat from "convex-revenuecat/convex.config"; +{{/if}} {{#if (includes examples "ai")}} import agent from "@convex-dev/agent/convex.config"; {{/if}} @@ -13978,6 +14017,9 @@ app.use(betterAuth); {{#if (eq payments "polar")}} app.use(polar); {{/if}} +{{#if (eq payments "revenuecat")}} +app.use(revenuecat); +{{/if}} {{#if (includes examples "ai")}} app.use(agent); {{/if}} @@ -23486,6 +23528,9 @@ import { queryClient } from "@/utils/orpc"; import { NAV_THEME } from "@/lib/constants"; import { useColorScheme } from "@/lib/use-color-scheme"; import { StyleSheet } from "react-native"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} const LIGHT_THEME = { ...DefaultTheme, @@ -23533,6 +23578,9 @@ export default function RootLayout() { return ( <> +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -23630,6 +23678,9 @@ export default function RootLayout() { {{/unless}} {{/if}} {{/if}} +{{#if (eq payments "revenuecat")}} + +{{/if}} ); } @@ -23867,6 +23918,10 @@ import { env } from "@{{projectName}}/env/native"; import { Container } from "@/components/container"; import { useColorScheme } from "@/lib/use-color-scheme"; import { NAV_THEME } from "@/lib/constants"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { orpc } from "@/utils/orpc"; @@ -24197,6 +24252,11 @@ return ( )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} @@ -24700,6 +24760,9 @@ import { Stack } from "expo-router"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useUnistyles } from "react-native-unistyles"; import { StatusBar } from "expo-status-bar"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} export const unstable_settings = { initialRouteName: "(drawer)", @@ -24731,6 +24794,9 @@ export default function RootLayout() { const { theme } = useUnistyles(); return ( +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} +{{/if}} ); } `], @@ -25119,6 +25188,10 @@ import { env } from "@{{projectName}}/env/native"; {{/if}} import { StyleSheet } from "react-native-unistyles"; import { Container } from "@/components/container"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; @@ -25408,6 +25481,11 @@ export default function Home() { )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} ); @@ -26102,6 +26180,9 @@ import { HeroUINativeProvider } from "heroui-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { AppThemeProvider } from "@/contexts/app-theme-context"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} {{#if (eq api "trpc")}} import { queryClient } from "@/utils/trpc"; @@ -26150,6 +26231,9 @@ function StackLayout() { export default function Layout() { return ( +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -26244,6 +26328,9 @@ export default function Layout() { {{/unless}} {{/if}} {{/if}} +{{#if (eq payments "revenuecat")}} + +{{/if}} ); } `], @@ -26456,6 +26543,10 @@ import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Ionicons } from "@expo/vector-icons"; {{/unless}} import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} export default function Home() { {{#if (eq api "orpc")}} @@ -26712,6 +26803,13 @@ return ( )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + + + {{/if}} ); } @@ -31984,7 +32082,983 @@ function SuccessPage() {

Checkout ID: {checkout_id}

{/if} +`], + ["payments/revenuecat/convex/backend/convex/revenuecat.ts.hbs", `import { RevenueCat } from "convex-revenuecat"; + +import { components } from "./_generated/api"; + +export const revenuecat = new RevenueCat(components.revenuecat, { + REVENUECAT_WEBHOOK_AUTH: process.env.REVENUECAT_WEBHOOK_AUTH, +}); + +export const { hasEntitlement, isSubscriber, getActiveSubscriptions } = revenuecat.api(); +`], + ["payments/revenuecat/convex/no-better-auth/convex/http.ts.hbs", `import { httpRouter } from "convex/server"; + +import { revenuecat } from "./revenuecat"; + +const http = httpRouter(); + +revenuecat.registerRoutes(http); + +export default http; +`], + ["payments/revenuecat/native/bare/components/paywall-example.tsx.hbs", `import { useState } from "react"; +import { Button, Column, Host, Text as ExpoUIText } from "@expo/ui"; +import { ActivityIndicator, Alert, StyleSheet, View } from "react-native"; +import type { PurchasesPackage } from "react-native-purchases"; + +import { NAV_THEME } from "@/lib/constants"; +import { useColorScheme } from "@/lib/use-color-scheme"; +import { useRevenueCat } from "@/contexts/revenuecat-context"; + +export function PaywallExample() { + const { colorScheme } = useColorScheme(); + const theme = colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light; + const { isPro, getPackages, purchasePackage, restorePurchases } = useRevenueCat(); + const [packages, setPackages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const isBusy = isPurchasing || isRestoring; + + const loadPackages = async () => { + setIsLoading(true); + try { + setPackages(await getPackages()); + } finally { + setIsLoading(false); + } + }; + + const handlePurchase = async (pack: PurchasesPackage) => { + if (isBusy) return; + setIsPurchasing(true); + try { + const success = await purchasePackage(pack); + Alert.alert(success ? "You're now Premium!" : "Purchase not completed"); + } finally { + setIsPurchasing(false); + } + }; + + const handleRestore = async () => { + if (isBusy) return; + setIsRestoring(true); + try { + const success = await restorePurchases(); + Alert.alert(success ? "Purchases restored" : "No purchases to restore"); + } finally { + setIsRestoring(false); + } + }; + + if (isPro) { + return ( + + + + + You're Premium + + + Thanks for your support — enjoy all features. + + + + + ); + } + + return ( + + + + + Upgrade to Premium + + + Unlock all features. + + + + + {isLoading ? ( + + + + ) : ( + + + {packages.length === 0 ? ( + + ) : ( + packages.map((pack) => ( + + )) + )} + + + + + ); +} +`], + ["payments/revenuecat/native/uniwind/components/subscription-status-card.tsx.hbs", `import { Text } from "react-native"; +import { Card } from "heroui-native"; + +import { useRevenueCat } from "@/contexts/revenuecat-context"; + +export function SubscriptionStatusCard() { + const { isPro, isConfigured } = useRevenueCat(); + + return ( + + Subscription + {!isConfigured ? ( + + RevenueCat is not configured yet. Add your API keys in apps/native/.env. + + ) : isPro ? ( + + You're a Premium member 🎉 + + ) : ( + You're on the free plan. + )} + + ); +} `] ]); -export const TEMPLATE_COUNT = 497; +export const TEMPLATE_COUNT = 507; diff --git a/packages/template-generator/src/utils/add-deps.ts b/packages/template-generator/src/utils/add-deps.ts index 4dd392e23..38d58efea 100644 --- a/packages/template-generator/src/utils/add-deps.ts +++ b/packages/template-generator/src/utils/add-deps.ts @@ -124,6 +124,7 @@ export const dependencyVersionMap = { "@convex-dev/react-query": "^0.1.0", "@convex-dev/agent": "^0.3.2", "@convex-dev/polar": "^0.9.1", + "convex-revenuecat": "^0.3.2", "convex-svelte": "^0.0.12", "convex-nuxt": "0.1.5", "convex-vue": "^0.1.5", @@ -171,6 +172,8 @@ export const dependencyVersionMap = { "@stripe/react-stripe-js": "^4.0.2", "@stripe/stripe-js": "^7.9.0", + "react-native-purchases": "^9.10.5", + evlog: "^2.18.1", } as const; diff --git a/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs b/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs index c1565a344..4d6d7c24a 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs @@ -6,6 +6,9 @@ import { httpAction } from "./_generated/server"; {{#if (eq payments "polar")}} import { polar } from "./polar"; {{/if}} +{{#if (eq payments "revenuecat")}} +import { revenuecat } from "./revenuecat"; +{{/if}} const http = httpRouter(); @@ -50,5 +53,9 @@ authComponent.registerRoutes(http, createAuth); polar.registerRoutes(http); {{/if}} +{{#if (eq payments "revenuecat")}} + +revenuecat.registerRoutes(http); +{{/if}} export default http; diff --git a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs index 9a7d68ccb..2cb4287ab 100644 --- a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs @@ -11,6 +11,10 @@ import { NAV_THEME } from "@/lib/constants"; import { authClient{{#if (eq payments "polar")}}, polarNativeClient{{/if}} } from "@/lib/auth-client"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -198,6 +202,11 @@ return ( )} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} diff --git a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs index 400a1c412..4de83e797 100644 --- a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs @@ -10,6 +10,10 @@ import { StyleSheet } from "react-native-unistyles"; import { Container } from "@/components/container"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -157,6 +161,11 @@ export default function Home() { )} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} diff --git a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs index 71ae7f85f..710f8ac9c 100644 --- a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs @@ -10,6 +10,10 @@ import { Ionicons } from "@expo/vector-icons"; import { Card, Chip, useThemeColor } from "heroui-native"; import { SignIn } from "@/components/sign-in"; import { SignUp } from "@/components/sign-up"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { queryClient, orpc } from "@/utils/orpc"; @@ -181,6 +185,13 @@ return ( )} + + {{#if (eq payments "revenuecat")}} + + + + + {{/if}} ); } diff --git a/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs b/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs index 630b1e044..de9aa52f1 100644 --- a/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs +++ b/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs @@ -5,6 +5,9 @@ import betterAuth from "@convex-dev/better-auth/convex.config"; {{#if (eq payments "polar")}} import polar from "@convex-dev/polar/convex.config.js"; {{/if}} +{{#if (eq payments "revenuecat")}} +import revenuecat from "convex-revenuecat/convex.config"; +{{/if}} {{#if (includes examples "ai")}} import agent from "@convex-dev/agent/convex.config"; {{/if}} @@ -16,6 +19,9 @@ app.use(betterAuth); {{#if (eq payments "polar")}} app.use(polar); {{/if}} +{{#if (eq payments "revenuecat")}} +app.use(revenuecat); +{{/if}} {{#if (includes examples "ai")}} app.use(agent); {{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs index c36c0149b..75d291f72 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs @@ -8,6 +8,10 @@ import { env } from "@{{projectName}}/env/native"; import { Container } from "@/components/container"; import { useColorScheme } from "@/lib/use-color-scheme"; import { NAV_THEME } from "@/lib/constants"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; import { orpc } from "@/utils/orpc"; @@ -338,6 +342,11 @@ return ( )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs index 53bcdc97a..2abd46c87 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs @@ -45,6 +45,9 @@ import { queryClient } from "@/utils/orpc"; import { NAV_THEME } from "@/lib/constants"; import { useColorScheme } from "@/lib/use-color-scheme"; import { StyleSheet } from "react-native"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} const LIGHT_THEME = { ...DefaultTheme, @@ -92,6 +95,9 @@ export default function RootLayout() { return ( <> +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -189,6 +195,9 @@ export default function RootLayout() { {{/unless}} {{/if}} {{/if}} +{{#if (eq payments "revenuecat")}} + +{{/if}} ); } diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs index 51b9ffc9f..d72308705 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs @@ -6,6 +6,10 @@ import { env } from "@{{projectName}}/env/native"; {{/if}} import { StyleSheet } from "react-native-unistyles"; import { Container } from "@/components/container"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} {{#if (eq api "orpc")}} import { useQuery } from "@tanstack/react-query"; @@ -295,6 +299,11 @@ export default function Home() { )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + {{/if}} ); diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs index 2fa46a3cc..ff2416d37 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs @@ -40,6 +40,9 @@ import { Stack } from "expo-router"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useUnistyles } from "react-native-unistyles"; import { StatusBar } from "expo-status-bar"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} export const unstable_settings = { initialRouteName: "(drawer)", @@ -71,6 +74,9 @@ export default function RootLayout() { const { theme } = useUnistyles(); return ( +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} +{{/if}} ); } diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs index 1b0ff2ca6..de46ccfba 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs @@ -37,6 +37,10 @@ import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Ionicons } from "@expo/vector-icons"; {{/unless}} import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native"; +{{#if (eq payments "revenuecat")}} +import { SubscriptionStatusCard } from "@/components/subscription-status-card"; +import { PaywallExample } from "@/components/paywall-example"; +{{/if}} export default function Home() { {{#if (eq api "orpc")}} @@ -293,6 +297,13 @@ return ( )} {{/if}} + + {{#if (eq payments "revenuecat")}} + + + + + {{/if}} ); } diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs index eaeda365c..934890f82 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs @@ -40,6 +40,9 @@ import { HeroUINativeProvider } from "heroui-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { AppThemeProvider } from "@/contexts/app-theme-context"; +{{#if (eq payments "revenuecat")}} +import { RevenueCatProvider } from "@/contexts/revenuecat-context"; +{{/if}} {{#if (eq api "trpc")}} import { queryClient } from "@/utils/trpc"; @@ -88,6 +91,9 @@ function StackLayout() { export default function Layout() { return ( +{{#if (eq payments "revenuecat")}} + +{{/if}} {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -182,5 +188,8 @@ export default function Layout() { {{/unless}} {{/if}} {{/if}} +{{#if (eq payments "revenuecat")}} + +{{/if}} ); } diff --git a/packages/template-generator/templates/payments/revenuecat/convex/backend/convex/revenuecat.ts.hbs b/packages/template-generator/templates/payments/revenuecat/convex/backend/convex/revenuecat.ts.hbs new file mode 100644 index 000000000..f4c7b0bf3 --- /dev/null +++ b/packages/template-generator/templates/payments/revenuecat/convex/backend/convex/revenuecat.ts.hbs @@ -0,0 +1,9 @@ +import { RevenueCat } from "convex-revenuecat"; + +import { components } from "./_generated/api"; + +export const revenuecat = new RevenueCat(components.revenuecat, { + REVENUECAT_WEBHOOK_AUTH: process.env.REVENUECAT_WEBHOOK_AUTH, +}); + +export const { hasEntitlement, isSubscriber, getActiveSubscriptions } = revenuecat.api(); diff --git a/packages/template-generator/templates/payments/revenuecat/convex/no-better-auth/convex/http.ts.hbs b/packages/template-generator/templates/payments/revenuecat/convex/no-better-auth/convex/http.ts.hbs new file mode 100644 index 000000000..404f09bc9 --- /dev/null +++ b/packages/template-generator/templates/payments/revenuecat/convex/no-better-auth/convex/http.ts.hbs @@ -0,0 +1,9 @@ +import { httpRouter } from "convex/server"; + +import { revenuecat } from "./revenuecat"; + +const http = httpRouter(); + +revenuecat.registerRoutes(http); + +export default http; diff --git a/packages/template-generator/templates/payments/revenuecat/native/bare/components/paywall-example.tsx.hbs b/packages/template-generator/templates/payments/revenuecat/native/bare/components/paywall-example.tsx.hbs new file mode 100644 index 000000000..df5aef1b7 --- /dev/null +++ b/packages/template-generator/templates/payments/revenuecat/native/bare/components/paywall-example.tsx.hbs @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { Button, Column, Host, Text as ExpoUIText } from "@expo/ui"; +import { ActivityIndicator, Alert, StyleSheet, View } from "react-native"; +import type { PurchasesPackage } from "react-native-purchases"; + +import { NAV_THEME } from "@/lib/constants"; +import { useColorScheme } from "@/lib/use-color-scheme"; +import { useRevenueCat } from "@/contexts/revenuecat-context"; + +export function PaywallExample() { + const { colorScheme } = useColorScheme(); + const theme = colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light; + const { isPro, getPackages, purchasePackage, restorePurchases } = useRevenueCat(); + const [packages, setPackages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const isBusy = isPurchasing || isRestoring; + + const loadPackages = async () => { + setIsLoading(true); + try { + setPackages(await getPackages()); + } finally { + setIsLoading(false); + } + }; + + const handlePurchase = async (pack: PurchasesPackage) => { + if (isBusy) return; + setIsPurchasing(true); + try { + const success = await purchasePackage(pack); + Alert.alert(success ? "You're now Premium!" : "Purchase not completed"); + } finally { + setIsPurchasing(false); + } + }; + + const handleRestore = async () => { + if (isBusy) return; + setIsRestoring(true); + try { + const success = await restorePurchases(); + Alert.alert(success ? "Purchases restored" : "No purchases to restore"); + } finally { + setIsRestoring(false); + } + }; + + if (isPro) { + return ( + + + + + You're Premium + + + Thanks for your support — enjoy all features. + + + + + ); + } + + return ( + + + + + Upgrade to Premium + + + Unlock all features. + + + + + {isLoading ? ( + + + + ) : ( + + + {packages.length === 0 ? ( + + ) : ( + packages.map((pack) => ( + + )) + )} + + + + + ); +} diff --git a/packages/template-generator/templates/payments/revenuecat/native/uniwind/components/subscription-status-card.tsx.hbs b/packages/template-generator/templates/payments/revenuecat/native/uniwind/components/subscription-status-card.tsx.hbs new file mode 100644 index 000000000..2ac36a49f --- /dev/null +++ b/packages/template-generator/templates/payments/revenuecat/native/uniwind/components/subscription-status-card.tsx.hbs @@ -0,0 +1,25 @@ +import { Text } from "react-native"; +import { Card } from "heroui-native"; + +import { useRevenueCat } from "@/contexts/revenuecat-context"; + +export function SubscriptionStatusCard() { + const { isPro, isConfigured } = useRevenueCat(); + + return ( + + Subscription + {!isConfigured ? ( + + RevenueCat is not configured yet. Add your API keys in apps/native/.env. + + ) : isPro ? ( + + You're a Premium member 🎉 + + ) : ( + You're on the free plan. + )} + + ); +} diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index c8b90486e..6986317e1 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -91,7 +91,7 @@ export const AuthSchema = z .enum(["better-auth", "clerk", "none"]) .describe("Authentication provider"); -export const PaymentsSchema = z.enum(["polar", "none"]).describe("Payments provider"); +export const PaymentsSchema = z.enum(["polar", "revenuecat", "none"]).describe("Payments provider"); export const WebDeploySchema = z.enum(["cloudflare", "docker", "none"]).describe("Web deployment"); @@ -159,6 +159,7 @@ export const McpServerSchema = z "clerk", "expo", "polar", + "revenuecat", ]) .describe("MCP server to install");