Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 4 additions & 97 deletions apps/ui/src/components/billing/plan-management.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";

import { UpgradeToProDialog } from "@/components/shared/upgrade-to-pro-dialog";
import { useDefaultOrganization } from "@/hooks/useOrganization";
import { Badge } from "@/lib/components/badge";
import { Button } from "@/lib/components/button";
Expand All @@ -12,22 +12,12 @@ import {
CardHeader,
CardTitle,
} from "@/lib/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/lib/components/dialog";
import { useToast } from "@/lib/components/use-toast";
import { $api } from "@/lib/fetch-client";

export function PlanManagement() {
const { data: organization } = useDefaultOrganization();
const { toast } = useToast();
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const queryClient = useQueryClient();

const { data: subscriptionStatus } = $api.useQuery(
Expand Down Expand Up @@ -194,14 +184,9 @@ export function PlanManagement() {
</CardContent>
<CardFooter className="flex justify-between">
{!isProPlan ? (
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogTrigger asChild>
<Button>Upgrade to Pro</Button>
</DialogTrigger>
<DialogContent>
<UpgradeDialog onSuccess={() => setUpgradeDialogOpen(false)} />
</DialogContent>
</Dialog>
<UpgradeToProDialog>
<Button>Upgrade to Pro</Button>
</UpgradeToProDialog>
) : (
<div className="flex gap-2">
{!subscriptionStatus?.subscriptionCancelled && (
Expand Down Expand Up @@ -235,81 +220,3 @@ export function PlanManagement() {
</Card>
);
}

function UpgradeDialog({ onSuccess }: { onSuccess: () => void }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);

const createSubscriptionMutation = $api.useMutation(
"post",
"/subscriptions/create-pro-subscription",
);

const handleUpgrade = async () => {
setLoading(true);

try {
const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});

// Redirect to Stripe Checkout
window.location.href = checkoutUrl;
} catch (error) {
toast({
title: "Upgrade Failed",
description: `Failed to create checkout session. Please try again. Error: ${error}`,
variant: "destructive",
});
setLoading(false);
}
};

return (
<>
<DialogHeader>
<DialogTitle>Upgrade to Pro</DialogTitle>
<DialogDescription>
Unlock provider keys and get full access to all features for
$50/month.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium">What you'll get:</h4>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
Use your own OpenAI, Anthropic, and other provider API keys
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
Hybrid mode: fallback to credits when needed
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
No surcharges or fees for API keys or credits usage
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
All existing features (credits, analytics, etc.)
</li>
</ul>
</div>
</div>
<DialogFooter>
<div className="flex flex-col gap-2 items-end">
<Button
onClick={handleUpgrade}
disabled={loading || createSubscriptionMutation.isPending}
>
{loading || createSubscriptionMutation.isPending
? "Redirecting to checkout..."
: "Upgrade for $50/month now"}
</Button>
<div className="text-sm text-muted-foreground">
<p>You'll be redirected to Stripe Checkout to complete payment.</p>
</div>
</div>
</DialogFooter>
</>
);
}
64 changes: 42 additions & 22 deletions apps/ui/src/components/landing/pricing-plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useNavigate } from "@tanstack/react-router";
import { Check, Loader2 } from "lucide-react";
import { useState, useEffect } from "react";

import { UpgradeToProDialog } from "@/components/shared/upgrade-to-pro-dialog";
import { useUser } from "@/hooks/useUser";
import { Badge } from "@/lib/components/badge";
import { Button } from "@/lib/components/button";
Expand Down Expand Up @@ -398,30 +399,49 @@ export function PricingPlans() {
</ul>
</CardContent>
<CardFooter>
<Button
className={`w-full ${plan.popular ? "bg-primary hover:bg-primary/90" : ""}`}
variant={plan.popular ? "default" : "outline"}
disabled={plan.disabled || isLoading}
onClick={() => {
if (
plan.name === "Pro" &&
subscriptionStatus?.plan === "pro"
) {
if (subscriptionStatus.subscriptionCancelled) {
handleResumeSubscription();
{plan.name === "Pro" &&
subscriptionStatus?.plan !== "pro" &&
user ? (
<UpgradeToProDialog
onSuccess={() => fetchSubscriptionStatus()}
>
<Button
className={`w-full ${plan.popular ? "bg-primary hover:bg-primary/90" : ""}`}
variant={plan.popular ? "default" : "outline"}
disabled={plan.disabled || isLoading}
>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{plan.cta}
</Button>
</UpgradeToProDialog>
) : (
<Button
className={`w-full ${plan.popular ? "bg-primary hover:bg-primary/90" : ""}`}
variant={plan.popular ? "default" : "outline"}
disabled={plan.disabled || isLoading}
onClick={() => {
if (
plan.name === "Pro" &&
subscriptionStatus?.plan === "pro"
) {
if (subscriptionStatus.subscriptionCancelled) {
handleResumeSubscription();
} else {
handleCancelSubscription();
}
} else {
handleCancelSubscription();
handlePlanSelection(plan.name);
}
} else {
handlePlanSelection(plan.name);
}
}}
>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{plan.cta}
</Button>
}}
>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{plan.cta}
</Button>
)}
</CardFooter>
</Card>
);
Expand Down
118 changes: 118 additions & 0 deletions apps/ui/src/components/shared/upgrade-to-pro-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useState } from "react";

import { Button } from "@/lib/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/lib/components/dialog";
import { useToast } from "@/lib/components/use-toast";
import { $api } from "@/lib/fetch-client";

interface UpgradeToProDialogProps {
children: React.ReactNode;
onSuccess?: () => void;
}

export function UpgradeToProDialog({
children,
onSuccess,
}: UpgradeToProDialogProps) {
const [open, setOpen] = useState(false);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<UpgradeDialogContent
onSuccess={() => {
setOpen(false);
onSuccess?.();
}}
/>
</DialogContent>
</Dialog>
);
}
Comment on lines +21 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider calling onSuccess after successful subscription creation

The onSuccess callback is only triggered when the dialog is closed manually, not after a successful subscription creation. Since the component redirects to Stripe immediately after a successful mutation, the onSuccess callback won't be executed in the typical success flow. This could lead to stale data in the parent component.

Consider storing the onSuccess callback and executing it after a successful mutation, possibly with a delay before redirect:

 function UpgradeDialogContent({ onSuccess }: { onSuccess: () => void }) {
 	const { toast } = useToast();
 	const [loading, setLoading] = useState(false);
 
 	const createSubscriptionMutation = $api.useMutation(
 		"post",
 		"/subscriptions/create-pro-subscription",
 	);
 
 	const handleUpgrade = async () => {
 		setLoading(true);
 
 		try {
 			const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});
 
+			// Call onSuccess before redirecting
+			onSuccess();
+
 			// Redirect to Stripe Checkout
 			window.location.href = checkoutUrl;
 		} catch (error) {
🤖 Prompt for AI Agents
In apps/ui/src/components/shared/upgrade-to-pro-dialog.tsx around lines 21 to
40, the onSuccess callback is currently only called when the dialog closes
manually, but not after a successful subscription creation which triggers an
immediate redirect to Stripe. To fix this, modify the UpgradeDialogContent
component or its mutation handler to accept and store the onSuccess callback,
then invoke it right after the subscription is successfully created, potentially
adding a short delay before redirecting to Stripe to ensure onSuccess completes
and parent data stays fresh.


function UpgradeDialogContent({ onSuccess }: { onSuccess: () => void }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);

const createSubscriptionMutation = $api.useMutation(
"post",
"/subscriptions/create-pro-subscription",
);

const handleUpgrade = async () => {
setLoading(true);

try {
const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});

// Redirect to Stripe Checkout
window.location.href = checkoutUrl;
} catch (error) {
toast({
title: "Upgrade Failed",
description: `Failed to create checkout session. Please try again. Error: ${error}`,
variant: "destructive",
});
setLoading(false);
Comment on lines +60 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve error handling for better user experience

The error object is directly interpolated into the error message string, which could result in [object Object] being displayed if the error is not a simple string.

Apply this diff to properly handle different error types:

 		} catch (error) {
 			toast({
 				title: "Upgrade Failed",
-				description: `Failed to create checkout session. Please try again. Error: ${error}`,
+				description: error instanceof Error 
+					? `Failed to create checkout session: ${error.message}`
+					: "Failed to create checkout session. Please try again.",
 				variant: "destructive",
 			});
 			setLoading(false);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
toast({
title: "Upgrade Failed",
description: `Failed to create checkout session. Please try again. Error: ${error}`,
variant: "destructive",
});
setLoading(false);
} catch (error) {
toast({
title: "Upgrade Failed",
description: error instanceof Error
? `Failed to create checkout session: ${error.message}`
: "Failed to create checkout session. Please try again.",
variant: "destructive",
});
setLoading(false);
}
🤖 Prompt for AI Agents
In apps/ui/src/components/shared/upgrade-to-pro-dialog.tsx around lines 60 to
65, the error object is directly interpolated into the toast description, which
can display as [object Object] if the error is not a string. To fix this, check
if the error is an instance of Error and use its message property; otherwise,
convert the error to a string safely before including it in the toast
description. This ensures the user sees a meaningful error message.

}
};
Comment on lines +51 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Prevent potential race conditions in mutation handling

While the button is disabled during loading, there's no explicit check to prevent multiple concurrent mutation calls if the button is rapidly clicked or if the component state updates unexpectedly.

Add a guard to prevent concurrent mutations:

 	const handleUpgrade = async () => {
+		if (loading || createSubscriptionMutation.isPending) {
+			return;
+		}
+
 		setLoading(true);
 
 		try {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleUpgrade = async () => {
setLoading(true);
try {
const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});
// Redirect to Stripe Checkout
window.location.href = checkoutUrl;
} catch (error) {
toast({
title: "Upgrade Failed",
description: `Failed to create checkout session. Please try again. Error: ${error}`,
variant: "destructive",
});
setLoading(false);
}
};
const handleUpgrade = async () => {
if (loading || createSubscriptionMutation.isPending) {
return;
}
setLoading(true);
try {
const { checkoutUrl } = await createSubscriptionMutation.mutateAsync({});
// Redirect to Stripe Checkout
window.location.href = checkoutUrl;
} catch (error) {
toast({
title: "Upgrade Failed",
description: `Failed to create checkout session. Please try again. Error: ${error}`,
variant: "destructive",
});
setLoading(false);
}
};
🤖 Prompt for AI Agents
In apps/ui/src/components/shared/upgrade-to-pro-dialog.tsx around lines 51 to
67, the handleUpgrade function lacks a guard against multiple concurrent
mutation calls, which can occur if the button is clicked rapidly. Add a check at
the start of handleUpgrade to return early if loading is already true,
preventing multiple simultaneous executions of the mutation and avoiding race
conditions.


return (
<>
<DialogHeader>
<DialogTitle>Upgrade to Pro</DialogTitle>
<DialogDescription>
Unlock provider keys and get full access to all features for
$50/month.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium">What you'll get:</h4>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
Use your own OpenAI, Anthropic, and other provider API keys
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
Hybrid mode: fallback to credits when needed
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
No surcharges or fees for API keys or credits usage
</li>
<li className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
All existing features (credits, analytics, etc.)
</li>
</ul>
</div>
</div>
<DialogFooter>
<div className="flex flex-col gap-2 items-end">
<Button
onClick={handleUpgrade}
disabled={loading || createSubscriptionMutation.isPending}
>
{loading || createSubscriptionMutation.isPending
? "Redirecting to checkout..."
: "Upgrade for $50/month now"}
</Button>
<div className="text-sm text-muted-foreground">
<p>You'll be redirected to Stripe Checkout to complete payment.</p>
</div>
</div>
</DialogFooter>
</>
);
}