mirror of
https://github.com/LukeHagar/polar.git
synced 2025-12-10 12:47:47 +00:00
working
This commit is contained in:
26
example/convex/_generated/api.d.ts
vendored
26
example/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -90,11 +90,7 @@ export default function TodoList() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<BillingSettings
|
||||
isPremium={user?.isPremium ?? false}
|
||||
isPremiumPlus={user?.isPremiumPlus ?? false}
|
||||
/>
|
||||
<BillingSettings />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
26
src/component/_generated/api.d.ts
vendored
26
src/component/_generated/api.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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));
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user