diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 90227ae29..3f10a40f6 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -13,6 +13,7 @@ export const DEFAULT_CONFIG_BASE = { database: "sqlite", orm: "drizzle", auth: "better-auth", + payments: "none", addons: ["turborepo"], examples: [], git: true, @@ -39,8 +40,8 @@ export function getDefaultConfig() { export const DEFAULT_CONFIG = getDefaultConfig(); export const dependencyVersionMap = { - "better-auth": "^1.3.9", - "@better-auth/expo": "^1.3.9", + "better-auth": "^1.3.10", + "@better-auth/expo": "^1.3.10", "@clerk/nextjs": "^6.31.5", "@clerk/clerk-react": "^5.45.0", @@ -139,9 +140,9 @@ export const dependencyVersionMap = { "@tanstack/react-query-devtools": "^5.85.5", "@tanstack/react-query": "^5.85.5", - "@tanstack/solid-query": "^5.75.0", - "@tanstack/solid-query-devtools": "^5.75.0", - "@tanstack/solid-router-devtools": "^1.131.25", + "@tanstack/solid-query": "^5.87.4", + "@tanstack/solid-query-devtools": "^5.87.4", + "@tanstack/solid-router-devtools": "^1.131.44", wrangler: "^4.23.0", "@cloudflare/vite-plugin": "^1.9.0", @@ -155,6 +156,9 @@ export const dependencyVersionMap = { nitropack: "^2.12.4", dotenv: "^17.2.1", + + "@polar-sh/better-auth": "^1.1.3", + "@polar-sh/sdk": "^0.34.16", } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; diff --git a/apps/cli/src/helpers/core/add-addons.ts b/apps/cli/src/helpers/core/add-addons.ts index 82de24660..1774530be 100644 --- a/apps/cli/src/helpers/core/add-addons.ts +++ b/apps/cli/src/helpers/core/add-addons.ts @@ -2,8 +2,8 @@ import path from "node:path"; import { log } from "@clack/prompts"; import pc from "picocolors"; import type { AddInput, Addons, ProjectConfig } from "../../types"; -import { validateAddonCompatibility } from "../../utils/addon-compatibility"; import { updateBtsConfig } from "../../utils/bts-config"; +import { validateAddonCompatibility } from "../../utils/compatibility-rules"; import { exitWithError } from "../../utils/errors"; import { setupAddons } from "../addons/addons-setup"; import { @@ -45,6 +45,7 @@ export async function addAddonsToProject( addons: input.addons, examples: detectedConfig.examples || [], auth: detectedConfig.auth || "none", + payments: detectedConfig.payments || "none", git: false, packageManager: input.packageManager || detectedConfig.packageManager || "npm", diff --git a/apps/cli/src/helpers/core/add-deployment.ts b/apps/cli/src/helpers/core/add-deployment.ts index 7dbc04dc9..96e5b69a1 100644 --- a/apps/cli/src/helpers/core/add-deployment.ts +++ b/apps/cli/src/helpers/core/add-deployment.ts @@ -69,6 +69,7 @@ export async function addDeploymentToProject( addons: detectedConfig.addons || [], examples: detectedConfig.examples || [], auth: detectedConfig.auth || "none", + payments: detectedConfig.payments || "none", git: false, packageManager: input.packageManager || detectedConfig.packageManager || "npm", diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 8af914621..af735cf6f 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -103,6 +103,7 @@ export async function createProjectHandler( addons: [], examples: [], auth: "none", + payments: "none", git: false, packageManager: "npm", install: false, @@ -272,6 +273,7 @@ export async function addAddonsHandler(input: AddInput) { const addonsPrompt = await getAddonsToAdd( detectedConfig.frontend || [], detectedConfig.addons || [], + detectedConfig.auth, ); if (addonsPrompt.length > 0) { diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index 03d1d4292..8de6ee17b 100644 --- a/apps/cli/src/helpers/core/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -17,6 +17,7 @@ import { createReadme } from "./create-readme"; import { setupEnvironmentVariables } from "./env-setup"; import { initializeGit } from "./git"; import { installDependencies } from "./install-dependencies"; +import { setupPayments } from "./payments-setup"; import { displayPostInstallInstructions } from "./post-installation"; import { updatePackageConfigurations } from "./project-config"; import { @@ -30,6 +31,7 @@ import { setupDockerComposeTemplates, setupExamplesTemplate, setupFrontendTemplates, + setupPaymentsTemplate, } from "./template-manager"; export async function createProject( @@ -50,6 +52,9 @@ export async function createProject( await setupDockerComposeTemplates(projectDir, options); } await setupAuthTemplate(projectDir, options); + if (options.payments && options.payments !== "none") { + await setupPaymentsTemplate(projectDir, options); + } if (options.examples.length > 0 && options.examples[0] !== "none") { await setupExamplesTemplate(projectDir, options); } @@ -76,6 +81,10 @@ export async function createProject( await setupAuth(options); } + if (options.payments && options.payments !== "none") { + await setupPayments(options); + } + await handleExtras(projectDir, options); await setupEnvironmentVariables(options); diff --git a/apps/cli/src/helpers/core/detect-project-config.ts b/apps/cli/src/helpers/core/detect-project-config.ts index f4857dbf9..ffee78ecd 100644 --- a/apps/cli/src/helpers/core/detect-project-config.ts +++ b/apps/cli/src/helpers/core/detect-project-config.ts @@ -17,6 +17,7 @@ export async function detectProjectConfig(projectDir: string) { addons: btsConfig.addons, examples: btsConfig.examples, auth: btsConfig.auth, + payments: btsConfig.payments, packageManager: btsConfig.packageManager, dbSetup: btsConfig.dbSetup, api: btsConfig.api, diff --git a/apps/cli/src/helpers/core/env-setup.ts b/apps/cli/src/helpers/core/env-setup.ts index f31a5d689..162741f58 100644 --- a/apps/cli/src/helpers/core/env-setup.ts +++ b/apps/cli/src/helpers/core/env-setup.ts @@ -280,6 +280,16 @@ export async function setupEnvironmentVariables(config: ProjectConfig) { value: "", condition: examples?.includes("ai") || false, }, + { + key: "POLAR_ACCESS_TOKEN", + value: "", + condition: config.payments === "polar", + }, + { + key: "POLAR_SUCCESS_URL", + value: `${corsOrigin}/success?checkout_id={CHECKOUT_ID}`, + condition: config.payments === "polar", + }, ]; await addEnvVariablesToFile(envPath, serverVars); diff --git a/apps/cli/src/helpers/core/payments-setup.ts b/apps/cli/src/helpers/core/payments-setup.ts new file mode 100644 index 000000000..5557b826d --- /dev/null +++ b/apps/cli/src/helpers/core/payments-setup.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { ProjectConfig } from "../../types"; +import { addPackageDependency } from "../../utils/add-package-deps"; + +export async function setupPayments(config: ProjectConfig) { + const { payments, projectDir, frontend } = config; + + if (!payments || payments === "none") { + return; + } + + const serverDir = path.join(projectDir, "apps/server"); + const clientDir = path.join(projectDir, "apps/web"); + + const serverDirExists = await fs.pathExists(serverDir); + const clientDirExists = await fs.pathExists(clientDir); + + if (!serverDirExists) { + return; + } + + if (payments === "polar") { + await addPackageDependency({ + dependencies: ["@polar-sh/better-auth", "@polar-sh/sdk"], + projectDir: serverDir, + }); + + if (clientDirExists) { + const hasWebFrontend = frontend.some((f) => + [ + "react-router", + "tanstack-router", + "tanstack-start", + "next", + "nuxt", + "svelte", + "solid", + ].includes(f), + ); + + if (hasWebFrontend) { + await addPackageDependency({ + dependencies: ["@polar-sh/better-auth"], + projectDir: clientDir, + }); + } + } + } +} diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts index 1b5bebd53..5c6742e26 100644 --- a/apps/cli/src/helpers/core/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -72,6 +72,10 @@ export async function displayPostInstallInstructions( : ""; const clerkInstructions = isConvex && config.auth === "clerk" ? getClerkInstructions() : ""; + const polarInstructions = + config.payments === "polar" && config.auth === "better-auth" + ? getPolarInstructions() + : ""; const wranglerDeployInstructions = getWranglerDeployInstructions( runCmd, webDeploy, @@ -188,6 +192,7 @@ export async function displayPostInstallInstructions( output += `\n${alchemyDeployInstructions.trim()}\n`; if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`; if (clerkInstructions) output += `\n${clerkInstructions.trim()}\n`; + if (polarInstructions) output += `\n${polarInstructions.trim()}\n`; if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`; @@ -447,6 +452,10 @@ function getClerkInstructions() { return `${pc.bold("Clerk Authentication Setup:")}\n${pc.cyan("•")} Follow the guide: ${pc.underline("https://docs.convex.dev/auth/clerk")}\n${pc.cyan("•")} Set CLERK_JWT_ISSUER_DOMAIN in Convex Dashboard\n${pc.cyan("•")} Set CLERK_PUBLISHABLE_KEY in apps/*/.env`; } +function getPolarInstructions() { + 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 apps/server/.env`; +} + function getAlchemyDeployInstructions( runCmd?: string, webDeploy?: string, diff --git a/apps/cli/src/helpers/core/template-manager.ts b/apps/cli/src/helpers/core/template-manager.ts index c90e0eb3d..5ffa9ec94 100644 --- a/apps/cli/src/helpers/core/template-manager.ts +++ b/apps/cli/src/helpers/core/template-manager.ts @@ -607,6 +607,102 @@ export async function setupAuthTemplate( } } +export async function setupPaymentsTemplate( + projectDir: string, + context: ProjectConfig, +) { + if (!context.payments || context.payments === "none") return; + + const serverAppDir = path.join(projectDir, "apps/server"); + const webAppDir = path.join(projectDir, "apps/web"); + + const serverAppDirExists = await fs.pathExists(serverAppDir); + const webAppDirExists = await fs.pathExists(webAppDir); + + if (serverAppDirExists && context.backend !== "convex") { + const paymentsServerSrc = path.join( + PKG_ROOT, + `templates/payments/${context.payments}/server/base`, + ); + if (await fs.pathExists(paymentsServerSrc)) { + await processAndCopyFiles( + "**/*", + paymentsServerSrc, + serverAppDir, + context, + ); + } + } + + const hasReactWeb = context.frontend.some((f) => + ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), + ); + const hasNuxtWeb = context.frontend.includes("nuxt"); + const hasSvelteWeb = context.frontend.includes("svelte"); + const hasSolidWeb = context.frontend.includes("solid"); + + if ( + webAppDirExists && + (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) + ) { + if (hasReactWeb) { + const reactFramework = context.frontend.find((f) => + ["tanstack-router", "react-router", "tanstack-start", "next"].includes( + f, + ), + ); + if (reactFramework) { + const paymentsWebSrc = path.join( + PKG_ROOT, + `templates/payments/${context.payments}/web/react/${reactFramework}`, + ); + if (await fs.pathExists(paymentsWebSrc)) { + await processAndCopyFiles("**/*", paymentsWebSrc, webAppDir, context); + } + } + } else if (hasNuxtWeb) { + const paymentsWebNuxtSrc = path.join( + PKG_ROOT, + `templates/payments/${context.payments}/web/nuxt`, + ); + if (await fs.pathExists(paymentsWebNuxtSrc)) { + await processAndCopyFiles( + "**/*", + paymentsWebNuxtSrc, + webAppDir, + context, + ); + } + } else if (hasSvelteWeb) { + const paymentsWebSvelteSrc = path.join( + PKG_ROOT, + `templates/payments/${context.payments}/web/svelte`, + ); + if (await fs.pathExists(paymentsWebSvelteSrc)) { + await processAndCopyFiles( + "**/*", + paymentsWebSvelteSrc, + webAppDir, + context, + ); + } + } else if (hasSolidWeb) { + const paymentsWebSolidSrc = path.join( + PKG_ROOT, + `templates/payments/${context.payments}/web/solid`, + ); + if (await fs.pathExists(paymentsWebSolidSrc)) { + await processAndCopyFiles( + "**/*", + paymentsWebSolidSrc, + webAppDir, + context, + ); + } + } + } +} + export async function setupAddonsTemplate( projectDir: string, context: ProjectConfig, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 7149a421c..d10eac107 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -32,6 +32,7 @@ import { ORMSchema, type PackageManager, PackageManagerSchema, + PaymentsSchema, type ProjectConfig, ProjectNameSchema, type Runtime, @@ -80,6 +81,7 @@ export const router = t.router({ database: DatabaseSchema.optional(), orm: ORMSchema.optional(), auth: AuthSchema.optional(), + payments: PaymentsSchema.optional(), frontend: z.array(FrontendSchema).optional(), addons: z.array(AddonsSchema).optional(), examples: z.array(ExamplesSchema).optional(), diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index d2099a05a..7bbde406b 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -1,10 +1,10 @@ import { groupMultiselect, isCancel } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; -import { type Addons, AddonsSchema, type Frontend } from "../types"; +import { type Addons, AddonsSchema, type Auth, type Frontend } from "../types"; import { getCompatibleAddons, validateAddonCompatibility, -} from "../utils/addon-compatibility"; +} from "../utils/compatibility-rules"; import { exitCancelled } from "../utils/errors"; type AddonOption = { @@ -75,6 +75,7 @@ const ADDON_GROUPS = { export async function getAddonsChoice( addons?: Addons[], frontends?: Frontend[], + auth?: Auth, ) { if (addons !== undefined) return addons; @@ -88,7 +89,11 @@ export async function getAddonsChoice( const frontendsArray = frontends || []; for (const addon of allAddons) { - const { isCompatible } = validateAddonCompatibility(addon, frontendsArray); + const { isCompatible } = validateAddonCompatibility( + addon, + frontendsArray, + auth, + ); if (!isCompatible) continue; const { label, hint } = getAddonDisplay(addon); @@ -131,6 +136,7 @@ export async function getAddonsChoice( export async function getAddonsToAdd( frontend: Frontend[], existingAddons: Addons[] = [], + auth?: Auth, ) { const groupedOptions: Record = { Documentation: [], @@ -144,6 +150,7 @@ export async function getAddonsToAdd( AddonsSchema.options.filter((addon) => addon !== "none"), frontendArray, existingAddons, + auth, ); for (const addon of compatibleAddons) { diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index c56acddbc..da62735da 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -10,6 +10,7 @@ import type { Frontend, ORM, PackageManager, + Payments, ProjectConfig, Runtime, ServerDeploy, @@ -28,6 +29,7 @@ import { getGitChoice } from "./git"; import { getinstallChoice } from "./install"; import { getORMChoice } from "./orm"; import { getPackageManagerChoice } from "./package-manager"; +import { getPaymentsChoice } from "./payments"; import { getRuntimeChoice } from "./runtime"; import { getServerDeploymentChoice } from "./server-deploy"; import { getDeploymentChoice } from "./web-deploy"; @@ -40,6 +42,7 @@ type PromptGroupResults = { orm: ORM; api: API; auth: Auth; + payments: Payments; addons: Addons[]; examples: Examples[]; dbSetup: DatabaseSetup; @@ -87,7 +90,15 @@ export async function gatherConfig( results.backend, results.frontend, ), - addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), + payments: ({ results }) => + getPaymentsChoice( + flags.payments, + results.auth, + results.backend, + results.frontend, + ), + addons: ({ results }) => + getAddonsChoice(flags.addons, results.frontend, results.auth), examples: ({ results }) => getExamplesChoice( flags.examples, @@ -137,6 +148,7 @@ export async function gatherConfig( database: result.database, orm: result.orm, auth: result.auth, + payments: result.payments, addons: result.addons, examples: result.examples, git: result.git, diff --git a/apps/cli/src/prompts/payments.ts b/apps/cli/src/prompts/payments.ts new file mode 100644 index 000000000..e720bbacc --- /dev/null +++ b/apps/cli/src/prompts/payments.ts @@ -0,0 +1,46 @@ +import { isCancel, select } from "@clack/prompts"; +import { DEFAULT_CONFIG } from "../constants"; +import type { Auth, Backend, Frontend, Payments } from "../types"; +import { splitFrontends } from "../utils/compatibility-rules"; +import { exitCancelled } from "../utils/errors"; + +export async function getPaymentsChoice( + payments?: Payments, + auth?: Auth, + backend?: Backend, + frontends?: Frontend[], +) { + if (payments !== undefined) return payments; + + const isPolarCompatible = + auth === "better-auth" && + backend !== "convex" && + (frontends?.length === 0 || splitFrontends(frontends).web.length > 0); + + if (!isPolarCompatible) { + return "none" as Payments; + } + + const options = [ + { + 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", + }, + ]; + + const response = await select({ + message: "Select payments provider", + options, + initialValue: DEFAULT_CONFIG.payments, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index ce3bce86a..679c53dbe 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -86,6 +86,11 @@ export const AuthSchema = z .describe("Authentication provider"); export type Auth = z.infer; +export const PaymentsSchema = z + .enum(["polar", "none"]) + .describe("Payments provider"); +export type Payments = z.infer; + export const ProjectNameSchema = z .string() .min(1, "Project name cannot be empty") @@ -132,6 +137,7 @@ export type CreateInput = { database?: Database; orm?: ORM; auth?: Auth; + payments?: Payments; frontend?: Frontend[]; addons?: Addons[]; examples?: Examples[]; @@ -175,6 +181,7 @@ export interface ProjectConfig { addons: Addons[]; examples: Examples[]; auth: Auth; + payments: Payments; git: boolean; packageManager: PackageManager; install: boolean; @@ -195,6 +202,7 @@ export interface BetterTStackConfig { addons: Addons[]; examples: Examples[]; auth: Auth; + payments: Payments; packageManager: PackageManager; dbSetup: DatabaseSetup; api: API; diff --git a/apps/cli/src/utils/addon-compatibility.ts b/apps/cli/src/utils/addon-compatibility.ts deleted file mode 100644 index a9c56d6e5..000000000 --- a/apps/cli/src/utils/addon-compatibility.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ADDON_COMPATIBILITY } from "../constants"; -import type { Addons, Frontend } from "../types"; - -export function validateAddonCompatibility( - addon: Addons, - frontend: Frontend[], -): { isCompatible: boolean; reason?: string } { - const compatibleFrontends = ADDON_COMPATIBILITY[addon]; - - if (compatibleFrontends.length === 0) { - return { isCompatible: true }; - } - - const hasCompatibleFrontend = frontend.some((f) => - (compatibleFrontends as readonly string[]).includes(f), - ); - - if (!hasCompatibleFrontend) { - const frontendList = compatibleFrontends.join(", "); - return { - isCompatible: false, - reason: `${addon} addon requires one of these frontends: ${frontendList}`, - }; - } - - return { isCompatible: true }; -} - -export function getCompatibleAddons( - allAddons: Addons[], - frontend: Frontend[], - existingAddons: Addons[] = [], -) { - return allAddons.filter((addon) => { - if (existingAddons.includes(addon)) return false; - - if (addon === "none") return false; - - const { isCompatible } = validateAddonCompatibility(addon, frontend); - return isCompatible; - }); -} diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index 3dba5f52a..97bab897b 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -18,6 +18,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { addons: projectConfig.addons, examples: projectConfig.examples, auth: projectConfig.auth, + payments: projectConfig.payments, packageManager: projectConfig.packageManager, dbSetup: projectConfig.dbSetup, api: projectConfig.api, diff --git a/apps/cli/src/utils/compatibility-rules.ts b/apps/cli/src/utils/compatibility-rules.ts index ba8fbcc4a..79eae4841 100644 --- a/apps/cli/src/utils/compatibility-rules.ts +++ b/apps/cli/src/utils/compatibility-rules.ts @@ -1,14 +1,16 @@ +import { ADDON_COMPATIBILITY } from "../constants"; import type { Addons, API, + Auth, Backend, CLIInput, Frontend, + Payments, ProjectConfig, ServerDeploy, WebDeploy, } from "../types"; -import { validateAddonCompatibility } from "./addon-compatibility"; import { WEB_FRAMEWORKS } from "./compatibility"; import { exitWithError } from "./errors"; @@ -191,15 +193,57 @@ export function validateServerDeployRequiresBackend( } } +export function validateAddonCompatibility( + addon: Addons, + frontend: Frontend[], + _auth?: Auth, +): { isCompatible: boolean; reason?: string } { + const compatibleFrontends = ADDON_COMPATIBILITY[addon]; + + if (compatibleFrontends.length > 0) { + const hasCompatibleFrontend = frontend.some((f) => + (compatibleFrontends as readonly string[]).includes(f), + ); + + if (!hasCompatibleFrontend) { + const frontendList = compatibleFrontends.join(", "); + return { + isCompatible: false, + reason: `${addon} addon requires one of these frontends: ${frontendList}`, + }; + } + } + + return { isCompatible: true }; +} + +export function getCompatibleAddons( + allAddons: Addons[], + frontend: Frontend[], + existingAddons: Addons[] = [], + auth?: Auth, +) { + return allAddons.filter((addon) => { + if (existingAddons.includes(addon)) return false; + + if (addon === "none") return false; + + const { isCompatible } = validateAddonCompatibility(addon, frontend, auth); + return isCompatible; + }); +} + export function validateAddonsAgainstFrontends( addons: Addons[] = [], frontends: Frontend[] = [], + auth?: Auth, ) { for (const addon of addons) { if (addon === "none") continue; const { isCompatible, reason } = validateAddonCompatibility( addon, frontends, + auth, ); if (!isCompatible) { exitWithError(`Incompatible addon/frontend combination: ${reason}`); @@ -207,6 +251,36 @@ export function validateAddonsAgainstFrontends( } } +export function validatePaymentsCompatibility( + payments: Payments | undefined, + auth: Auth | undefined, + backend: Backend | undefined, + frontends: Frontend[] = [], +) { + if (!payments || payments === "none") return; + + if (payments === "polar") { + if (!auth || auth === "none" || auth !== "better-auth") { + exitWithError( + "Polar payments requires Better Auth. Please use '--auth better-auth' or choose a different payments provider.", + ); + } + + if (backend === "convex") { + exitWithError( + "Polar payments is not compatible with Convex backend. Please use a different backend or choose a different payments provider.", + ); + } + + const { web } = splitFrontends(frontends); + if (web.length === 0 && frontends.length > 0) { + exitWithError( + "Polar payments requires a web frontend or no frontend. Please select a web frontend or choose a different payments provider.", + ); + } + } +} + export function validateExamplesCompatibility( examples: string[] | undefined, backend: ProjectConfig["backend"] | undefined, diff --git a/apps/cli/src/utils/config-processing.ts b/apps/cli/src/utils/config-processing.ts index 62f40ded2..db16b7a3e 100644 --- a/apps/cli/src/utils/config-processing.ts +++ b/apps/cli/src/utils/config-processing.ts @@ -8,6 +8,7 @@ import type { DatabaseSetup, ORM, PackageManager, + Payments, ProjectConfig, Runtime, ServerDeploy, @@ -56,6 +57,10 @@ export function processFlags(options: CLIInput, projectName?: string) { config.auth = options.auth as Auth; } + if (options.payments !== undefined) { + config.payments = options.payments as Payments; + } + if (options.git !== undefined) { config.git = options.git; } diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts index 9dd3da4ab..1e95e8233 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -11,6 +11,7 @@ import { validateAddonsAgainstFrontends, validateApiFrontendCompatibility, validateExamplesCompatibility, + validatePaymentsCompatibility, validateServerDeployRequiresBackend, validateWebDeployRequiresWebFrontend, validateWorkersCompatibility, @@ -417,7 +418,7 @@ export function validateFullConfig( } if (config.addons && config.addons.length > 0) { - validateAddonsAgainstFrontends(config.addons, config.frontend); + validateAddonsAgainstFrontends(config.addons, config.frontend, config.auth); config.addons = [...new Set(config.addons)]; } @@ -441,8 +442,19 @@ export function validateConfigForProgrammaticUse( validateApiFrontendCompatibility(config.api, config.frontend); + validatePaymentsCompatibility( + config.payments, + config.auth, + config.backend, + config.frontend, + ); + if (config.addons && config.addons.length > 0) { - validateAddonsAgainstFrontends(config.addons, config.frontend); + validateAddonsAgainstFrontends( + config.addons, + config.frontend, + config.auth, + ); } validateExamplesCompatibility( diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index 971c5d9a5..486396672 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -43,6 +43,10 @@ export function displayConfig(config: Partial) { configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`); } + if (config.payments !== undefined) { + configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`); + } + if (config.addons !== undefined) { const addons = Array.isArray(config.addons) ? config.addons diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index b01442bfd..f39810d0a 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -15,6 +15,7 @@ export function generateReproducibleCommand(config: ProjectConfig) { flags.push(`--orm ${config.orm}`); flags.push(`--api ${config.api}`); flags.push(`--auth ${config.auth}`); + flags.push(`--payments ${config.payments}`); if (config.addons && config.addons.length > 0) { flags.push(`--addons ${config.addons.join(" ")}`); diff --git a/apps/cli/templates/auth/better-auth/server/base/src/lib/auth.ts.hbs b/apps/cli/templates/auth/better-auth/server/base/src/lib/auth.ts.hbs index c84089f67..5763e84f1 100644 --- a/apps/cli/templates/auth/better-auth/server/base/src/lib/auth.ts.hbs +++ b/apps/cli/templates/auth/better-auth/server/base/src/lib/auth.ts.hbs @@ -4,6 +4,10 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} import { expo } from "@better-auth/expo"; {{/if}} +{{#if (eq payments "polar")}} +import { polar, checkout, portal } from "@polar-sh/better-auth"; +import { polarClient } from "./payments"; +{{/if}} import prisma from "../db"; export const auth = betterAuth({ @@ -28,9 +32,35 @@ export const auth = betterAuth({ secure: true, httpOnly: true, }, - } + }, + {{#if (eq payments "polar")}} + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + enableCustomerPortal: true, + use: [ + checkout({ + products: [ + { + productId: "your-product-id", + slug: "pro", + }, + ], + successUrl: process.env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true, + }), + portal(), + ], + }), + {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} + expo(), + {{/if}} + ], + {{else}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} - , plugins: [expo()] + plugins: [expo()], + {{/if}} {{/if}} }); {{/if}} @@ -42,6 +72,10 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} import { expo } from "@better-auth/expo"; {{/if}} +{{#if (eq payments "polar")}} +import { polar, checkout, portal } from "@polar-sh/better-auth"; +import { polarClient } from "./payments"; +{{/if}} import { db } from "../db"; import * as schema from "../db/schema/auth"; @@ -68,9 +102,35 @@ export const auth = betterAuth({ httpOnly: true, }, }, + {{#if (eq payments "polar")}} + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + enableCustomerPortal: true, + use: [ + checkout({ + products: [ + { + productId: "your-product-id", + slug: "pro", + }, + ], + successUrl: process.env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true, + }), + portal(), + ], + }), + {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} + expo(), + {{/if}} + ], + {{else}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} plugins: [expo()], {{/if}} + {{/if}} }); {{/if}} @@ -80,6 +140,10 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} import { expo } from "@better-auth/expo"; {{/if}} +{{#if (eq payments "polar")}} +import { polar, checkout, portal } from "@polar-sh/better-auth"; +import { polarClient } from "./payments"; +{{/if}} import { db } from "../db"; import * as schema from "../db/schema/auth"; import { env } from "cloudflare:workers"; @@ -109,9 +173,32 @@ export const auth = betterAuth({ httpOnly: true, }, }, + {{#if (eq payments "polar")}} + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + enableCustomerPortal: true, + use: [ + checkout({ + products: [ + { + productId: "your-product-id", + slug: "pro", + }, + ], + successUrl: env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true, + }), + portal(), + ], + }), + ], + {{else}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} plugins: [expo()], {{/if}} + {{/if}} }); {{/if}} {{/if}} @@ -122,6 +209,10 @@ import { mongodbAdapter } from "better-auth/adapters/mongodb"; {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} import { expo } from "@better-auth/expo"; {{/if}} +{{#if (eq payments "polar")}} +import { polar, checkout, portal } from "@polar-sh/better-auth"; +import { polarClient } from "./payments"; +{{/if}} import { client } from "../db"; export const auth = betterAuth({ @@ -141,9 +232,35 @@ export const auth = betterAuth({ secure: true, httpOnly: true, }, - } + }, + {{#if (eq payments "polar")}} + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + enableCustomerPortal: true, + use: [ + checkout({ + products: [ + { + productId: "your-product-id", + slug: "pro", + }, + ], + successUrl: process.env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true, + }), + portal(), + ], + }), + {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} + expo(), + {{/if}} + ], + {{else}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} - , plugins: [expo()] + plugins: [expo()], + {{/if}} {{/if}} }); {{/if}} @@ -153,6 +270,10 @@ import { betterAuth, type BetterAuthOptions } from "better-auth"; {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} import { expo } from "@better-auth/expo"; {{/if}} +{{#if (eq payments "polar")}} +import { polar, checkout, portal } from "@polar-sh/better-auth"; +import { polarClient } from "./payments"; +{{/if}} export const auth = betterAuth({ database: "", // Invalid configuration @@ -171,9 +292,35 @@ export const auth = betterAuth({ secure: true, httpOnly: true, }, - } + }, + {{#if (eq payments "polar")}} + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + enableCustomerPortal: true, + use: [ + checkout({ + products: [ + { + productId: "your-product-id", + slug: "pro", + }, + ], + successUrl: process.env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true, + }), + portal(), + ], + }), + {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} + expo(), + {{/if}} + ], + {{else}} {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} - , plugins: [expo()] + plugins: [expo()], + {{/if}} {{/if}} }); {{/if}} diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignInForm.vue b/apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignInForm.vue.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignInForm.vue rename to apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignInForm.vue.hbs diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignUpForm.vue b/apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignUpForm.vue.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignUpForm.vue rename to apps/cli/templates/auth/better-auth/web/nuxt/app/components/SignUpForm.vue.hbs diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/components/UserMenu.vue b/apps/cli/templates/auth/better-auth/web/nuxt/app/components/UserMenu.vue.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/nuxt/app/components/UserMenu.vue rename to apps/cli/templates/auth/better-auth/web/nuxt/app/components/UserMenu.vue.hbs diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/dashboard.vue.hbs b/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/dashboard.vue.hbs index b2d3dfd5f..0250ef240 100644 --- a/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/dashboard.vue.hbs +++ b/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/dashboard.vue.hbs @@ -11,9 +11,28 @@ definePageMeta({ const { $orpc } = useNuxtApp() const session = $authClient.useSession() +{{#if (eq payments "polar")}} +const customerState = ref(null) +{{/if}} {{#if (eq api "orpc")}} -const privateData = useQuery($orpc.privateData.queryOptions()) +const privateData = useQuery({ + ...$orpc.privateData.queryOptions(), + enabled: computed(() => !!session.value?.data?.user) +}) +{{/if}} + +{{#if (eq payments "polar")}} +onMounted(async () => { + if (session.value?.data) { + const { data } = await $authClient.customer.state() + customerState.value = data + } +}) + +const hasProSubscription = computed(() => + customerState.value?.activeSubscriptions?.length! > 0 +) {{/if}} @@ -27,7 +46,22 @@ const privateData = useQuery($orpc.privateData.queryOptions()) {{#if (eq api "orpc")}}
Loading private data...
Error loading private data: \{{ privateData.error.value?.message }}
-

Private Data: \{{ privateData.data.value.message }}

+

API: \{{ privateData.data.value.message }}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: \{{ hasProSubscription ? "Pro" : "Free" }}

+ + Manage Subscription + + + Upgrade to Pro + {{/if}} diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/login.vue b/apps/cli/templates/auth/better-auth/web/nuxt/app/pages/login.vue.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/nuxt/app/pages/login.vue rename to apps/cli/templates/auth/better-auth/web/nuxt/app/pages/login.vue.hbs diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts b/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts deleted file mode 100644 index 38bf8dbae..000000000 --- a/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createAuthClient } from "better-auth/vue"; - -export default defineNuxtPlugin(nuxtApp => { - const config = useRuntimeConfig() - const serverUrl = config.public.serverURL - - const authClient = createAuthClient({ - baseURL: serverUrl - }) - - return { - provide: { - authClient: authClient - } - } -}) diff --git a/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts.hbs b/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts.hbs new file mode 100644 index 000000000..1e6195e5a --- /dev/null +++ b/apps/cli/templates/auth/better-auth/web/nuxt/app/plugins/auth-client.ts.hbs @@ -0,0 +1,22 @@ +import { createAuthClient } from "better-auth/vue"; +{{#if (eq payments "polar")}} +import { polarClient } from "@polar-sh/better-auth"; +{{/if}} + +export default defineNuxtPlugin((nuxtApp) => { + const config = useRuntimeConfig(); + const serverUrl = config.public.serverURL; + + const authClient = createAuthClient({ + baseURL: serverUrl, + {{#if (eq payments "polar")}} + plugins: [polarClient()], + {{/if}} + }); + + return { + provide: { + authClient: authClient, + }, + }; +}); diff --git a/apps/cli/templates/auth/better-auth/web/react/base/src/lib/auth-client.ts.hbs b/apps/cli/templates/auth/better-auth/web/react/base/src/lib/auth-client.ts.hbs index 16fd3f148..8c10482ad 100644 --- a/apps/cli/templates/auth/better-auth/web/react/base/src/lib/auth-client.ts.hbs +++ b/apps/cli/templates/auth/better-auth/web/react/base/src/lib/auth-client.ts.hbs @@ -1,4 +1,7 @@ import { createAuthClient } from "better-auth/react"; +{{#if (eq payments "polar")}} +import { polarClient } from "@polar-sh/better-auth"; +{{/if}} export const authClient = createAuthClient({ baseURL: @@ -7,4 +10,7 @@ export const authClient = createAuthClient({ {{else}} import.meta.env.VITE_SERVER_URL, {{/if}} +{{#if (eq payments "polar")}} + plugins: [polarClient()] +{{/if}} }); diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/dashboard.tsx.hbs b/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/dashboard.tsx.hbs new file mode 100644 index 000000000..8b54fea07 --- /dev/null +++ b/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/dashboard.tsx.hbs @@ -0,0 +1,58 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; +{{#if (eq api "orpc")}} +import { useQuery } from "@tanstack/react-query"; +import { orpc } from "@/utils/orpc"; +{{/if}} +{{#if (eq api "trpc")}} +import { useQuery } from "@tanstack/react-query"; +import { trpc } from "@/utils/trpc"; +{{/if}} + +export default function Dashboard({ + {{#if (eq payments "polar")}} + customerState, + {{/if}} + session +}: { + {{#if (eq payments "polar")}} + customerState: ReturnType; + {{/if}} + session: typeof authClient.$Infer.Session; +}) { + {{#if (eq api "orpc")}} + const privateData = useQuery(orpc.privateData.queryOptions()); + {{/if}} + {{#if (eq api "trpc")}} + const privateData = useQuery(trpc.privateData.queryOptions()); + {{/if}} + + {{#if (eq payments "polar")}} + const hasProSubscription = customerState?.activeSubscriptions?.length! > 0; + console.log("Active subscriptions:", customerState?.activeSubscriptions); + {{/if}} + + return ( + <> + {{#if (eq api "orpc")}} +

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq api "trpc")}} +

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {hasProSubscription ? ( + + ) : ( + + )} + {{/if}} + + ); +} diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/page.tsx.hbs b/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/page.tsx.hbs index 4bb70fc6a..1e66f8e59 100644 --- a/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/page.tsx.hbs +++ b/apps/cli/templates/auth/better-auth/web/react/next/src/app/dashboard/page.tsx.hbs @@ -1,47 +1,37 @@ -"use client" import { authClient } from "@/lib/auth-client"; -{{#if (eq api "orpc")}} -import { useQuery } from "@tanstack/react-query"; -import { orpc } from "@/utils/orpc"; -{{/if}} -{{#if (eq api "trpc")}} -import { useQuery } from "@tanstack/react-query"; -import { trpc } from "@/utils/trpc"; -{{/if}} -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { redirect } from "next/navigation"; +import Dashboard from "./dashboard"; +import { headers } from "next/headers"; -export default function Dashboard() { - const router = useRouter(); - const { data: session, isPending } = authClient.useSession(); +export default async function DashboardPage() { + const session = await authClient.getSession({ + fetchOptions: { + headers: await headers() + } + }); - {{#if (eq api "orpc")}} - const privateData = useQuery(orpc.privateData.queryOptions()); - {{/if}} - {{#if (eq api "trpc")}} - const privateData = useQuery(trpc.privateData.queryOptions()); - {{/if}} + if (!session.data) { + redirect("/login"); + } - useEffect(() => { - if (!session && !isPending) { - router.push("/login"); - } - }, [session, isPending]); + {{#if (eq payments "polar")}} + const { data: customerState, error } = await authClient.customer.state({ + fetchOptions: { + headers: await headers() + } + }); + {{/if}} - if (isPending) { - return
Loading...
; - } - - return ( -
-

Dashboard

-

Welcome {session?.user.name}

- {{#if (eq api "orpc")}} -

privateData: {privateData.data?.message}

- {{/if}} - {{#if (eq api "trpc")}} -

privateData: {privateData.data?.message}

- {{/if}} -
- ); + return ( +
+

Dashboard

+

Welcome {session.data.user.name}

+ +
+ ); } diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/app/login/page.tsx b/apps/cli/templates/auth/better-auth/web/react/next/src/app/login/page.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/next/src/app/login/page.tsx rename to apps/cli/templates/auth/better-auth/web/react/next/src/app/login/page.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-in-form.tsx b/apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-in-form.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-in-form.tsx rename to apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-in-form.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-up-form.tsx b/apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-up-form.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-up-form.tsx rename to apps/cli/templates/auth/better-auth/web/react/next/src/components/sign-up-form.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/components/theme-provider.tsx b/apps/cli/templates/auth/better-auth/web/react/next/src/components/theme-provider.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/next/src/components/theme-provider.tsx rename to apps/cli/templates/auth/better-auth/web/react/next/src/components/theme-provider.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/next/src/components/user-menu.tsx b/apps/cli/templates/auth/better-auth/web/react/next/src/components/user-menu.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/next/src/components/user-menu.tsx rename to apps/cli/templates/auth/better-auth/web/react/next/src/components/user-menu.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-in-form.tsx b/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-in-form.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-in-form.tsx rename to apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-in-form.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-up-form.tsx b/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-up-form.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-up-form.tsx rename to apps/cli/templates/auth/better-auth/web/react/react-router/src/components/sign-up-form.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/user-menu.tsx b/apps/cli/templates/auth/better-auth/web/react/react-router/src/components/user-menu.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/react-router/src/components/user-menu.tsx rename to apps/cli/templates/auth/better-auth/web/react/react-router/src/components/user-menu.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/dashboard.tsx.hbs b/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/dashboard.tsx.hbs index 361bc7ca7..67eb65dae 100644 --- a/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/dashboard.tsx.hbs +++ b/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/dashboard.tsx.hbs @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; {{#if (eq api "orpc")}} import { orpc } from "@/utils/orpc"; @@ -8,12 +9,15 @@ import { trpc } from "@/utils/trpc"; {{#if ( or (eq api "orpc") (eq api "trpc"))}} import { useQuery } from "@tanstack/react-query"; {{/if}} -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router"; export default function Dashboard() { const { data: session, isPending } = authClient.useSession(); const navigate = useNavigate(); + {{#if (eq payments "polar")}} + const [customerState, setCustomerState] = useState(null); + {{/if}} {{#if (eq api "orpc")}} const privateData = useQuery(orpc.privateData.queryOptions()); @@ -26,18 +30,48 @@ export default function Dashboard() { if (!session && !isPending) { navigate("/login"); } - }, [session, isPending]); + }, [session, isPending, navigate]); + + {{#if (eq payments "polar")}} + useEffect(() => { + async function fetchCustomerState() { + if (session) { + const { data } = await authClient.customer.state(); + setCustomerState(data); + } + } + + fetchCustomerState(); + }, [session]); + {{/if}} if (isPending) { return
Loading...
; } + {{#if (eq payments "polar")}} + const hasProSubscription = customerState?.activeSubscriptions?.length! > 0; + console.log("Active subscriptions:", customerState?.activeSubscriptions); + {{/if}} + return (

Dashboard

Welcome {session?.user.name}

{{#if ( or (eq api "orpc") (eq api "trpc"))}} -

privateData: {privateData.data?.message}

+

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {hasProSubscription ? ( + + ) : ( + + )} {{/if}}
); diff --git a/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/login.tsx b/apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/login.tsx.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/login.tsx rename to apps/cli/templates/auth/better-auth/web/react/react-router/src/routes/login.tsx.hbs diff --git a/apps/cli/templates/auth/better-auth/web/react/tanstack-router/src/routes/dashboard.tsx.hbs b/apps/cli/templates/auth/better-auth/web/react/tanstack-router/src/routes/dashboard.tsx.hbs index 3ae44b6f8..92d953c4d 100644 --- a/apps/cli/templates/auth/better-auth/web/react/tanstack-router/src/routes/dashboard.tsx.hbs +++ b/apps/cli/templates/auth/better-auth/web/react/tanstack-router/src/routes/dashboard.tsx.hbs @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; {{#if (eq api "orpc")}} import { orpc } from "@/utils/orpc"; @@ -8,44 +9,61 @@ import { trpc } from "@/utils/trpc"; {{#if ( or (eq api "orpc") (eq api "trpc"))}} import { useQuery } from "@tanstack/react-query"; {{/if}} -import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/dashboard")({ - component: RouteComponent, + component: RouteComponent, + beforeLoad: async () => { + const session = await authClient.getSession(); + if (!session.data) { + redirect({ + to: "/login", + throw: true + }); + } + {{#if (eq payments "polar")}} + const {data: customerState} = await authClient.customer.state() + return { session, customerState }; + {{else}} + return { session }; + {{/if}} + } }); function RouteComponent() { - const { data: session, isPending } = authClient.useSession(); + const { session{{#if (eq payments "polar")}}, customerState{{/if}} } = Route.useRouteContext(); - const navigate = Route.useNavigate(); + {{#if (eq api "orpc")}} + const privateData = useQuery(orpc.privateData.queryOptions()); + {{/if}} + {{#if (eq api "trpc")}} + const privateData = useQuery(trpc.privateData.queryOptions()); + {{/if}} - {{#if (eq api "orpc")}} - const privateData = useQuery(orpc.privateData.queryOptions()); - {{/if}} - {{#if (eq api "trpc")}} - const privateData = useQuery(trpc.privateData.queryOptions()); - {{/if}} + {{#if (eq payments "polar")}} + const hasProSubscription = customerState?.activeSubscriptions?.length! > 0 + console.log("Active subscriptions:", customerState?.activeSubscriptions) + {{/if}} - useEffect(() => { - if (!session && !isPending) { - navigate({ - to: "/login", - }); - } - }, [session, isPending]); - - if (isPending) { - return
Loading...
; - } - - return ( -
-

Dashboard

-

Welcome {session?.user.name}

- {{#if ( or (eq api "orpc") (eq api "trpc"))}} -

privateData: {privateData.data?.message}

- {{/if}} -
- ); + return ( +
+

Dashboard

+

Welcome {session.data?.user.name}

+ {{#if ( or (eq api "orpc") (eq api "trpc"))}} +

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {hasProSubscription ? ( + + ) : ( + + )} + {{/if}} +
+ ); } diff --git a/apps/cli/templates/auth/better-auth/web/react/tanstack-start/src/routes/dashboard.tsx.hbs b/apps/cli/templates/auth/better-auth/web/react/tanstack-start/src/routes/dashboard.tsx.hbs index 18e95850d..79584a431 100644 --- a/apps/cli/templates/auth/better-auth/web/react/tanstack-start/src/routes/dashboard.tsx.hbs +++ b/apps/cli/templates/auth/better-auth/web/react/tanstack-start/src/routes/dashboard.tsx.hbs @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; {{#if (eq api "trpc")}} import { useTRPC } from "@/utils/trpc"; @@ -7,48 +8,62 @@ import { useQuery } from "@tanstack/react-query"; import { orpc } from "@/utils/orpc"; import { useQuery } from "@tanstack/react-query"; {{/if}} -import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/dashboard")({ - component: RouteComponent, + component: RouteComponent, + beforeLoad: async () => { + const session = await authClient.getSession(); + if (!session.data) { + redirect({ + to: "/login", + throw: true + }); + } + {{#if (eq payments "polar")}} + const {data: customerState} = await authClient.customer.state() + return { session, customerState }; + {{else}} + return { session }; + {{/if}} + } }); function RouteComponent() { - const navigate = Route.useNavigate(); - {{#if (eq api "trpc")}} - const trpc = useTRPC(); - {{/if}} - {{#if (eq api "orpc")}} - {{/if}} - const { data: session, isPending } = authClient.useSession(); + const { session{{#if (eq payments "polar")}}, customerState{{/if}} } = Route.useRouteContext(); - {{#if (eq api "trpc")}} - const privateData = useQuery(trpc.privateData.queryOptions()); - {{/if}} - {{#if (eq api "orpc")}} - const privateData = useQuery(orpc.privateData.queryOptions()); - {{/if}} + {{#if (eq api "trpc")}} + const trpc = useTRPC(); + const privateData = useQuery(trpc.privateData.queryOptions()); + {{/if}} + {{#if (eq api "orpc")}} + const privateData = useQuery(orpc.privateData.queryOptions()); + {{/if}} - useEffect(() => { - if (!session && !isPending) { - navigate({ - to: "/login", - }); - } - }, [session, isPending]); + {{#if (eq payments "polar")}} + const hasProSubscription = customerState?.activeSubscriptions?.length! > 0 + console.log("Active subscriptions:", customerState?.activeSubscriptions) + {{/if}} - if (isPending) { - return
Loading...
; - } - - return ( -
-

Dashboard

-

Welcome {session?.user.name}

- {{#if ( or (eq api "orpc") (eq api "trpc"))}} -

privateData: {privateData.data?.message}

- {{/if}} -
- ); + return ( +
+

Dashboard

+

Welcome {session.data?.user.name}

+ {{#if ( or (eq api "orpc") (eq api "trpc"))}} +

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {hasProSubscription ? ( + + ) : ( + + )} + {{/if}} +
+ ); } diff --git a/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts b/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts deleted file mode 100644 index 73bac9bd1..000000000 --- a/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthClient } from "better-auth/solid"; - -export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_SERVER_URL, -}); diff --git a/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts.hbs b/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts.hbs new file mode 100644 index 000000000..43048f5e1 --- /dev/null +++ b/apps/cli/templates/auth/better-auth/web/solid/src/lib/auth-client.ts.hbs @@ -0,0 +1,11 @@ +import { createAuthClient } from "better-auth/solid"; +{{#if (eq payments "polar")}} +import { polarClient } from "@polar-sh/better-auth"; +{{/if}} + +export const authClient = createAuthClient({ + baseURL: import.meta.env.VITE_SERVER_URL, +{{#if (eq payments "polar")}} + plugins: [polarClient()] +{{/if}} +}); diff --git a/apps/cli/templates/auth/better-auth/web/solid/src/routes/dashboard.tsx.hbs b/apps/cli/templates/auth/better-auth/web/solid/src/routes/dashboard.tsx.hbs index 235d08e7d..1798659cd 100644 --- a/apps/cli/templates/auth/better-auth/web/solid/src/routes/dashboard.tsx.hbs +++ b/apps/cli/templates/auth/better-auth/web/solid/src/routes/dashboard.tsx.hbs @@ -3,42 +3,65 @@ import { authClient } from "@/lib/auth-client"; import { orpc } from "@/utils/orpc"; import { useQuery } from "@tanstack/solid-query"; {{/if}} -import { createFileRoute } from "@tanstack/solid-router"; -import { createEffect, Show } from "solid-js"; +import { createFileRoute, redirect } from "@tanstack/solid-router"; export const Route = createFileRoute("/dashboard")({ - component: RouteComponent, + component: RouteComponent, + beforeLoad: async () => { + const session = await authClient.getSession(); + if (!session.data) { + redirect({ + to: "/login", + throw: true, + }); + } + {{#if (eq payments "polar")}} + const { data: customerState } = await authClient.customer.state(); + return { session, customerState }; + {{else}} + return { session }; + {{/if}} + }, }); function RouteComponent() { - const session = authClient.useSession(); - const navigate = Route.useNavigate(); + const context = Route.useRouteContext(); -{{#if (eq api "orpc")}} - const privateData = useQuery(() => orpc.privateData.queryOptions()); -{{/if}} + const session = context().session; + {{#if (eq payments "polar")}} + const customerState = context().customerState; + {{/if}} - createEffect(() => { - if (!session().data && !session().isPending) { - navigate({ - to: "/login", - }); - } - }); + {{#if (eq api "orpc")}} + const privateData = useQuery(() => orpc.privateData.queryOptions()); + {{/if}} - return ( -
- -
Loading...
-
+ {{#if (eq payments "polar")}} + const hasProSubscription = () => + customerState?.activeSubscriptions?.length! > 0; + {{/if}} - -

Dashboard

-

Welcome {session().data?.user.name}

- {{#if (eq api "orpc")}} -

privateData: {privateData.data?.message}

- {{/if}} -
-
- ); + return ( +
+

Dashboard

+

Welcome {session.data?.user.name}

+ {{#if (eq api "orpc")}} +

API: {privateData.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {hasProSubscription() ? "Pro" : "Free"}

+ {hasProSubscription() ? ( + + ) : ( + + )} + {{/if}} +
+ ); } diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/components/SignInForm.svelte b/apps/cli/templates/auth/better-auth/web/svelte/src/components/SignInForm.svelte.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/svelte/src/components/SignInForm.svelte rename to apps/cli/templates/auth/better-auth/web/svelte/src/components/SignInForm.svelte.hbs diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/components/SignUpForm.svelte b/apps/cli/templates/auth/better-auth/web/svelte/src/components/SignUpForm.svelte.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/svelte/src/components/SignUpForm.svelte rename to apps/cli/templates/auth/better-auth/web/svelte/src/components/SignUpForm.svelte.hbs diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/components/UserMenu.svelte b/apps/cli/templates/auth/better-auth/web/svelte/src/components/UserMenu.svelte.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/svelte/src/components/UserMenu.svelte rename to apps/cli/templates/auth/better-auth/web/svelte/src/components/UserMenu.svelte.hbs diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts b/apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts.hbs similarity index 55% rename from apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts rename to apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts.hbs index 7955006d5..e80f21a73 100644 --- a/apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts +++ b/apps/cli/templates/auth/better-auth/web/svelte/src/lib/auth-client.ts.hbs @@ -1,6 +1,12 @@ import { PUBLIC_SERVER_URL } from "$env/static/public"; import { createAuthClient } from "better-auth/svelte"; +{{#if (eq payments "polar")}} +import { polarClient } from "@polar-sh/better-auth"; +{{/if}} export const authClient = createAuthClient({ baseURL: PUBLIC_SERVER_URL, +{{#if (eq payments "polar")}} + plugins: [polarClient()] +{{/if}} }); diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/routes/dashboard/+page.svelte.hbs b/apps/cli/templates/auth/better-auth/web/svelte/src/routes/dashboard/+page.svelte.hbs index d6f0294c8..74dbf0a50 100644 --- a/apps/cli/templates/auth/better-auth/web/svelte/src/routes/dashboard/+page.svelte.hbs +++ b/apps/cli/templates/auth/better-auth/web/svelte/src/routes/dashboard/+page.svelte.hbs @@ -6,7 +6,9 @@ import { orpc } from '$lib/orpc'; import { createQuery } from '@tanstack/svelte-query'; {{/if}} - import { get } from 'svelte/store'; + {{#if (eq payments "polar")}} + let customerState: any = null; + {{/if}} const sessionQuery = authClient.useSession(); @@ -15,10 +17,17 @@ {{/if}} onMount(() => { - const { data: session, isPending } = get(sessionQuery); + const { data: session, isPending } = $sessionQuery; if (!session && !isPending) { goto('/login'); } + {{#if (eq payments "polar")}} + if (session) { + authClient.customer.state().then(({ data }) => { + customerState = data; + }); + } + {{/if}} }); @@ -30,7 +39,19 @@

Dashboard

Welcome {$sessionQuery.data.user.name}

{{#if (eq api "orpc")}} -

privateData: {$privateDataQuery.data?.message}

+

API: {$privateDataQuery.data?.message}

+ {{/if}} + {{#if (eq payments "polar")}} +

Plan: {customerState?.activeSubscriptions?.length > 0 ? "Pro" : "Free"}

+ {#if customerState?.activeSubscriptions?.length > 0} + + {:else} + + {/if} {{/if}} {/if} diff --git a/apps/cli/templates/auth/better-auth/web/svelte/src/routes/login/+page.svelte b/apps/cli/templates/auth/better-auth/web/svelte/src/routes/login/+page.svelte.hbs similarity index 100% rename from apps/cli/templates/auth/better-auth/web/svelte/src/routes/login/+page.svelte rename to apps/cli/templates/auth/better-auth/web/svelte/src/routes/login/+page.svelte.hbs diff --git a/apps/cli/templates/backend/server/server-base/package.json.hbs b/apps/cli/templates/backend/server/server-base/package.json.hbs index 6ce6cc7a3..728a483c4 100644 --- a/apps/cli/templates/backend/server/server-base/package.json.hbs +++ b/apps/cli/templates/backend/server/server-base/package.json.hbs @@ -17,7 +17,7 @@ ], {{/if}} "devDependencies": { - "tsdown": "^0.14.1", + "tsdown": "^0.15.1", "typescript": "^5.8.2" } } diff --git a/apps/cli/templates/extras/bunfig.toml.hbs b/apps/cli/templates/extras/bunfig.toml.hbs index 641e878fd..aebcf6a97 100644 --- a/apps/cli/templates/extras/bunfig.toml.hbs +++ b/apps/cli/templates/extras/bunfig.toml.hbs @@ -1,2 +1,6 @@ [install] +{{#if (includes frontend "nuxt")}} +# linker = "isolated" # Commented out for Nuxt compatibility +{{else}} linker = "isolated" +{{/if}} diff --git a/apps/cli/templates/frontend/react/tanstack-start/src/routes/__root.tsx.hbs b/apps/cli/templates/frontend/react/tanstack-start/src/routes/__root.tsx.hbs index bad8f4062..2b4c8cc87 100644 --- a/apps/cli/templates/frontend/react/tanstack-start/src/routes/__root.tsx.hbs +++ b/apps/cli/templates/frontend/react/tanstack-start/src/routes/__root.tsx.hbs @@ -17,6 +17,10 @@ import appCss from "../index.css?url"; import type { QueryClient } from "@tanstack/react-query"; import type { ConvexQueryClient } from "@convex-dev/react-query"; import type { ConvexReactClient } from "convex/react"; +{{else}} +{{#if (or (eq api "trpc") (eq api "orpc"))}} +import type { QueryClient } from "@tanstack/react-query"; +{{/if}} {{/if}} import Loader from "@/components/loader"; diff --git a/apps/cli/templates/frontend/solid/package.json.hbs b/apps/cli/templates/frontend/solid/package.json.hbs index 300aeed67..da029cd18 100644 --- a/apps/cli/templates/frontend/solid/package.json.hbs +++ b/apps/cli/templates/frontend/solid/package.json.hbs @@ -9,18 +9,18 @@ "test": "vitest run" }, "dependencies": { - "@tailwindcss/vite": "^4.0.6", - "@tanstack/router-plugin": "^1.109.2", - "@tanstack/solid-form": "^1.9.0", - "@tanstack/solid-router": "^1.110.0", - "lucide-solid": "^0.507.0", - "solid-js": "^1.9.4", - "tailwindcss": "^4.0.6", + "@tailwindcss/vite": "^4.1.13", + "@tanstack/router-plugin": "^1.131.44", + "@tanstack/solid-form": "^1.20.0", + "@tanstack/solid-router": "^1.131.44", + "lucide-solid": "^0.544.0", + "solid-js": "^1.9.9", + "tailwindcss": "^4.1.13", "zod": "^4.0.2" }, "devDependencies": { - "typescript": "^5.7.2", - "vite": "^7.0.2", - "vite-plugin-solid": "^2.11.2" + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vite-plugin-solid": "^2.11.8" } } diff --git a/apps/cli/templates/payments/polar/server/base/src/lib/payments.ts.hbs b/apps/cli/templates/payments/polar/server/base/src/lib/payments.ts.hbs new file mode 100644 index 000000000..6cbc4f609 --- /dev/null +++ b/apps/cli/templates/payments/polar/server/base/src/lib/payments.ts.hbs @@ -0,0 +1,6 @@ +import { Polar } from "@polar-sh/sdk"; + +export const polarClient = new Polar({ + accessToken: process.env.POLAR_ACCESS_TOKEN, + server: "sandbox", +}); diff --git a/apps/cli/templates/payments/polar/web/nuxt/app/pages/success.vue.hbs b/apps/cli/templates/payments/polar/web/nuxt/app/pages/success.vue.hbs new file mode 100644 index 000000000..ecdf87dc1 --- /dev/null +++ b/apps/cli/templates/payments/polar/web/nuxt/app/pages/success.vue.hbs @@ -0,0 +1,11 @@ + + + diff --git a/apps/cli/templates/payments/polar/web/react/next/src/app/success/page.tsx.hbs b/apps/cli/templates/payments/polar/web/react/next/src/app/success/page.tsx.hbs new file mode 100644 index 000000000..625ea2b9a --- /dev/null +++ b/apps/cli/templates/payments/polar/web/react/next/src/app/success/page.tsx.hbs @@ -0,0 +1,15 @@ +export default async function SuccessPage({ + searchParams, +}: { + searchParams: Promise<{ checkout_id: string }> +}) { + const params = await searchParams; + const checkout_id = params.checkout_id; + + return ( +
+

Payment Successful!

+ {checkout_id &&

Checkout ID: {checkout_id}

} +
+ ); +} diff --git a/apps/cli/templates/payments/polar/web/react/react-router/src/routes/success.tsx.hbs b/apps/cli/templates/payments/polar/web/react/react-router/src/routes/success.tsx.hbs new file mode 100644 index 000000000..f36653c58 --- /dev/null +++ b/apps/cli/templates/payments/polar/web/react/react-router/src/routes/success.tsx.hbs @@ -0,0 +1,13 @@ +import { useSearchParams } from "react-router"; + +export default function SuccessPage() { + const [searchParams] = useSearchParams(); + const checkout_id = searchParams.get("checkout_id"); + + return ( +
+

Payment Successful!

+ {checkout_id &&

Checkout ID: {checkout_id}

} +
+ ); +} diff --git a/apps/cli/templates/payments/polar/web/react/tanstack-router/src/routes/success.tsx.hbs b/apps/cli/templates/payments/polar/web/react/tanstack-router/src/routes/success.tsx.hbs new file mode 100644 index 000000000..faa93dc7d --- /dev/null +++ b/apps/cli/templates/payments/polar/web/react/tanstack-router/src/routes/success.tsx.hbs @@ -0,0 +1,19 @@ +import { createFileRoute, useSearch } from "@tanstack/react-router"; + +export const Route = createFileRoute("/success")({ + component: SuccessPage, + validateSearch: (search) => ({ + checkout_id: search.checkout_id as string, + }), +}); + +function SuccessPage() { + const { checkout_id } = useSearch({ from: "/success" }); + + return ( +
+

Payment Successful!

+ {checkout_id &&

Checkout ID: {checkout_id}

} +
+ ); +} diff --git a/apps/cli/templates/payments/polar/web/react/tanstack-start/src/routes/success.tsx.hbs b/apps/cli/templates/payments/polar/web/react/tanstack-start/src/routes/success.tsx.hbs new file mode 100644 index 000000000..faa93dc7d --- /dev/null +++ b/apps/cli/templates/payments/polar/web/react/tanstack-start/src/routes/success.tsx.hbs @@ -0,0 +1,19 @@ +import { createFileRoute, useSearch } from "@tanstack/react-router"; + +export const Route = createFileRoute("/success")({ + component: SuccessPage, + validateSearch: (search) => ({ + checkout_id: search.checkout_id as string, + }), +}); + +function SuccessPage() { + const { checkout_id } = useSearch({ from: "/success" }); + + return ( +
+

Payment Successful!

+ {checkout_id &&

Checkout ID: {checkout_id}

} +
+ ); +} diff --git a/apps/cli/templates/payments/polar/web/solid/src/routes/success.tsx.hbs b/apps/cli/templates/payments/polar/web/solid/src/routes/success.tsx.hbs new file mode 100644 index 000000000..d64d4b4ed --- /dev/null +++ b/apps/cli/templates/payments/polar/web/solid/src/routes/success.tsx.hbs @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/solid-router"; +import { Show } from "solid-js"; + +export const Route = createFileRoute("/success")({ + component: SuccessPage, + validateSearch: (search) => ({ + checkout_id: search.checkout_id as string, + }), +}); + +function SuccessPage() { + const searchParams = Route.useSearch(); + const checkout_id = searchParams().checkout_id; + + return ( +
+

Payment Successful!

+ +

Checkout ID: {checkout_id}

+
+
+ ); +} diff --git a/apps/cli/templates/payments/polar/web/svelte/src/routes/success/+page.svelte.hbs b/apps/cli/templates/payments/polar/web/svelte/src/routes/success/+page.svelte.hbs new file mode 100644 index 000000000..6f2de3425 --- /dev/null +++ b/apps/cli/templates/payments/polar/web/svelte/src/routes/success/+page.svelte.hbs @@ -0,0 +1,12 @@ + + +
+

Payment Successful!

+ {#if checkout_id} +

Checkout ID: {checkout_id}

+ {/if} +
diff --git a/apps/cli/test/database-orm.test.ts b/apps/cli/test/database-orm.test.ts index 44dbcd60b..23a2d761e 100644 --- a/apps/cli/test/database-orm.test.ts +++ b/apps/cli/test/database-orm.test.ts @@ -60,54 +60,54 @@ describe("Database and ORM Combinations", () => { orm: ORM; error: string; }> = [ - // MongoDB with Drizzle (not supported) - { - database: "mongodb" as Database, - orm: "drizzle" as ORM, - error: "Drizzle ORM does not support MongoDB", - }, - - // Mongoose with non-MongoDB - { - database: "sqlite" as Database, - orm: "mongoose" as ORM, - error: "Mongoose ORM requires MongoDB database", - }, - { - database: "postgres" as Database, - orm: "mongoose" as ORM, - error: "Mongoose ORM requires MongoDB database", - }, - { - database: "mysql" as Database, - orm: "mongoose" as ORM, - error: "Mongoose ORM requires MongoDB database", - }, - - // Database without ORM - { - database: "sqlite" as Database, - orm: "none" as ORM, - error: "Database selection requires an ORM", - }, - { - database: "postgres" as Database, - orm: "none" as ORM, - error: "Database selection requires an ORM", - }, - - // ORM without database - { - database: "none" as Database, - orm: "drizzle" as ORM, - error: "ORM selection requires a database", - }, - { - database: "none" as Database, - orm: "prisma" as ORM, - error: "ORM selection requires a database", - }, - ]; + // MongoDB with Drizzle (not supported) + { + database: "mongodb" as Database, + orm: "drizzle" as ORM, + error: "Drizzle ORM does not support MongoDB", + }, + + // Mongoose with non-MongoDB + { + database: "sqlite" as Database, + orm: "mongoose" as ORM, + error: "Mongoose ORM requires MongoDB database", + }, + { + database: "postgres" as Database, + orm: "mongoose" as ORM, + error: "Mongoose ORM requires MongoDB database", + }, + { + database: "mysql" as Database, + orm: "mongoose" as ORM, + error: "Mongoose ORM requires MongoDB database", + }, + + // Database without ORM + { + database: "sqlite" as Database, + orm: "none" as ORM, + error: "Database selection requires an ORM", + }, + { + database: "postgres" as Database, + orm: "none" as ORM, + error: "Database selection requires an ORM", + }, + + // ORM without database + { + database: "none" as Database, + orm: "drizzle" as ORM, + error: "ORM selection requires a database", + }, + { + database: "none" as Database, + orm: "prisma" as ORM, + error: "ORM selection requires a database", + }, + ]; for (const { database, orm, error } of invalidCombinations) { it(`should fail with ${database} + ${orm}`, async () => { diff --git a/apps/cli/test/deployment.test.ts b/apps/cli/test/deployment.test.ts index 93cea9382..bad7d46ba 100644 --- a/apps/cli/test/deployment.test.ts +++ b/apps/cli/test/deployment.test.ts @@ -456,16 +456,16 @@ describe("Deployment Configurations", () => { webDeploy: TestConfig["webDeploy"]; serverDeploy: TestConfig["serverDeploy"]; }> = [ - { webDeploy: "wrangler", serverDeploy: "wrangler" }, - { webDeploy: "wrangler", serverDeploy: "alchemy" }, - { webDeploy: "alchemy", serverDeploy: "wrangler" }, - { webDeploy: "alchemy", serverDeploy: "alchemy" }, - { webDeploy: "wrangler", serverDeploy: "none" }, - { webDeploy: "alchemy", serverDeploy: "none" }, - { webDeploy: "none", serverDeploy: "wrangler" }, - { webDeploy: "none", serverDeploy: "alchemy" }, - { webDeploy: "none", serverDeploy: "none" }, - ]; + { webDeploy: "wrangler", serverDeploy: "wrangler" }, + { webDeploy: "wrangler", serverDeploy: "alchemy" }, + { webDeploy: "alchemy", serverDeploy: "wrangler" }, + { webDeploy: "alchemy", serverDeploy: "alchemy" }, + { webDeploy: "wrangler", serverDeploy: "none" }, + { webDeploy: "alchemy", serverDeploy: "none" }, + { webDeploy: "none", serverDeploy: "wrangler" }, + { webDeploy: "none", serverDeploy: "alchemy" }, + { webDeploy: "none", serverDeploy: "none" }, + ]; for (const { webDeploy, serverDeploy } of deployOptions) { it(`should work with webDeploy: ${webDeploy}, serverDeploy: ${serverDeploy}`, async () => { diff --git a/apps/cli/test/test-utils.ts b/apps/cli/test/test-utils.ts index 65978501c..fec4344e3 100644 --- a/apps/cli/test/test-utils.ts +++ b/apps/cli/test/test-utils.ts @@ -98,19 +98,19 @@ export async function runTRPCTest(config: TestConfig): Promise { const coreStackDefaults = willUseYesFlag ? {} : { - frontend: ["tanstack-router"] as Frontend[], - backend: "hono" as Backend, - runtime: "bun" as Runtime, - api: "trpc" as API, - database: "sqlite" as Database, - orm: "drizzle" as ORM, - auth: "none" as Auth, - addons: ["none"] as Addons[], - examples: ["none"] as Examples[], - dbSetup: "none" as DatabaseSetup, - webDeploy: "none" as WebDeploy, - serverDeploy: "none" as ServerDeploy, - }; + frontend: ["tanstack-router"] as Frontend[], + backend: "hono" as Backend, + runtime: "bun" as Runtime, + api: "trpc" as API, + database: "sqlite" as Database, + orm: "drizzle" as ORM, + auth: "none" as Auth, + addons: ["none"] as Addons[], + examples: ["none"] as Examples[], + dbSetup: "none" as DatabaseSetup, + webDeploy: "none" as WebDeploy, + serverDeploy: "none" as ServerDeploy, + }; // Build options object - let the CLI handle all validation const options: CreateInput = { diff --git a/apps/web/src/app/(home)/new/_components/tech-icon.tsx b/apps/web/src/app/(home)/new/_components/tech-icon.tsx index 1b9d099b7..0a3b11dbd 100644 --- a/apps/web/src/app/(home)/new/_components/tech-icon.tsx +++ b/apps/web/src/app/(home)/new/_components/tech-icon.tsx @@ -30,7 +30,8 @@ export function TechIcon({ icon.includes("prisma") || icon.includes("express") || icon.includes("clerk") || - icon.includes("planetscale")) + icon.includes("planetscale") || + icon.includes("polar")) ) { iconSrc = icon.replace(".svg", "-light.svg"); } diff --git a/apps/web/src/app/(home)/new/_components/utils.ts b/apps/web/src/app/(home)/new/_components/utils.ts index 7f970cca8..13bde1d30 100644 --- a/apps/web/src/app/(home)/new/_components/utils.ts +++ b/apps/web/src/app/(home)/new/_components/utils.ts @@ -818,6 +818,64 @@ export const analyzeStackCompatibility = ( }); } + if (nextStack.payments === "polar") { + if (nextStack.auth !== "better-auth") { + notes.payments.notes.push( + "Polar payments requires Better Auth. Payments will be set to 'None'.", + ); + notes.auth.notes.push( + "Polar payments requires Better Auth. Payments will be disabled.", + ); + notes.payments.hasIssue = true; + notes.auth.hasIssue = true; + nextStack.payments = "none"; + changed = true; + changes.push({ + category: "payments", + message: "Payments set to 'None' (Polar requires Better Auth)", + }); + } + + if (nextStack.backend === "convex") { + notes.payments.notes.push( + "Polar payments is not compatible with Convex backend. Payments will be set to 'None'.", + ); + notes.backend.notes.push( + "Polar payments is not compatible with Convex backend. Payments will be disabled.", + ); + notes.payments.hasIssue = true; + notes.backend.hasIssue = true; + nextStack.payments = "none"; + changed = true; + changes.push({ + category: "payments", + message: + "Payments set to 'None' (Polar not compatible with Convex backend)", + }); + } + + const hasWebFrontend = nextStack.webFrontend.some((f) => f !== "none"); + if ( + !hasWebFrontend && + nextStack.nativeFrontend.some((f) => f !== "none") + ) { + notes.payments.notes.push( + "Polar payments requires a web frontend. Payments will be set to 'None'.", + ); + notes.webFrontend.notes.push( + "Polar payments requires a web frontend. Payments will be disabled.", + ); + notes.payments.hasIssue = true; + notes.webFrontend.hasIssue = true; + nextStack.payments = "none"; + changed = true; + changes.push({ + category: "payments", + message: "Payments set to 'None' (Polar requires web frontend)", + }); + } + } + const incompatibleAddons: string[] = []; const isPWACompat = hasPWACompatibleFrontend(nextStack.webFrontend); const isTauriCompat = hasTauriCompatibleFrontend(nextStack.webFrontend); @@ -1536,6 +1594,22 @@ export const getDisabledReason = ( } } + if (category === "payments" && optionId === "polar") { + if (finalStack.auth !== "better-auth") { + return "Polar payments requires Better Auth. Select Better Auth first."; + } + if (finalStack.backend === "convex") { + return "Polar payments is not compatible with Convex backend. Try Hono, Express, Fastify, or Elysia."; + } + const hasWebFrontend = finalStack.webFrontend.some((f) => f !== "none"); + if ( + !hasWebFrontend && + finalStack.nativeFrontend.some((f) => f !== "none") + ) { + return "Polar payments requires a web frontend. Select a web frontend first."; + } + } + if (category === "dbSetup" && optionId === "planetscale") { if (finalStack.database !== "postgres" && finalStack.database !== "mysql") { return "PlanetScale requires PostgreSQL or MySQL database. Select PostgreSQL or MySQL first."; diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 5f0ec837c..7fcbcc094 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -427,6 +427,24 @@ export const TECH_OPTIONS: Record< color: "from-red-400 to-red-600", }, ], + payments: [ + { + id: "polar", + name: "Polar", + description: "Turn your software into a business. 6 lines of code.", + icon: `${ICON_BASE_URL}/polar.svg`, + color: "from-purple-400 to-purple-600", + default: false, + }, + { + id: "none", + name: "No Payments", + description: "Skip payments integration", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], packageManager: [ { id: "npm", @@ -604,6 +622,7 @@ export const PRESET_TEMPLATES = [ orm: "drizzle", dbSetup: "none", auth: "better-auth", + payments: "none", packageManager: "bun", addons: ["turborepo"], examples: [], @@ -629,6 +648,7 @@ export const PRESET_TEMPLATES = [ orm: "none", dbSetup: "none", auth: "none", + payments: "none", packageManager: "bun", addons: ["turborepo"], examples: ["todo"], @@ -654,6 +674,7 @@ export const PRESET_TEMPLATES = [ orm: "drizzle", dbSetup: "none", auth: "better-auth", + payments: "none", packageManager: "bun", addons: ["turborepo"], examples: [], @@ -679,6 +700,7 @@ export const PRESET_TEMPLATES = [ orm: "drizzle", dbSetup: "none", auth: "better-auth", + payments: "none", packageManager: "bun", addons: ["turborepo"], examples: [], @@ -704,6 +726,7 @@ export const PRESET_TEMPLATES = [ orm: "drizzle", dbSetup: "turso", auth: "better-auth", + payments: "polar", packageManager: "bun", addons: ["pwa", "biome", "husky", "tauri", "starlight", "turborepo"], examples: ["todo", "ai"], @@ -727,6 +750,7 @@ export type StackState = { orm: string; dbSetup: string; auth: string; + payments: string; packageManager: string; addons: string[]; examples: string[]; @@ -748,6 +772,7 @@ export const DEFAULT_STACK: StackState = { orm: "drizzle", dbSetup: "none", auth: "better-auth", + payments: "none", packageManager: "bun", addons: ["turborepo"], examples: [], diff --git a/apps/web/src/lib/stack-url-keys.ts b/apps/web/src/lib/stack-url-keys.ts index 7f41b657a..409620538 100644 --- a/apps/web/src/lib/stack-url-keys.ts +++ b/apps/web/src/lib/stack-url-keys.ts @@ -12,6 +12,7 @@ export const stackUrlKeys: UrlKeys> = { orm: "orm", dbSetup: "dbs", auth: "au", + payments: "pay", packageManager: "pm", addons: "add", examples: "ex", diff --git a/apps/web/src/lib/stack-url-state.client.ts b/apps/web/src/lib/stack-url-state.client.ts index c11169430..67f04e1de 100644 --- a/apps/web/src/lib/stack-url-state.client.ts +++ b/apps/web/src/lib/stack-url-state.client.ts @@ -43,6 +43,9 @@ export const stackParsers = { auth: parseAsStringEnum(getValidIds("auth")).withDefault( DEFAULT_STACK.auth, ), + payments: parseAsStringEnum( + getValidIds("payments"), + ).withDefault(DEFAULT_STACK.payments), packageManager: parseAsStringEnum( getValidIds("packageManager"), ).withDefault(DEFAULT_STACK.packageManager), diff --git a/apps/web/src/lib/stack-url-state.ts b/apps/web/src/lib/stack-url-state.ts index fc69bd275..bd41bc8fe 100644 --- a/apps/web/src/lib/stack-url-state.ts +++ b/apps/web/src/lib/stack-url-state.ts @@ -44,6 +44,9 @@ const serverStackParsers = { auth: parseAsStringEnumServer( getValidIds("auth"), ).withDefault(DEFAULT_STACK.auth), + payments: parseAsStringEnumServer( + getValidIds("payments"), + ).withDefault(DEFAULT_STACK.payments), packageManager: parseAsStringEnumServer( getValidIds("packageManager"), ).withDefault(DEFAULT_STACK.packageManager), diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index 65ee9760d..6a649efa4 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -18,6 +18,7 @@ const CATEGORY_ORDER: Array = [ "webDeploy", "serverDeploy", "auth", + "payments", "packageManager", "addons", "examples", @@ -88,6 +89,7 @@ export function generateStackCommand(stack: StackState) { `--runtime ${stack.runtime}`, `--api ${stack.api}`, `--auth ${stack.auth}`, + `--payments ${stack.payments}`, `--database ${stack.database}`, `--orm ${stack.orm}`, `--db-setup ${stack.dbSetup}`, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 2967afb24..232f1ba44 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -10,6 +10,7 @@ export type TechCategory = | "webDeploy" | "serverDeploy" | "auth" + | "payments" | "packageManager" | "addons" | "examples"