support upgrade, downgrade, cancel

This commit is contained in:
Shawn Erquhart
2025-02-20 16:19:13 -05:00
parent 4e2b374c40
commit 26dc7b14c4
4 changed files with 97 additions and 32 deletions

View File

@@ -10,7 +10,8 @@ export const polar = new Polar<DataModel>(components.polar);
export const MAX_FREE_TODOS = 3;
export const MAX_PREMIUM_TODOS = 6;
export const { generateCheckoutLink } = polar.checkoutApi({
export const { generateCheckoutLink, generateCustomerPortalUrl } =
polar.checkoutApi({
products: {
premium: "5fde8344-5fca-4d0b-adeb-2052cddfd9ed",
premiumPlus: "db548a6f-ff8c-4969-8f02-5f7301a36e7c",

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, Check, Star, Settings } from "lucide-react";
import { CheckoutLink } from "../../src/react";
import { CheckoutLink, CustomerPortalLink } from "../../src/react";
import { api } from "../convex/_generated/api";
export function UpgradeCTA({
@@ -147,23 +147,24 @@ export function UpgradeCTA({
className="w-full text-gray-600 hover:text-indigo-700 dark:text-gray-400 dark:hover:text-indigo-300"
asChild
>
<a href="#">
<CustomerPortalLink polarApi={api.example}>
Manage Subscription <Settings className="ml-2 h-4 w-4" />
</a>
</CustomerPortalLink>
</Button>
)}
{!isPremium && !isPremiumPlus && (
<CheckoutLink polarApi={api.example} productKey="premium">
<Button
variant="secondary"
className="w-full bg-white text-indigo-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-gray-700"
asChild
>
<CheckoutLink polarApi={api.example} productKey="premium">
Upgrade to Premium{" "}
<div className="ml-2">
<ArrowRight size={16} />
</div>
</Button>
</CheckoutLink>
</Button>
)}
</div>
@@ -258,23 +259,24 @@ export function UpgradeCTA({
className="w-full text-gray-600 hover:text-purple-700 dark:text-gray-400 dark:hover:text-purple-300"
asChild
>
<a href="#">
<CustomerPortalLink polarApi={api.example}>
Manage Subscription <Settings className="ml-2 h-4 w-4" />
</a>
</CustomerPortalLink>
</Button>
)}
{!isPremiumPlus && (
<CheckoutLink polarApi={api.example} productKey="premiumPlus">
<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">
Upgrade to Premium Plus{" "}
<div className="ml-2">
<ArrowRight size={16} />
</div>
</Button>
</CheckoutLink>
</Button>
)}
</div>
</div>

View File

@@ -23,6 +23,7 @@ import { WebhookSubscriptionUpdatedPayload } from "@polar-sh/sdk/models/componen
import { WebhookProductCreatedPayload } from "@polar-sh/sdk/models/components/webhookproductcreatedpayload.js";
import { WebhookProductUpdatedPayload } from "@polar-sh/sdk/models/components/webhookproductupdatedpayload.js";
import { Checkout } from "@polar-sh/sdk/models/components/checkout.js";
import { CustomerSession } from "@polar-sh/sdk/models/components/customersession.js";
import {
validateEvent,
WebhookVerificationError,
@@ -122,6 +123,14 @@ export class Polar<DataModel extends GenericDataModel> {
getProduct(ctx: RunQueryCtx, { productId }: { productId: string }) {
return ctx.runQuery(this.component.lib.getProduct, { id: productId });
}
createCustomerPortalSession(
ctx: GenericActionCtx<DataModel>,
{ customerId }: { customerId: string }
): Promise<CustomerSession> {
return this.polar.customerSessions.create({
customerId,
});
}
checkoutApi(opts: {
products: Record<string, string>;
getUserInfo: (ctx: RunQueryCtx) => Promise<{
@@ -149,6 +158,27 @@ export class Polar<DataModel extends GenericDataModel> {
return { url };
},
}),
generateCustomerPortalUrl: actionGeneric({
args: {},
returns: v.union(v.object({ url: v.string() }), v.null()),
handler: async (ctx) => {
const { userId } = await opts.getUserInfo(ctx);
const customer = await ctx.runQuery(
this.component.lib.getCustomerByUserId,
{ userId }
);
if (!customer) {
return null;
}
const session = await this.createCustomerPortalSession(ctx, {
customerId: customer.id,
});
return { url: session.customerPortalUrl };
},
}),
};
}
registerRoutes(

View File

@@ -4,6 +4,38 @@ import { CheckoutApi } from "../client";
import { GenericDataModel } from "convex/server";
import { useAction } from "convex/react";
export const CustomerPortalLink = <DataModel extends GenericDataModel>({
polarApi,
children,
className,
}: PropsWithChildren<{
polarApi: CheckoutApi<DataModel>;
className?: string;
}>) => {
const generateCustomerPortalUrl = useAction(
polarApi.generateCustomerPortalUrl
);
const [portalUrl, setPortalUrl] = useState<string>();
useEffect(() => {
void generateCustomerPortalUrl({}).then((result) => {
if (result) {
setPortalUrl(result.url);
}
});
}, []);
if (!portalUrl) {
return null;
}
return (
<a className={className} href={portalUrl} target="_blank">
{children}
</a>
);
};
export const CheckoutLink = <DataModel extends GenericDataModel>({
polarApi,
productKey,