This commit is contained in:
Shawn Erquhart
2025-02-21 23:13:22 -05:00
parent 7592f9545d
commit 57d44c43f5
12 changed files with 238 additions and 172 deletions

View File

@@ -85,7 +85,7 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
@@ -111,7 +111,7 @@ export declare const components: {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}; };
@@ -177,12 +177,12 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
} | null } | null
@@ -241,7 +241,7 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
} | null } | null
@@ -267,7 +267,7 @@ export declare const components: {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
} | null } | null
@@ -299,7 +299,7 @@ export declare const components: {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}> }>
@@ -307,7 +307,7 @@ export declare const components: {
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"internal", "internal",
{ includeArchived: boolean }, { includeArchived?: boolean },
Array<{ Array<{
_creationTime: number; _creationTime: number;
_id: string; _id: string;
@@ -347,7 +347,7 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}> }>
@@ -411,12 +411,12 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
} | null; } | null;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}> }>
@@ -462,7 +462,7 @@ export declare const components: {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
@@ -488,7 +488,7 @@ export declare const components: {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}; };

View File

@@ -2,15 +2,16 @@ import { Polar } from "@convex-dev/polar";
import { api, components } from "./_generated/api"; import { api, components } from "./_generated/api";
import { QueryCtx, mutation, query } from "./_generated/server"; import { QueryCtx, mutation, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { DataModel, Id } from "./_generated/dataModel"; import { Id } from "./_generated/dataModel";
const products = { export const polar = new Polar(components.polar, {
premium: "5fde8344-5fca-4d0b-adeb-2052cddfd9ed", products: {
premiumPlus: "db548a6f-ff8c-4969-8f02-5f7301a36e7c", // These would probably be environment variables in a production app
}; premiumMonthly: "5fde8344-5fca-4d0b-adeb-2052cddfd9ed",
premiumYearly: "9bc5ed5f-2065-40a4-bd1f-e012e448d82f",
export const polar = new Polar<DataModel>(components.polar, { premiumPlusMonthly: "db548a6f-ff8c-4969-8f02-5f7301a36e7c",
products, premiumPlusYearly: "9ff9976e-459e-4ebc-8cde-b2ced74f8822",
},
getUserInfo: async (ctx) => { getUserInfo: async (ctx) => {
const user: { _id: Id<"users">; email: string } = await ctx.runQuery( const user: { _id: Id<"users">; email: string } = await ctx.runQuery(
api.example.getCurrentUser api.example.getCurrentUser
@@ -25,8 +26,11 @@ export const polar = new Polar<DataModel>(components.polar, {
export const MAX_FREE_TODOS = 3; export const MAX_FREE_TODOS = 3;
export const MAX_PREMIUM_TODOS = 6; export const MAX_PREMIUM_TODOS = 6;
export const { changeCurrentSubscription, cancelCurrentSubscription } = export const {
polar.api(); changeCurrentSubscription,
cancelCurrentSubscription,
getProducts,
} = polar.api();
export const { generateCheckoutLink, generateCustomerPortalUrl } = export const { generateCheckoutLink, generateCustomerPortalUrl } =
polar.checkoutApi(); polar.checkoutApi();
@@ -41,14 +45,17 @@ const currentUser = async (ctx: QueryCtx) => {
const subscription = await polar.getCurrentSubscription(ctx, { const subscription = await polar.getCurrentSubscription(ctx, {
userId: user._id, userId: user._id,
}); });
const isPremiumPlus = const productKey = subscription?.productKey;
subscription?.product?.id === polar.products.premiumPlus;
const isPremium = const isPremium =
isPremiumPlus || subscription?.product?.id === polar.products.premium; productKey === "premiumMonthly" || productKey === "premiumYearly";
const isPremiumPlus =
productKey === "premiumPlusMonthly" || productKey === "premiumPlusYearly";
return { return {
...user, ...user,
isFree: !isPremium && !isPremiumPlus,
isPremium, isPremium,
isPremiumPlus, isPremiumPlus,
subscription,
maxTodos: isPremiumPlus maxTodos: isPremiumPlus
? MAX_PREMIUM_TODOS ? MAX_PREMIUM_TODOS
: isPremium : isPremium
@@ -111,10 +118,15 @@ export const insertTodo = mutation({
.withIndex("userId", (q) => q.eq("userId", user._id)) .withIndex("userId", (q) => q.eq("userId", user._id))
.collect() .collect()
).length; ).length;
if (!user.isPremium && todoCount >= MAX_FREE_TODOS) { const productKey = user.subscription?.productKey;
if (!productKey && todoCount >= MAX_FREE_TODOS) {
throw new Error("Reached maximum number of todos for free plan"); throw new Error("Reached maximum number of todos for free plan");
} }
if (!user.isPremiumPlus && todoCount >= MAX_PREMIUM_TODOS) { if (
(productKey === "premiumMonthly" ||
productKey === "premiumPlusMonthly") &&
todoCount >= MAX_PREMIUM_TODOS
) {
throw new Error("Reached maximum number of todos for premium plan"); throw new Error("Reached maximum number of todos for premium plan");
} }
await ctx.db.insert("todos", { await ctx.db.insert("todos", {

View File

@@ -27,6 +27,7 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"remeda": "^2.20.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"use-debounce": "^10.0.4" "use-debounce": "^10.0.4"

View File

@@ -90,11 +90,7 @@ export default function TodoList() {
))} ))}
</ul> </ul>
</div> </div>
<BillingSettings />
<BillingSettings
isPremium={user?.isPremium ?? false}
isPremiumPlus={user?.isPremiumPlus ?? false}
/>
</div> </div>
</main> </main>
); );

View File

@@ -1,36 +1,34 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight, Check, Settings } from "lucide-react"; import { ArrowLeft, Check, Settings } from "lucide-react";
import { CustomerPortalLink } from "../../src/react"; import { CustomerPortalLink } from "../../src/react";
import { api } from "../convex/_generated/api"; import { api } from "../convex/_generated/api";
import { useState } from "react"; import { useState } from "react";
import { UpgradeCTA } from "@/UpgradeCta"; import { UpgradeCTA } from "@/UpgradeCta";
import { useQuery } from "convex/react";
export function BillingSettings({ export function BillingSettings() {
isPremium, const user = useQuery(api.example.getCurrentUser);
isPremiumPlus,
}: {
isPremium: boolean;
isPremiumPlus: boolean;
}) {
const [showPricingPlans, setShowPricingPlans] = useState(false); const [showPricingPlans, setShowPricingPlans] = useState(false);
const currentPlan = isPremiumPlus const getFeatures = () => {
? "Premium Plus" switch (user?.subscription?.productKey) {
: isPremium case "premiumMonthly":
? "Premium" case "premiumYearly":
: "Free"; return ["Up to 6 todos", "Reduced ads", "Basic support"];
case "premiumPlusMonthly":
case "premiumPlusYearly":
return [
"Unlimited todos",
"No ads",
"Priority support",
"Advanced analytics",
];
default:
return ["Up to 3 todos", "Ad supported", "Community support"];
}
};
const currentPrice = isPremiumPlus const currentPlan = user?.subscription?.product;
? "$20/month or $200/year"
: isPremium
? "$10/month or $100/year"
: "Free";
const features = isPremiumPlus
? ["Unlimited todos", "No ads", "Priority support", "Advanced analytics"]
: isPremium
? ["Up to 6 todos", "Reduced ads", "Basic support"]
: ["Up to 3 todos", "Ad supported", "Community support"];
return ( return (
<div className="mt-8 p-6 bg-white dark:bg-gray-950 border border-transparent dark:border-gray-900 rounded-lg shadow-lg dark:shadow-gray-800/30"> <div className="mt-8 p-6 bg-white dark:bg-gray-950 border border-transparent dark:border-gray-900 rounded-lg shadow-lg dark:shadow-gray-800/30">
@@ -39,7 +37,7 @@ export function BillingSettings({
{showPricingPlans ? "Available Plans" : "Billing Settings"} {showPricingPlans ? "Available Plans" : "Billing Settings"}
</h2> </h2>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{!showPricingPlans && (isPremium || isPremiumPlus) && ( {!showPricingPlans && user?.subscription && (
<CustomerPortalLink <CustomerPortalLink
polarApi={api.example} polarApi={api.example}
className="text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 flex items-center gap-1.5 px-2 py-1 rounded-md transition-colors" className="text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 flex items-center gap-1.5 px-2 py-1 rounded-md transition-colors"
@@ -70,22 +68,20 @@ export function BillingSettings({
<h3 className="text-lg font-medium">Current Plan:</h3> <h3 className="text-lg font-medium">Current Plan:</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200"> <span className="px-3 py-1 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200">
{currentPlan} {currentPlan?.name || "Free"}
</span> </span>
{currentPrice !== "Free" && ( {currentPlan && (
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-medium text-gray-600 dark:text-gray-400"> <span className="font-medium text-gray-600 dark:text-gray-400">
{isPremiumPlus ? "$20/month" : "$10/month"} {currentPlan.prices[0].priceAmount}/
</span> {currentPlan.prices[0].recurringInterval}
<span className="text-xs text-gray-500 dark:text-gray-500">
or {isPremiumPlus ? "$200/year" : "$100/year"}
</span> </span>
</div> </div>
)} )}
</div> </div>
</div> </div>
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">
{features.map((feature) => ( {getFeatures().map((feature) => (
<li <li
key={feature} key={feature}
className="flex items-center text-gray-600 dark:text-gray-400" className="flex items-center text-gray-600 dark:text-gray-400"
@@ -98,11 +94,9 @@ export function BillingSettings({
</div> </div>
)} )}
{showPricingPlans && ( {showPricingPlans && (
<UpgradeCTA <div className="mt-12">
isFree={!isPremium && !isPremiumPlus} <UpgradeCTA />
isPremium={isPremium && !isPremiumPlus} </div>
isPremiumPlus={isPremiumPlus}
/>
)} )}
</div> </div>
); );

View File

@@ -2,19 +2,13 @@ import { Button } from "@/components/ui/button";
import { ArrowRight, Check, Star, Settings } from "lucide-react"; import { ArrowRight, Check, Star, Settings } from "lucide-react";
import { CheckoutLink, CustomerPortalLink } from "../../src/react"; import { CheckoutLink, CustomerPortalLink } from "../../src/react";
import { api } from "../convex/_generated/api"; import { api } from "../convex/_generated/api";
import { useAction } from "convex/react"; import { useAction, useQuery } from "convex/react";
import { useState } from "react"; import { useState } from "react";
import { ConfirmationModal } from "./ConfirmationModal"; import { ConfirmationModal } from "./ConfirmationModal";
export function UpgradeCTA({ export function UpgradeCTA() {
isFree, const user = useQuery(api.example.getCurrentUser);
isPremium, const products = useQuery(api.example.getProducts);
isPremiumPlus,
}: {
isFree: boolean;
isPremium: boolean;
isPremiumPlus: boolean;
}) {
const changeCurrentSubscription = useAction( const changeCurrentSubscription = useAction(
api.example.changeCurrentSubscription api.example.changeCurrentSubscription
); );
@@ -23,11 +17,11 @@ export function UpgradeCTA({
); );
const [showDowngradeModal, setShowDowngradeModal] = useState(false); const [showDowngradeModal, setShowDowngradeModal] = useState(false);
const [pendingDowngrade, setPendingDowngrade] = useState< const [pendingDowngrade, setPendingDowngrade] = useState<
"premium" | "free" "free" | "premiumMonthly"
>(); >();
const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [pendingUpgrade, setPendingUpgrade] = useState< const [pendingUpgrade, setPendingUpgrade] = useState<
"premium" | "premiumPlus" "premiumMonthly" | "premiumPlusMonthly"
>(); >();
return ( return (
@@ -35,12 +29,12 @@ export function UpgradeCTA({
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div <div
className={`relative flex flex-col bg-gradient-to-br ${ className={`relative flex flex-col bg-gradient-to-br ${
isFree user?.isFree
? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-gray-300 dark:ring-gray-700" ? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-gray-300 dark:ring-gray-700"
: "from-gray-600 to-gray-700 dark:from-gray-800 dark:to-gray-900" : "from-gray-600 to-gray-700 dark:from-gray-800 dark:to-gray-900"
} p-6 rounded-lg shadow-md`} } p-6 rounded-lg shadow-md`}
> >
{isFree && ( {user?.isFree && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1"> <div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
<Star className="w-3 h-3" /> Current Plan <Star className="w-3 h-3" /> Current Plan
</div> </div>
@@ -48,7 +42,7 @@ export function UpgradeCTA({
<div className="flex-1"> <div className="flex-1">
<h2 <h2
className={`text-xl font-semibold mb-4 ${ className={`text-xl font-semibold mb-4 ${
isFree ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isFree ? "text-gray-700 dark:text-gray-300" : "text-white"
}`} }`}
> >
Free Free
@@ -56,7 +50,9 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6"> <ul className="space-y-3 mb-6">
<li <li
className={`flex items-center ${ className={`flex items-center ${
isFree ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isFree
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -64,7 +60,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isFree ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isFree
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -72,7 +70,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isFree ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isFree
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -80,7 +80,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isFree ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isFree
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -88,7 +90,7 @@ export function UpgradeCTA({
</li> </li>
</ul> </ul>
</div> </div>
{!isFree && ( {!user?.isFree && (
<Button <Button
variant="ghost" variant="ghost"
className="w-full mt-2 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" className="w-full mt-2 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
@@ -105,12 +107,14 @@ export function UpgradeCTA({
<div <div
className={`relative flex flex-col bg-gradient-to-br ${ className={`relative flex flex-col bg-gradient-to-br ${
isPremium user?.isPremium && "ring-2 ring-indigo-300 dark:ring-indigo-700"
? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-indigo-300 dark:ring-indigo-700" } ${
user?.isPremium || user?.isPremiumPlus
? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900"
: "from-indigo-600 to-purple-600 dark:from-indigo-900 dark:to-purple-900" : "from-indigo-600 to-purple-600 dark:from-indigo-900 dark:to-purple-900"
} text-white p-6 rounded-lg shadow-md`} } text-white p-6 rounded-lg shadow-md`}
> >
{isPremium && ( {user?.isPremium && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1"> <div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
<Star className="w-3 h-3" /> Current Plan <Star className="w-3 h-3" /> Current Plan
</div> </div>
@@ -118,7 +122,7 @@ export function UpgradeCTA({
<div className="flex-1"> <div className="flex-1">
<h2 <h2
className={`text-xl font-semibold mb-4 ${ className={`text-xl font-semibold mb-4 ${
isPremium || isPremiumPlus user?.isPremium || user?.isPremiumPlus
? "text-indigo-700 dark:text-indigo-300" ? "text-indigo-700 dark:text-indigo-300"
: "text-white" : "text-white"
}`} }`}
@@ -128,7 +132,9 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6"> <ul className="space-y-3 mb-6">
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremium ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isPremium
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -136,7 +142,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremium ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isPremium
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -144,7 +152,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremium ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isPremium
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -152,7 +162,9 @@ export function UpgradeCTA({
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremium ? "text-gray-700 dark:text-gray-300" : "text-white" user?.isPremium
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`} }`}
> >
<Check className="w-4 h-4 mr-2 flex-shrink-0" /> <Check className="w-4 h-4 mr-2 flex-shrink-0" />
@@ -160,7 +172,7 @@ export function UpgradeCTA({
</li> </li>
</ul> </ul>
</div> </div>
{isPremium && ( {user?.isPremium && (
<Button <Button
variant="ghost" variant="ghost"
className="w-full text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300" className="w-full text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300"
@@ -171,12 +183,12 @@ export function UpgradeCTA({
</CustomerPortalLink> </CustomerPortalLink>
</Button> </Button>
)} )}
{isPremiumPlus && ( {user?.isPremiumPlus && (
<Button <Button
variant="ghost" variant="ghost"
className="w-full mt-2 text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300" className="w-full mt-2 text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300"
onClick={() => { onClick={() => {
setPendingDowngrade("premium"); setPendingDowngrade("premiumMonthly");
setShowDowngradeModal(true); setShowDowngradeModal(true);
}} }}
> >
@@ -184,13 +196,16 @@ export function UpgradeCTA({
<ArrowRight className="ml-2 h-4 w-4 rotate-90" /> <ArrowRight className="ml-2 h-4 w-4 rotate-90" />
</Button> </Button>
)} )}
{isFree && ( {user?.isFree && products && (
<Button <Button
variant="secondary" variant="secondary"
className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20" className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20"
asChild asChild
> >
<CheckoutLink polarApi={api.example} productKey="premium"> <CheckoutLink
polarApi={api.example}
productId={products?.premiumMonthly.id}
>
Upgrade to Premium{" "} Upgrade to Premium{" "}
<div className="ml-2"> <div className="ml-2">
<ArrowRight size={16} /> <ArrowRight size={16} />
@@ -202,12 +217,12 @@ export function UpgradeCTA({
<div <div
className={`relative flex flex-col bg-gradient-to-br ${ className={`relative flex flex-col bg-gradient-to-br ${
isPremiumPlus user?.isPremiumPlus
? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-purple-300 dark:ring-purple-700" ? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-purple-300 dark:ring-purple-700"
: "from-indigo-600 via-purple-600 to-indigo-600 dark:from-indigo-900 dark:via-purple-900 dark:to-indigo-900" : "from-indigo-600 via-purple-600 to-indigo-600 dark:from-indigo-900 dark:via-purple-900 dark:to-indigo-900"
} text-white p-6 rounded-lg shadow-xl`} } text-white p-6 rounded-lg shadow-xl`}
> >
{isPremiumPlus ? ( {user?.isPremiumPlus ? (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1"> <div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
<Star className="w-3 h-3" /> Current Plan <Star className="w-3 h-3" /> Current Plan
</div> </div>
@@ -219,7 +234,7 @@ export function UpgradeCTA({
<div className="flex-1"> <div className="flex-1">
<h2 <h2
className={`text-xl font-semibold mb-4 ${ className={`text-xl font-semibold mb-4 ${
isPremiumPlus user?.isPremiumPlus
? "text-purple-700 dark:text-purple-300" ? "text-purple-700 dark:text-purple-300"
: "text-white" : "text-white"
}`} }`}
@@ -229,63 +244,63 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6"> <ul className="space-y-3 mb-6">
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremiumPlus user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300" ? "text-gray-700 dark:text-gray-300"
: "text-white" : "text-white"
}`} }`}
> >
<Check <Check
className={`w-4 h-4 mr-2 flex-shrink-0 ${ className={`w-4 h-4 mr-2 flex-shrink-0 ${
isPremiumPlus ? "" : "text-purple-200" user?.isPremiumPlus && "text-purple-200"
}`} }`}
/> />
All the todos you can todo All the todos you can todo
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremiumPlus user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300" ? "text-gray-700 dark:text-gray-300"
: "text-white" : "text-white"
}`} }`}
> >
<Check <Check
className={`w-4 h-4 mr-2 flex-shrink-0 ${ className={`w-4 h-4 mr-2 flex-shrink-0 ${
isPremiumPlus ? "" : "text-purple-200" user?.isPremiumPlus && "text-purple-200"
}`} }`}
/> />
24/7 support (3-5 day response time 🙌) 24/7 support (3-5 day response time 🙌)
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremiumPlus user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300" ? "text-gray-700 dark:text-gray-300"
: "text-white" : "text-white"
}`} }`}
> >
<Check <Check
className={`w-4 h-4 mr-2 flex-shrink-0 ${ className={`w-4 h-4 mr-2 flex-shrink-0 ${
isPremiumPlus ? "" : "text-purple-200" user?.isPremiumPlus && "text-purple-200"
}`} }`}
/> />
Todo Inc. will steal less of your data** Todo Inc. will steal less of your data**
</li> </li>
<li <li
className={`flex items-center ${ className={`flex items-center ${
isPremiumPlus user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300" ? "text-gray-700 dark:text-gray-300"
: "text-white" : "text-white"
}`} }`}
> >
<Check <Check
className={`w-4 h-4 mr-2 flex-shrink-0 ${ className={`w-4 h-4 mr-2 flex-shrink-0 ${
isPremiumPlus ? "" : "text-purple-200" user?.isPremiumPlus && "text-purple-200"
}`} }`}
/> />
Advanced analytics (for us) Advanced analytics (for us)
</li> </li>
</ul> </ul>
</div> </div>
{isPremiumPlus && ( {user?.isPremiumPlus && (
<Button <Button
variant="ghost" variant="ghost"
className="w-full text-gray-600 hover:text-purple-700 dark:text-gray-400 dark:hover:text-purple-300" className="w-full text-gray-600 hover:text-purple-700 dark:text-gray-400 dark:hover:text-purple-300"
@@ -296,12 +311,12 @@ export function UpgradeCTA({
</CustomerPortalLink> </CustomerPortalLink>
</Button> </Button>
)} )}
{isPremium && ( {user?.isPremium && (
<Button <Button
variant="secondary" variant="secondary"
className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20" className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20"
onClick={() => { onClick={() => {
setPendingUpgrade("premiumPlus"); setPendingUpgrade("premiumPlusMonthly");
setShowUpgradeModal(true); setShowUpgradeModal(true);
}} }}
> >
@@ -311,13 +326,16 @@ export function UpgradeCTA({
</div> </div>
</Button> </Button>
)} )}
{isFree && ( {user?.isFree && products && (
<Button <Button
variant="secondary" variant="secondary"
className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20" className="w-full bg-white/95 backdrop-blur-sm text-purple-700 hover:bg-white dark:bg-white/10 dark:text-purple-200 dark:hover:bg-white/20"
asChild asChild
> >
<CheckoutLink polarApi={api.example} productKey="premiumPlus"> <CheckoutLink
polarApi={api.example}
productId={products?.premiumPlusMonthly.id}
>
Upgrade to Premium Plus{" "} Upgrade to Premium Plus{" "}
<div className="ml-2"> <div className="ml-2">
<ArrowRight size={16} /> <ArrowRight size={16} />
@@ -331,41 +349,51 @@ export function UpgradeCTA({
<ConfirmationModal <ConfirmationModal
open={showUpgradeModal} open={showUpgradeModal}
onOpenChange={setShowUpgradeModal} onOpenChange={setShowUpgradeModal}
title={`Upgrade to ${pendingUpgrade === "premium" ? "Premium" : "Premium Plus"}`} title={`Upgrade to ${pendingUpgrade === "premiumMonthly" ? "Premium" : "Premium Plus"}`}
description={ description={
pendingUpgrade === "premium" pendingUpgrade === "premiumMonthly"
? "Upgrade to Premium and get access to 6 todos, fewer ads, and support for the cheapskates!" ? "Upgrade to Premium and get access to 6 todos, fewer ads, and support for the cheapskates!"
: "Get the ultimate todo experience with Premium Plus! Unlimited todos, no ads, and priority support!" : "Get the ultimate todo experience with Premium Plus! Unlimited todos, no ads, and priority support!"
} }
actionLabel="Confirm Upgrade" actionLabel="Confirm Upgrade"
onConfirm={() => { onConfirm={() => {
if (!pendingUpgrade) return; if (!pendingUpgrade) {
changeCurrentSubscription({ return;
productKey: pendingUpgrade, }
}); const productId = products?.[pendingUpgrade].id;
if (!productId) {
return;
}
changeCurrentSubscription({ productId });
}} }}
/> />
<ConfirmationModal <ConfirmationModal
open={showDowngradeModal} open={showDowngradeModal}
onOpenChange={setShowDowngradeModal} onOpenChange={setShowDowngradeModal}
title={`Downgrade to ${pendingDowngrade === "premium" ? "Premium" : "Free"}`} title={`Downgrade to ${pendingDowngrade === "premiumMonthly" ? "Premium" : "Free"}`}
description={ description={
pendingDowngrade === "premium" pendingDowngrade === "premiumMonthly"
? "Your Premium Plus features will remain active until the end of your current billing period. After that, you'll be moved to the Premium plan." ? "Your Premium Plus features will remain active until the end of your current billing period. After that, you'll be moved to the Premium plan."
: "Your premium features will remain active until the end of your current billing period. After that, you'll be moved to the Free plan." : "Your premium features will remain active until the end of your current billing period. After that, you'll be moved to the Free plan."
} }
actionLabel="Confirm Downgrade" actionLabel="Confirm Downgrade"
onConfirm={() => { onConfirm={() => {
if (!pendingDowngrade) return; if (!pendingDowngrade) {
return;
}
if (pendingDowngrade === "free") { if (pendingDowngrade === "free") {
cancelCurrentSubscription({ cancelCurrentSubscription({
revokeImmediately: true, revokeImmediately: true,
}); });
} else { return;
changeCurrentSubscription({
productKey: pendingDowngrade,
});
} }
const productId = products?.[pendingDowngrade].id;
if (!productId) {
return;
}
changeCurrentSubscription({
productId: products?.[pendingDowngrade].id,
});
}} }}
variant="destructive" variant="destructive"
/> />

View File

@@ -89,6 +89,7 @@
"@polar-sh/checkout": "^0.1.9", "@polar-sh/checkout": "^0.1.9",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"convex-helpers": "^0.1.63", "convex-helpers": "^0.1.63",
"remeda": "^2.20.2",
"standardwebhooks": "^1.0.0" "standardwebhooks": "^1.0.0"
} }
} }

View File

@@ -7,6 +7,7 @@ import {
actionGeneric, actionGeneric,
httpActionGeneric, httpActionGeneric,
GenericActionCtx, GenericActionCtx,
queryGeneric,
} from "convex/server"; } from "convex/server";
import { import {
type ComponentApi, type ComponentApi,
@@ -27,6 +28,7 @@ import {
validateEvent, validateEvent,
WebhookVerificationError, WebhookVerificationError,
} from "@polar-sh/sdk/webhooks"; } from "@polar-sh/sdk/webhooks";
import { Doc } from "../component/_generated/dataModel";
export const subscriptionValidator = schema.tables.subscriptions.validator; export const subscriptionValidator = schema.tables.subscriptions.validator;
export type Subscription = Infer<typeof subscriptionValidator>; export type Subscription = Infer<typeof subscriptionValidator>;
@@ -38,15 +40,15 @@ export type SubscriptionHandler = FunctionReference<
>; >;
export type CheckoutApi< export type CheckoutApi<
DataModel extends GenericDataModel, DataModel extends GenericDataModel = GenericDataModel,
Products extends Record<string, string>, Products extends Record<string, string> = Record<string, string>,
> = ApiFromModules<{ > = ApiFromModules<{
checkout: ReturnType<Polar<DataModel, Products>["checkoutApi"]>; checkout: ReturnType<Polar<DataModel, Products>["checkoutApi"]>;
}>["checkout"]; }>["checkout"];
export class Polar< export class Polar<
DataModel extends GenericDataModel, DataModel extends GenericDataModel = GenericDataModel,
Products extends Record<string, string>, Products extends Record<string, string> = Record<string, string>,
> { > {
public sdk: PolarSdk; public sdk: PolarSdk;
public products: Products; public products: Products;
@@ -129,31 +131,45 @@ export class Polar<
} }
listProducts( listProducts(
ctx: RunQueryCtx, ctx: RunQueryCtx,
{ includeArchived }: { includeArchived: boolean } { includeArchived }: { includeArchived?: boolean } = {}
) { ) {
return ctx.runQuery(this.component.lib.listProducts, { includeArchived }); return ctx.runQuery(this.component.lib.listProducts, {
} includeArchived,
getCurrentSubscription(ctx: RunQueryCtx, { userId }: { userId: string }) {
return ctx.runQuery(this.component.lib.getCurrentSubscription, {
userId,
}); });
} }
async getCurrentSubscription(
ctx: RunQueryCtx,
{ userId }: { userId: string }
) {
const subscription = await ctx.runQuery(
this.component.lib.getCurrentSubscription,
{
userId,
}
);
if (!subscription) {
return null;
}
const productKey = (
Object.keys(this.products) as Array<keyof Products>
).find((key) => this.products[key] === subscription.productId);
return {
...subscription,
productKey,
};
}
getProduct(ctx: RunQueryCtx, { productId }: { productId: string }) { getProduct(ctx: RunQueryCtx, { productId }: { productId: string }) {
return ctx.runQuery(this.component.lib.getProduct, { id: productId }); return ctx.runQuery(this.component.lib.getProduct, { id: productId });
} }
async changeSubscription( async changeSubscription(
ctx: GenericActionCtx<DataModel>, ctx: GenericActionCtx<DataModel>,
{ productKey }: { productKey: string } { productId }: { productId: string }
) { ) {
const { userId } = await this.config.getUserInfo(ctx); const { userId } = await this.config.getUserInfo(ctx);
const subscription = await this.getCurrentSubscription(ctx, { userId }); const subscription = await this.getCurrentSubscription(ctx, { userId });
if (!subscription) { if (!subscription) {
throw new Error("Subscription not found"); throw new Error("Subscription not found");
} }
const productId = this.config.products[productKey];
if (!productId) {
throw new Error("Product not found");
}
if (subscription.productId === productId) { if (subscription.productId === productId) {
throw new Error("Subscription already on this product"); throw new Error("Subscription already on this product");
} }
@@ -188,11 +204,11 @@ export class Polar<
return { return {
changeCurrentSubscription: actionGeneric({ changeCurrentSubscription: actionGeneric({
args: { args: {
productKey: v.string(), productId: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await this.changeSubscription(ctx, { await this.changeSubscription(ctx, {
productKey: args.productKey, productId: args.productId,
}); });
}, },
}), }),
@@ -206,13 +222,25 @@ export class Polar<
}); });
}, },
}), }),
getProducts: queryGeneric({
args: {},
handler: async (ctx) => {
const products = await this.listProducts(ctx);
return Object.fromEntries(
Object.keys(this.products).map((key) => [
key,
products.find((p) => p.id === this.products[key]),
])
) as Record<keyof Products, Doc<"products">>;
},
}),
}; };
} }
checkoutApi() { checkoutApi() {
return { return {
generateCheckoutLink: actionGeneric({ generateCheckoutLink: actionGeneric({
args: { args: {
productKey: v.string(), productId: v.string(),
origin: v.string(), origin: v.string(),
}, },
returns: v.object({ returns: v.object({
@@ -221,7 +249,7 @@ export class Polar<
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { userId, email } = await this.config.getUserInfo(ctx); const { userId, email } = await this.config.getUserInfo(ctx);
const { url } = await this.createCheckoutSession(ctx, { const { url } = await this.createCheckoutSession(ctx, {
productId: this.config.products?.[args.productKey], productId: args.productId,
userId, userId,
email, email,
origin: args.origin, origin: args.origin,

View File

@@ -71,7 +71,7 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
@@ -97,7 +97,7 @@ export type Mounts = {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}; };
@@ -163,12 +163,12 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
} | null } | null
@@ -222,7 +222,7 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
} | null } | null
@@ -248,7 +248,7 @@ export type Mounts = {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
} | null } | null
@@ -280,7 +280,7 @@ export type Mounts = {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}> }>
@@ -288,7 +288,7 @@ export type Mounts = {
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"public", "public",
{ includeArchived: boolean }, { includeArchived?: boolean },
Array<{ Array<{
_creationTime: number; _creationTime: number;
_id: string; _id: string;
@@ -328,7 +328,7 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}> }>
@@ -392,12 +392,12 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
} | null; } | null;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}> }>
@@ -443,7 +443,7 @@ export type Mounts = {
priceAmount?: number; priceAmount?: number;
priceCurrency?: string; priceCurrency?: string;
productId: string; productId: string;
recurringInterval?: string; recurringInterval?: "month" | "year" | null;
type?: string; type?: string;
}>; }>;
}; };
@@ -469,7 +469,7 @@ export type Mounts = {
modifiedAt: string | null; modifiedAt: string | null;
priceId: string; priceId: string;
productId: string; productId: string;
recurringInterval: string; recurringInterval: "month" | "year" | null;
startedAt: string | null; startedAt: string | null;
status: string; status: string;
}; };

View File

@@ -211,7 +211,7 @@ export const listUserSubscriptions = query({
export const listProducts = query({ export const listProducts = query({
args: { args: {
includeArchived: v.boolean(), includeArchived: v.optional(v.boolean()),
}, },
returns: v.array( returns: v.array(
v.object({ v.object({

View File

@@ -29,7 +29,9 @@ export default defineSchema(
priceCurrency: v.optional(v.string()), priceCurrency: v.optional(v.string()),
priceAmount: v.optional(v.number()), priceAmount: v.optional(v.number()),
type: v.optional(v.string()), type: v.optional(v.string()),
recurringInterval: v.optional(v.string()), recurringInterval: v.optional(
v.union(v.literal("month"), v.literal("year"), v.null())
),
}) })
), ),
medias: v.array( medias: v.array(
@@ -63,7 +65,11 @@ export default defineSchema(
modifiedAt: v.union(v.string(), v.null()), modifiedAt: v.union(v.string(), v.null()),
amount: v.union(v.number(), v.null()), amount: v.union(v.number(), v.null()),
currency: v.union(v.string(), v.null()), currency: v.union(v.string(), v.null()),
recurringInterval: v.string(), recurringInterval: v.union(
v.literal("month"),
v.literal("year"),
v.null()
),
status: v.string(), status: v.string(),
currentPeriodStart: v.string(), currentPeriodStart: v.string(),
currentPeriodEnd: v.union(v.string(), v.null()), currentPeriodEnd: v.union(v.string(), v.null()),

View File

@@ -36,15 +36,15 @@ export const CustomerPortalLink = <DataModel extends GenericDataModel>({
); );
}; };
export const CheckoutLink = <DataModel extends GenericDataModel>({ export const CheckoutLink = ({
polarApi, polarApi,
productKey, productId,
children, children,
className, className,
theme = "dark", theme = "dark",
}: PropsWithChildren<{ }: PropsWithChildren<{
polarApi: Pick<CheckoutApi<DataModel>, "generateCheckoutLink">; polarApi: Pick<CheckoutApi, "generateCheckoutLink">;
productKey: string; productId: string;
className?: string; className?: string;
theme?: "dark" | "light"; theme?: "dark" | "light";
}>) => { }>) => {
@@ -54,7 +54,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
useEffect(() => { useEffect(() => {
PolarEmbedCheckout.init(); PolarEmbedCheckout.init();
void generateCheckoutLink({ void generateCheckoutLink({
productKey, productId,
origin: window.location.origin, origin: window.location.origin,
}).then(({ url }) => setCheckoutLink(url)); }).then(({ url }) => setCheckoutLink(url));
}, []); }, []);