billing popup

This commit is contained in:
Adam Cohen Hillel 2025-04-21 16:36:40 +01:00
parent 838070519a
commit 8751a1a716
4 changed files with 308 additions and 132 deletions

View File

@ -11,6 +11,9 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useBillingError } from "@/hooks/useBillingError";
import { BillingErrorAlert } from "@/components/billing/BillingErrorAlert";
import { useAccounts } from "@/hooks/use-accounts";
// Constant for localStorage key to ensure consistency
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
@ -19,9 +22,12 @@ function DashboardContent() {
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [autoSubmit, setAutoSubmit] = useState(false);
const { billingError, handleBillingError, clearBillingError } = useBillingError();
const router = useRouter();
const isMobile = useIsMobile();
const { setOpenMobile } = useSidebar();
const { data: accounts } = useAccounts();
const personalAccount = accounts?.find(account => account.personal_account);
const handleSubmit = async (message: string, options?: { model_name?: string; enable_thinking?: boolean }) => {
if (!message.trim() || isSubmitting) return;
@ -44,18 +50,102 @@ function DashboardContent() {
// 3. Add the user message to the thread
await addUserMessage(thread.thread_id, message.trim());
// 4. Start the agent with the thread ID
const agentRun = await startAgent(thread.thread_id, {
model_name: options?.model_name,
enable_thinking: options?.enable_thinking,
stream: true
});
// If successful, clear the pending prompt
localStorage.removeItem(PENDING_PROMPT_KEY);
// 5. Navigate to the new agent's thread page
router.push(`/agents/${thread.thread_id}`);
try {
// 4. Start the agent with the thread ID
const agentRun = await startAgent(thread.thread_id, {
model_name: options?.model_name,
enable_thinking: options?.enable_thinking,
stream: true
});
// If successful, clear the pending prompt
localStorage.removeItem(PENDING_PROMPT_KEY);
// 5. Navigate to the new agent's thread page
router.push(`/agents/${thread.thread_id}`);
} catch (error: any) {
// Check specifically for billing errors (402 Payment Required)
if (error.message?.includes('(402)') || error?.status === 402) {
console.log("Billing error detected:", error);
// Try to extract the error details from the error object
try {
// Try to parse the error.response or the error itself
let errorDetails;
// First attempt: check if error.data exists and has a detail property
if (error.data?.detail) {
errorDetails = error.data.detail;
console.log("Extracted billing error details from error.data.detail:", errorDetails);
}
// Second attempt: check if error.detail exists directly
else if (error.detail) {
errorDetails = error.detail;
console.log("Extracted billing error details from error.detail:", errorDetails);
}
// Third attempt: try to parse the error text if it's JSON
else if (typeof error.text === 'function') {
const text = await error.text();
console.log("Extracted error text:", text);
try {
const parsed = JSON.parse(text);
errorDetails = parsed.detail || parsed;
console.log("Parsed error text as JSON:", errorDetails);
} catch (e) {
// Not JSON, use regex to extract info
console.log("Error text is not valid JSON");
}
}
// If we still don't have details, try to extract from the error message
if (!errorDetails && error.message) {
const match = error.message.match(/Monthly limit of (\d+) minutes reached/);
if (match) {
const minutes = parseInt(match[1]);
errorDetails = {
message: error.message,
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier by default
plan_name: "Free",
current_usage: minutes / 60, // Convert to hours
limit: minutes / 60 // Convert to hours
}
};
console.log("Extracted billing error details from error message:", errorDetails);
}
}
// Handle the billing error with the details we extracted
if (errorDetails) {
console.log("Handling billing error with extracted details:", errorDetails);
handleBillingError(errorDetails);
} else {
// Fallback with generic billing error
console.log("Using fallback generic billing error");
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan.",
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier
plan_name: "Free"
}
});
}
} catch (parseError) {
console.error("Error parsing billing error details:", parseError);
// Fallback with generic error
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan."
});
}
// Don't rethrow - we've handled this error with the billing alert
setIsSubmitting(false);
return; // Exit handleSubmit
}
// Rethrow any non-billing errors
throw error;
}
} catch (error) {
console.error("Error creating agent:", error);
setIsSubmitting(false);
@ -125,6 +215,16 @@ function DashboardContent() {
hideAttachments={true}
/>
</div>
{/* Billing Error Alert */}
<BillingErrorAlert
message={billingError?.message}
currentUsage={billingError?.currentUsage || billingError?.subscription?.current_usage}
limit={billingError?.limit || billingError?.subscription?.limit}
accountId={personalAccount?.account_id}
onDismiss={clearBillingError}
isOpen={!!billingError}
/>
</div>
);
}

View File

@ -38,7 +38,7 @@ export function BillingErrorAlert({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9999] flex items-center justify-center"
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto py-4"
>
{/* Backdrop */}
<motion.div
@ -58,52 +58,52 @@ export function BillingErrorAlert({
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"relative bg-background rounded-xl shadow-2xl w-full max-w-3xl mx-4",
"relative bg-background rounded-lg shadow-xl w-full max-w-sm mx-3",
className
)}
role="dialog"
aria-modal="true"
aria-labelledby="billing-modal-title"
>
<div className="p-6">
<div className="p-4">
{/* Close button */}
{onDismiss && (
<button
onClick={onDismiss}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground transition-colors"
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close dialog"
>
<X className="h-5 w-5" />
<X className="h-4 w-4" />
</button>
)}
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center p-2 bg-destructive/10 rounded-full mb-4">
<AlertCircle className="h-6 w-6 text-destructive" />
<div className="text-center mb-4">
<div className="inline-flex items-center justify-center p-1.5 bg-destructive/10 rounded-full mb-2">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
<h2 id="billing-modal-title" className="text-2xl font-medium tracking-tight mb-2">
<h2 id="billing-modal-title" className="text-lg font-medium tracking-tight mb-1">
Usage Limit Reached
</h2>
<p className="text-muted-foreground">
<p className="text-xs text-muted-foreground">
{message || "You've reached your monthly usage limit."}
</p>
</div>
{/* Usage Stats */}
{currentUsage !== undefined && limit !== undefined && (
<div className="mb-8 p-6 bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border rounded-xl">
<div className="flex justify-between items-center mb-4">
<div className="mb-4 p-3 bg-muted/30 border border-border rounded-lg">
<div className="flex justify-between items-center mb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Current Usage</p>
<p className="text-2xl font-semibold">{currentUsage} hours</p>
<p className="text-xs font-medium text-muted-foreground">Usage</p>
<p className="text-base font-semibold">{currentUsage}h</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-muted-foreground">Monthly Limit</p>
<p className="text-2xl font-semibold">{limit} hours</p>
<p className="text-xs font-medium text-muted-foreground">Limit</p>
<p className="text-base font-semibold">{limit}h</p>
</div>
</div>
<div className="w-full h-2 bg-background rounded-full overflow-hidden">
<div className="w-full h-1.5 bg-background rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.min((currentUsage / limit) * 100, 100)}%` }}
@ -118,14 +118,16 @@ export function BillingErrorAlert({
<PlanComparison
accountId={accountId}
returnUrl={returnUrl}
className="mb-6"
className="mb-3"
isCompact={true}
/>
{/* Dismiss Button */}
{onDismiss && (
<Button
variant="ghost"
className="w-full text-muted-foreground hover:text-foreground"
size="sm"
className="w-full text-muted-foreground hover:text-foreground text-xs h-7"
onClick={onDismiss}
>
Continue with Current Plan

View File

@ -22,14 +22,15 @@ interface PlanComparisonProps {
isManaged?: boolean;
onPlanSelect?: (planId: string) => void;
className?: string;
isCompact?: boolean; // When true, uses vertical stacked layout for modals
}
// Price display animation component
const PriceDisplay = ({ tier }: { tier: typeof siteConfig.cloudPricingItems[number] }) => {
const PriceDisplay = ({ tier, isCompact }: { tier: typeof siteConfig.cloudPricingItems[number]; isCompact?: boolean }) => {
return (
<motion.span
key={tier.price}
className="text-4xl font-semibold"
className={isCompact ? "text-xl font-semibold" : "text-3xl font-semibold"}
initial={{
opacity: 0,
x: 10,
@ -48,7 +49,8 @@ export function PlanComparison({
returnUrl = typeof window !== 'undefined' ? window.location.href : '',
isManaged = true,
onPlanSelect,
className = ""
className = "",
isCompact = false
}: PlanComparisonProps) {
const [currentPlanId, setCurrentPlanId] = useState<string | undefined>();
@ -74,7 +76,15 @@ export function PlanComparison({
}, [accountId]);
return (
<div className={cn("grid min-[650px]:grid-cols-2 min-[900px]:grid-cols-3 gap-4 w-full max-w-6xl mx-auto", className)}>
<div
className={cn(
"grid gap-3 w-full mx-auto",
isCompact
? "grid-cols-1 max-w-md"
: "grid-cols-1 md:grid-cols-3 max-w-6xl",
className
)}
>
{siteConfig.cloudPricingItems.map((tier) => {
const isCurrentPlan = currentPlanId === SUBSCRIPTION_PLANS[tier.name.toUpperCase() as keyof typeof SUBSCRIPTION_PLANS];
@ -82,108 +92,157 @@ export function PlanComparison({
<div
key={tier.name}
className={cn(
"rounded-xl bg-background border border-border p-6 flex flex-col gap-6",
isCurrentPlan && "ring-2 ring-primary"
"rounded-lg bg-background border border-border",
isCompact ? "p-3 text-sm" : "p-5",
isCurrentPlan && (isCompact ? "ring-1 ring-primary" : "ring-2 ring-primary")
)}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium">{tier.name}</h3>
{tier.isPopular && (
<span className="bg-primary text-primary-foreground text-xs font-medium px-2 py-0.5 rounded-full">
Popular
</span>
)}
{isCurrentPlan && (
<span className="bg-secondary text-secondary-foreground text-xs font-medium px-2 py-0.5 rounded-full">
Current Plan
</span>
)}
</div>
<div className="flex items-baseline">
<PriceDisplay tier={tier} />
<span className="text-muted-foreground ml-2">
{tier.price !== "$0" ? "/month" : ""}
</span>
</div>
<p className="text-muted-foreground">{tier.description}</p>
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium bg-secondary/10 text-secondary">
{tier.hours}/month
</div>
</div>
{!isCurrentPlan && accountId && (
<form className="mt-2">
<input type="hidden" name="accountId" value={accountId} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={SUBSCRIPTION_PLANS[tier.name.toUpperCase() as keyof typeof SUBSCRIPTION_PLANS]} />
{isManaged ? (
<SubmitButton
pendingText="Loading..."
formAction={setupNewSubscription}
className={cn(
"w-full h-10 rounded-full font-medium transition-colors",
tier.buttonColor
)}
>
{tier.buttonText}
</SubmitButton>
) : (
<Button
className={cn(
"w-full h-10 rounded-full font-medium transition-colors",
tier.buttonColor
)}
onClick={() => onPlanSelect?.(SUBSCRIPTION_PLANS[tier.name.toUpperCase() as keyof typeof SUBSCRIPTION_PLANS])}
>
{tier.buttonText}
</Button>
)}
</form>
)}
<div className="space-y-4">
{tier.name !== "Free" && (
<p className="text-sm text-muted-foreground">
Everything in {tier.name === "Basic" ? "Free" : "Basic"} +
</p>
)}
<ul className="space-y-3">
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-muted-foreground">
<div className="size-5 rounded-full bg-primary/10 flex items-center justify-center">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-primary"
>
<path
d="M2.5 6L5 8.5L9.5 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{isCompact ? (
// Compact layout for modal
<>
<div className="flex justify-between mb-2">
<div>
<div className="flex items-center gap-1">
<h3 className="font-medium">{tier.name}</h3>
{tier.isPopular && (
<span className="bg-primary/10 text-primary text-[10px] font-medium px-1.5 py-0.5 rounded-full">
Popular
</span>
)}
{isCurrentPlan && (
<span className="bg-secondary/10 text-secondary text-[10px] font-medium px-1.5 py-0.5 rounded-full">
Current
</span>
)}
</div>
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
{(tier as any).showContactSales && (
<Button
variant="outline"
className="w-full h-10 rounded-full font-medium transition-colors mt-4"
onClick={() => window.open('mailto:support@kortix.ai?subject=Enterprise Plan Inquiry', '_blank')}
<div className="text-xs text-muted-foreground mt-0.5">{tier.description}</div>
</div>
<div className="text-right">
<div className="flex items-baseline">
<PriceDisplay tier={tier} isCompact={true} />
<span className="text-xs text-muted-foreground ml-1">
{tier.price !== "$0" ? "/mo" : ""}
</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">
{tier.hours}/month
</div>
</div>
</div>
<div className="mb-2.5">
<div className="text-[10px] text-muted-foreground leading-tight max-h-[40px] overflow-y-auto pr-1">
{tier.features.map((feature, index) => (
<span key={index} className="whitespace-normal">
{index > 0 && ' • '}
{feature}
</span>
))}
</div>
</div>
</>
) : (
// Standard layout for normal view
<>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">{tier.name}</h3>
<div className="flex gap-1">
{tier.isPopular && (
<span className="bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full">
Popular
</span>
)}
{isCurrentPlan && (
<span className="bg-secondary/10 text-secondary text-xs font-medium px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
</div>
<div className="flex items-baseline mb-1">
<PriceDisplay tier={tier} />
<span className="text-muted-foreground ml-2">
{tier.price !== "$0" ? "/month" : ""}
</span>
</div>
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium bg-secondary/10 text-secondary mb-4">
{tier.hours}/month
</div>
<p className="text-muted-foreground mb-6">{tier.description}</p>
<div className="mb-6">
<div className="text-sm text-muted-foreground space-y-2">
{tier.features.map((feature, index) => (
<div key={index} className="flex items-start gap-2">
<div className="size-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-primary"
>
<path
d="M2.5 6L5 8.5L9.5 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<span>{feature}</span>
</div>
))}
</div>
</div>
</>
)}
<form>
<input type="hidden" name="accountId" value={accountId} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={SUBSCRIPTION_PLANS[tier.name.toUpperCase() as keyof typeof SUBSCRIPTION_PLANS]} />
{isManaged ? (
<SubmitButton
pendingText="..."
formAction={setupNewSubscription}
// disabled={isCurrentPlan}
className={cn(
"w-full font-medium transition-colors",
isCompact
? "h-7 rounded-md text-xs"
: "h-10 rounded-full text-sm",
isCurrentPlan
? "bg-muted text-muted-foreground hover:bg-muted"
: tier.buttonColor
)}
>
Need more? Contact Sales
{isCurrentPlan ? "Current Plan" : (tier.name === "Free" ? tier.buttonText : "Upgrade")}
</SubmitButton>
) : (
<Button
className={cn(
"w-full font-medium transition-colors",
isCompact
? "h-7 rounded-md text-xs"
: "h-10 rounded-full text-sm",
isCurrentPlan
? "bg-muted text-muted-foreground hover:bg-muted"
: tier.buttonColor
)}
disabled={isCurrentPlan}
onClick={() => onPlanSelect?.(SUBSCRIPTION_PLANS[tier.name.toUpperCase() as keyof typeof SUBSCRIPTION_PLANS])}
>
{isCurrentPlan ? "Current Plan" : (tier.name === "Free" ? tier.buttonText : "Upgrade")}
</Button>
)}
</div>
</form>
</div>
);
})}

View File

@ -6,6 +6,7 @@ interface BillingErrorState {
limit?: number;
subscription?: {
price_id?: string;
plan_name?: string;
current_usage?: number;
limit?: number;
};
@ -15,8 +16,20 @@ export function useBillingError() {
const [billingError, setBillingError] = useState<BillingErrorState | null>(null);
const handleBillingError = useCallback((error: any) => {
// Check if it's a billing error (402 status or message contains 'Payment Required')
// Case 1: Error is already a formatted billing error detail object
if (error && (error.message || error.subscription)) {
setBillingError({
message: error.message || "You've reached your monthly usage limit.",
currentUsage: error.currentUsage || error.subscription?.current_usage,
limit: error.limit || error.subscription?.limit,
subscription: error.subscription || {}
});
return true;
}
// Case 2: Error is an HTTP error response
if (error.status === 402 || (error.message && error.message.includes('Payment Required'))) {
// Try to get details from error.data.detail (common API pattern)
const errorDetail = error.data?.detail || {};
const subscription = errorDetail.subscription || {};
@ -28,6 +41,8 @@ export function useBillingError() {
});
return true;
}
// Not a billing error
return false;
}, []);