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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,34 @@
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 { api } from "../convex/_generated/api";
import { useState } from "react";
import { UpgradeCTA } from "@/UpgradeCta";
import { useQuery } from "convex/react";
export function BillingSettings({
isPremium,
isPremiumPlus,
}: {
isPremium: boolean;
isPremiumPlus: boolean;
}) {
export function BillingSettings() {
const user = useQuery(api.example.getCurrentUser);
const [showPricingPlans, setShowPricingPlans] = useState(false);
const currentPlan = isPremiumPlus
? "Premium Plus"
: isPremium
? "Premium"
: "Free";
const getFeatures = () => {
switch (user?.subscription?.productKey) {
case "premiumMonthly":
case "premiumYearly":
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
? "$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"];
const currentPlan = user?.subscription?.product;
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">
@@ -39,7 +37,7 @@ export function BillingSettings({
{showPricingPlans ? "Available Plans" : "Billing Settings"}
</h2>
<div className="flex items-center gap-4">
{!showPricingPlans && (isPremium || isPremiumPlus) && (
{!showPricingPlans && user?.subscription && (
<CustomerPortalLink
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"
@@ -70,22 +68,20 @@ export function BillingSettings({
<h3 className="text-lg font-medium">Current Plan:</h3>
<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">
{currentPlan}
{currentPlan?.name || "Free"}
</span>
{currentPrice !== "Free" && (
{currentPlan && (
<div className="flex flex-col text-sm">
<span className="font-medium text-gray-600 dark:text-gray-400">
{isPremiumPlus ? "$20/month" : "$10/month"}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
or {isPremiumPlus ? "$200/year" : "$100/year"}
{currentPlan.prices[0].priceAmount}/
{currentPlan.prices[0].recurringInterval}
</span>
</div>
)}
</div>
</div>
<ul className="mt-4 space-y-2">
{features.map((feature) => (
{getFeatures().map((feature) => (
<li
key={feature}
className="flex items-center text-gray-600 dark:text-gray-400"
@@ -98,11 +94,9 @@ export function BillingSettings({
</div>
)}
{showPricingPlans && (
<UpgradeCTA
isFree={!isPremium && !isPremiumPlus}
isPremium={isPremium && !isPremiumPlus}
isPremiumPlus={isPremiumPlus}
/>
<div className="mt-12">
<UpgradeCTA />
</div>
)}
</div>
);

View File

@@ -2,19 +2,13 @@ import { Button } from "@/components/ui/button";
import { ArrowRight, Check, Star, Settings } from "lucide-react";
import { CheckoutLink, CustomerPortalLink } from "../../src/react";
import { api } from "../convex/_generated/api";
import { useAction } from "convex/react";
import { useAction, useQuery } from "convex/react";
import { useState } from "react";
import { ConfirmationModal } from "./ConfirmationModal";
export function UpgradeCTA({
isFree,
isPremium,
isPremiumPlus,
}: {
isFree: boolean;
isPremium: boolean;
isPremiumPlus: boolean;
}) {
export function UpgradeCTA() {
const user = useQuery(api.example.getCurrentUser);
const products = useQuery(api.example.getProducts);
const changeCurrentSubscription = useAction(
api.example.changeCurrentSubscription
);
@@ -23,11 +17,11 @@ export function UpgradeCTA({
);
const [showDowngradeModal, setShowDowngradeModal] = useState(false);
const [pendingDowngrade, setPendingDowngrade] = useState<
"premium" | "free"
"free" | "premiumMonthly"
>();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [pendingUpgrade, setPendingUpgrade] = useState<
"premium" | "premiumPlus"
"premiumMonthly" | "premiumPlusMonthly"
>();
return (
@@ -35,12 +29,12 @@ export function UpgradeCTA({
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
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-600 to-gray-700 dark:from-gray-800 dark:to-gray-900"
} 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">
<Star className="w-3 h-3" /> Current Plan
</div>
@@ -48,7 +42,7 @@ export function UpgradeCTA({
<div className="flex-1">
<h2
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
@@ -56,7 +50,9 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6">
<li
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" />
@@ -64,7 +60,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -72,7 +70,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -80,7 +80,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -88,7 +90,7 @@ export function UpgradeCTA({
</li>
</ul>
</div>
{!isFree && (
{!user?.isFree && (
<Button
variant="ghost"
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
className={`relative flex flex-col bg-gradient-to-br ${
isPremium
? "from-gray-100 to-white dark:from-gray-800 dark:to-gray-900 ring-2 ring-indigo-300 dark:ring-indigo-700"
user?.isPremium && "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"
} 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">
<Star className="w-3 h-3" /> Current Plan
</div>
@@ -118,7 +122,7 @@ export function UpgradeCTA({
<div className="flex-1">
<h2
className={`text-xl font-semibold mb-4 ${
isPremium || isPremiumPlus
user?.isPremium || user?.isPremiumPlus
? "text-indigo-700 dark:text-indigo-300"
: "text-white"
}`}
@@ -128,7 +132,9 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6">
<li
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" />
@@ -136,7 +142,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -144,7 +152,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -152,7 +162,9 @@ export function UpgradeCTA({
</li>
<li
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" />
@@ -160,7 +172,7 @@ export function UpgradeCTA({
</li>
</ul>
</div>
{isPremium && (
{user?.isPremium && (
<Button
variant="ghost"
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>
</Button>
)}
{isPremiumPlus && (
{user?.isPremiumPlus && (
<Button
variant="ghost"
className="w-full mt-2 text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300"
onClick={() => {
setPendingDowngrade("premium");
setPendingDowngrade("premiumMonthly");
setShowDowngradeModal(true);
}}
>
@@ -184,13 +196,16 @@ export function UpgradeCTA({
<ArrowRight className="ml-2 h-4 w-4 rotate-90" />
</Button>
)}
{isFree && (
{user?.isFree && products && (
<Button
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"
asChild
>
<CheckoutLink polarApi={api.example} productKey="premium">
<CheckoutLink
polarApi={api.example}
productId={products?.premiumMonthly.id}
>
Upgrade to Premium{" "}
<div className="ml-2">
<ArrowRight size={16} />
@@ -202,12 +217,12 @@ export function UpgradeCTA({
<div
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-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`}
>
{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">
<Star className="w-3 h-3" /> Current Plan
</div>
@@ -219,7 +234,7 @@ export function UpgradeCTA({
<div className="flex-1">
<h2
className={`text-xl font-semibold mb-4 ${
isPremiumPlus
user?.isPremiumPlus
? "text-purple-700 dark:text-purple-300"
: "text-white"
}`}
@@ -229,63 +244,63 @@ export function UpgradeCTA({
<ul className="space-y-3 mb-6">
<li
className={`flex items-center ${
isPremiumPlus
user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`}
>
<Check
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
</li>
<li
className={`flex items-center ${
isPremiumPlus
user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`}
>
<Check
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 🙌)
</li>
<li
className={`flex items-center ${
isPremiumPlus
user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`}
>
<Check
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**
</li>
<li
className={`flex items-center ${
isPremiumPlus
user?.isPremiumPlus
? "text-gray-700 dark:text-gray-300"
: "text-white"
}`}
>
<Check
className={`w-4 h-4 mr-2 flex-shrink-0 ${
isPremiumPlus ? "" : "text-purple-200"
user?.isPremiumPlus && "text-purple-200"
}`}
/>
Advanced analytics (for us)
</li>
</ul>
</div>
{isPremiumPlus && (
{user?.isPremiumPlus && (
<Button
variant="ghost"
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>
</Button>
)}
{isPremium && (
{user?.isPremium && (
<Button
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"
onClick={() => {
setPendingUpgrade("premiumPlus");
setPendingUpgrade("premiumPlusMonthly");
setShowUpgradeModal(true);
}}
>
@@ -311,13 +326,16 @@ export function UpgradeCTA({
</div>
</Button>
)}
{isFree && (
{user?.isFree && products && (
<Button
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"
asChild
>
<CheckoutLink polarApi={api.example} productKey="premiumPlus">
<CheckoutLink
polarApi={api.example}
productId={products?.premiumPlusMonthly.id}
>
Upgrade to Premium Plus{" "}
<div className="ml-2">
<ArrowRight size={16} />
@@ -331,41 +349,51 @@ export function UpgradeCTA({
<ConfirmationModal
open={showUpgradeModal}
onOpenChange={setShowUpgradeModal}
title={`Upgrade to ${pendingUpgrade === "premium" ? "Premium" : "Premium Plus"}`}
title={`Upgrade to ${pendingUpgrade === "premiumMonthly" ? "Premium" : "Premium Plus"}`}
description={
pendingUpgrade === "premium"
pendingUpgrade === "premiumMonthly"
? "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!"
}
actionLabel="Confirm Upgrade"
onConfirm={() => {
if (!pendingUpgrade) return;
changeCurrentSubscription({
productKey: pendingUpgrade,
});
if (!pendingUpgrade) {
return;
}
const productId = products?.[pendingUpgrade].id;
if (!productId) {
return;
}
changeCurrentSubscription({ productId });
}}
/>
<ConfirmationModal
open={showDowngradeModal}
onOpenChange={setShowDowngradeModal}
title={`Downgrade to ${pendingDowngrade === "premium" ? "Premium" : "Free"}`}
title={`Downgrade to ${pendingDowngrade === "premiumMonthly" ? "Premium" : "Free"}`}
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 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"
onConfirm={() => {
if (!pendingDowngrade) return;
if (!pendingDowngrade) {
return;
}
if (pendingDowngrade === "free") {
cancelCurrentSubscription({
revokeImmediately: true,
});
} else {
changeCurrentSubscription({
productKey: pendingDowngrade,
});
return;
}
const productId = products?.[pendingDowngrade].id;
if (!productId) {
return;
}
changeCurrentSubscription({
productId: products?.[pendingDowngrade].id,
});
}}
variant="destructive"
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,9 @@ export default defineSchema(
priceCurrency: v.optional(v.string()),
priceAmount: v.optional(v.number()),
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(
@@ -63,7 +65,11 @@ export default defineSchema(
modifiedAt: v.union(v.string(), v.null()),
amount: v.union(v.number(), 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(),
currentPeriodStart: v.string(),
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,
productKey,
productId,
children,
className,
theme = "dark",
}: PropsWithChildren<{
polarApi: Pick<CheckoutApi<DataModel>, "generateCheckoutLink">;
productKey: string;
polarApi: Pick<CheckoutApi, "generateCheckoutLink">;
productId: string;
className?: string;
theme?: "dark" | "light";
}>) => {
@@ -54,7 +54,7 @@ export const CheckoutLink = <DataModel extends GenericDataModel>({
useEffect(() => {
PolarEmbedCheckout.init();
void generateCheckoutLink({
productKey,
productId,
origin: window.location.origin,
}).then(({ url }) => setCheckoutLink(url));
}, []);