mirror of
https://github.com/LukeHagar/polar.git
synced 2025-12-11 04:21:09 +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;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user