config, alerts

This commit is contained in:
marko-kraemer 2025-04-24 05:30:08 +01:00
parent 7325559d26
commit 7e72fa4f3f
13 changed files with 653 additions and 323 deletions

View File

@ -7,8 +7,8 @@ import { usePathname } from "next/navigation";
export default function PersonalAccountSettingsPage({children}: {children: React.ReactNode}) {
const pathname = usePathname();
const items = [
{ name: "Profile", href: "/settings" },
{ name: "Teams", href: "/settings/teams" },
// { name: "Profile", href: "/settings" },
// { name: "Teams", href: "/settings/teams" },
{ name: "Billing", href: "/settings/billing" },
]
return (

View File

@ -19,9 +19,10 @@ import { useAgentStream } from '@/hooks/useAgentStream';
import { Markdown } from '@/components/ui/markdown';
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import { BillingErrorAlert } from '@/components/billing/BillingErrorAlert';
import { SUBSCRIPTION_PLANS } from '@/components/billing/PlanComparison';
import { BillingErrorAlert } from '@/components/billing/usage-limit-alert';
import { SUBSCRIPTION_PLANS } from '@/components/billing/plan-comparison';
import { createClient } from '@/lib/supabase/client';
import { isLocalMode } from "@/lib/config";
import { UnifiedMessage, ParsedContent, ParsedMetadata, ThreadParams } from '@/components/thread/types';
import { getToolIcon, extractPrimaryParam, safeJsonParse } from '@/components/thread/utils';
@ -1069,6 +1070,12 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
// Check billing status when agent completes
const checkBillingStatus = useCallback(async () => {
// Skip billing checks in local development mode
if (isLocalMode()) {
console.log("Running in local development mode - billing checks are disabled");
return false;
}
if (!project?.account_id) return;
const supabase = createClient();

View File

@ -5,14 +5,16 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useRouter } from 'next/navigation';
import { Menu } from "lucide-react";
import { ChatInput, ChatInputHandles } from '@/components/thread/chat-input';
import { initiateAgent } from "@/lib/api";
import { initiateAgent, createThread, addUserMessage, startAgent } from "@/lib/api";
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 { BillingErrorAlert } from "@/components/billing/usage-limit-alert";
import { useAccounts } from "@/hooks/use-accounts";
import { isLocalMode } from "@/lib/config";
import { toast } from "sonner";
// Constant for localStorage key to ensure consistency
const PENDING_PROMPT_KEY = 'pendingAgentPrompt';
@ -35,113 +37,139 @@ function DashboardContent() {
setIsSubmitting(true);
try {
// Get any pending files
const pendingFiles = chatInputRef.current?.getPendingFiles() || [];
// Check if any files are attached
const files = chatInputRef.current?.getPendingFiles() || [];
// Create FormData for the request
const formData = new FormData();
formData.append('prompt', message.trim());
// Add model options
formData.append('model_name', options?.model_name || 'anthropic/claude-3-7-sonnet-latest');
formData.append('enable_thinking', options?.enable_thinking ? 'true' : 'false');
// Add files if any
pendingFiles.forEach(file => {
formData.append('files', file, file.name);
});
// Single API call to initialize everything
const response = await initiateAgent(formData);
// Clear pendingFiles
chatInputRef.current?.clearPendingFiles();
// Clear pending prompt from localStorage
// Clear localStorage if this is a successful submission
localStorage.removeItem(PENDING_PROMPT_KEY);
// Navigate to the new thread
router.push(`/agents/${response.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);
if (files.length > 0) {
// Create a FormData instance
const formData = new FormData();
// 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."
});
// Append the message
formData.append('message', message);
// Append all files
files.forEach(file => {
formData.append('files', file);
});
// Add any additional options
if (options) {
formData.append('options', JSON.stringify(options));
}
// Don't rethrow - we've handled this error with the billing alert
setIsSubmitting(false);
return; // Exit handleSubmit
// Call initiateAgent API
const result = await initiateAgent(formData);
console.log('Agent initiated:', result);
// Navigate to the thread
if (result.thread_id) {
router.push(`/agents/${result.thread_id}`);
}
} else {
// For text-only messages, first create a thread
const thread = await createThread("");
// Then add the user message
await addUserMessage(thread.thread_id, message);
// Start the agent on this thread with the options
await startAgent(thread.thread_id, options);
// Navigate to thread
router.push(`/agents/${thread.thread_id}`);
}
} catch (error: any) {
console.error('Error creating thread or initiating agent:', error);
// Skip billing error checks in local development mode
if (isLocalMode()) {
console.log("Running in local development mode - billing checks are disabled");
} else {
// 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
}
}
// Handle other errors or rethrow
toast.error(error.message || "An error occurred");
console.error("Error creating agent:", error);
setIsSubmitting(false);

View File

@ -6,6 +6,7 @@ import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { PricingAlert } from "@/components/billing/pricing-alert"
import { MaintenanceAlert } from "@/components/maintenance-alert"
import { useAccounts } from "@/hooks/use-accounts"
@ -16,13 +17,14 @@ interface DashboardLayoutProps {
export default function DashboardLayout({
children,
}: DashboardLayoutProps) {
const [showPricingAlert, setShowPricingAlert] = useState(false)
const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false)
const { data: accounts } = useAccounts()
const personalAccount = accounts?.find(account => account.personal_account)
useEffect(() => {
// Show the maintenance alert when component mounts
setShowMaintenanceAlert(true)
setShowPricingAlert(true)
setShowMaintenanceAlert(false)
}, [])
return (
@ -34,12 +36,18 @@ export default function DashboardLayout({
</div>
</SidebarInset>
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
<PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={true}
accountId={personalAccount?.account_id}
/>
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
closeable={true}
/>
</SidebarProvider>
)
}

View File

@ -1,7 +1,8 @@
import { createClient } from "@/lib/supabase/server";
import { SubmitButton } from "../ui/submit-button";
import { manageSubscription } from "@/lib/actions/billing";
import { PlanComparison, SUBSCRIPTION_PLANS } from "../billing/PlanComparison";
import { PlanComparison, SUBSCRIPTION_PLANS } from "../billing/plan-comparison";
import { isLocalMode } from "@/lib/config";
type Props = {
accountId: string;
@ -9,87 +10,141 @@ type Props = {
}
export default async function AccountBillingStatus({ accountId, returnUrl }: Props) {
// In local development mode, show a simplified component
if (isLocalMode()) {
return (
<div className="rounded-xl border shadow-sm bg-card p-6">
<h2 className="text-xl font-semibold mb-4">Billing Status</h2>
<div className="p-4 mb-4 bg-muted/30 border border-border rounded-lg text-center">
<p className="text-sm text-muted-foreground">
Running in local development mode - billing features are disabled
</p>
<p className="text-xs text-muted-foreground mt-2">
Agent usage limits are not enforced in this environment
</p>
</div>
</div>
);
}
const supabaseClient = await createClient();
const { data: billingData, error: billingError } = await supabaseClient.functions.invoke('billing-functions', {
body: {
action: "get_billing_status",
args: {
account_id: accountId
}
}
});
// Get current subscription details
// Get account subscription and usage data
const { data: subscriptionData } = await supabaseClient
.schema('basejump')
.from('billing_subscriptions')
.select('price_id')
.select('*')
.eq('account_id', accountId)
.eq('status', 'active')
.limit(1)
.order('created_at', { ascending: false })
.single();
const currentPlanId = subscriptionData?.price_id;
// Get agent run hours for current month
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
// First get threads for this account
const { data: threadsData } = await supabaseClient
// Get agent runs for this account
// Get the account's threads
const { data: threads } = await supabaseClient
.from('threads')
.select('thread_id')
.eq('account_id', accountId);
const threadIds = threadsData?.map(t => t.thread_id) || [];
// Then get agent runs for those threads
const { data: agentRunData, error: agentRunError } = await supabaseClient
.from('agent_runs')
.select('started_at, completed_at')
.in('thread_id', threadIds)
.gte('started_at', startOfMonth.toISOString());
let totalSeconds = 0;
if (agentRunData) {
totalSeconds = agentRunData.reduce((acc, run) => {
const start = new Date(run.started_at);
const end = run.completed_at ? new Date(run.completed_at) : new Date();
const seconds = (end.getTime() - start.getTime()) / 1000;
return acc + seconds;
}, 0);
const threadIds = threads?.map(t => t.thread_id) || [];
// Get current month usage
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const isoStartOfMonth = startOfMonth.toISOString();
let totalAgentTime = 0;
let usageDisplay = "No usage this month";
if (threadIds.length > 0) {
const { data: agentRuns } = await supabaseClient
.from('agent_runs')
.select('started_at, completed_at')
.in('thread_id', threadIds)
.gte('started_at', isoStartOfMonth);
if (agentRuns && agentRuns.length > 0) {
const nowTimestamp = now.getTime();
totalAgentTime = agentRuns.reduce((total, run) => {
const startTime = new Date(run.started_at).getTime();
const endTime = run.completed_at
? new Date(run.completed_at).getTime()
: nowTimestamp;
return total + (endTime - startTime) / 1000; // In seconds
}, 0);
// Convert to minutes
const totalMinutes = Math.round(totalAgentTime / 60);
usageDisplay = `${totalMinutes} minutes`;
}
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const usageDisplay = `${hours}h ${minutes}m ${seconds}s`;
const isPlan = (planId?: string) => {
return subscriptionData?.price_id === planId;
};
const planName = isPlan(SUBSCRIPTION_PLANS.FREE)
? "Free"
: isPlan(SUBSCRIPTION_PLANS.PRO)
? "Pro"
: isPlan(SUBSCRIPTION_PLANS.ENTERPRISE)
? "Enterprise"
: "Unknown";
return (
<div className="space-y-6">
{!Boolean(billingData?.billing_enabled) ? (
<div className="rounded-xl bg-destructive/10 border border-destructive p-6">
<h3 className="text-lg font-medium text-destructive mb-2">Billing Not Enabled</h3>
<p className="text-sm text-destructive/80">
Billing is not enabled for this account. Check out usebasejump.com for more info or remove this component if you don't plan on enabling billing.
</p>
</div>
<div className="rounded-xl border shadow-sm bg-card p-6">
<h2 className="text-xl font-semibold mb-4">Billing Status</h2>
{subscriptionData ? (
<>
<div className="mb-6">
<div className="rounded-lg border bg-background p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">Current Plan</span>
<span className="text-sm font-medium text-card-title">{planName}</span>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">Agent Usage This Month</span>
<span className="text-sm font-medium text-card-title">{usageDisplay}</span>
</div>
</div>
</div>
{/* Plans Comparison */}
<PlanComparison
accountId={accountId}
returnUrl={returnUrl}
className="mb-6"
/>
{/* Manage Subscription Button */}
<form>
<input type="hidden" name="accountId" value={accountId} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<SubmitButton
pendingText="Loading..."
formAction={manageSubscription}
className="w-full bg-primary text-white hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
>
Manage Subscription
</SubmitButton>
</form>
</>
) : (
<>
<div className="rounded-xl bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border p-6">
<div className="flex flex-col gap-4">
<div className="mb-6">
<div className="rounded-lg border bg-background p-4 gap-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">Status</span>
<span className="text-sm font-medium text-card-title">
{(!currentPlanId || currentPlanId === SUBSCRIPTION_PLANS.FREE) ? 'Active (Free)' : billingData.status === 'active' ? 'Active' : 'Inactive'}
</span>
<span className="text-sm font-medium text-foreground/90">Current Plan</span>
<span className="text-sm font-medium text-card-title">Free</span>
</div>
{billingData.plan_name && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">Plan</span>
<span className="text-sm font-medium text-card-title">{billingData.plan_name}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">Agent Usage This Month</span>
<span className="text-sm font-medium text-card-title">{usageDisplay}</span>

View File

@ -57,9 +57,9 @@ export default function ClientUserAccountButton({
<DropdownMenuItem asChild className="rounded-md hover:bg-hover-bg cursor-pointer">
<Link href="/dashboard" className="flex w-full h-full text-foreground/90">My Account</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="rounded-md hover:bg-hover-bg cursor-pointer">
{/* <DropdownMenuItem asChild className="rounded-md hover:bg-hover-bg cursor-pointer">
<Link href="/settings" className="flex w-full h-full text-foreground/90">Settings</Link>
</DropdownMenuItem>
</DropdownMenuItem> */}
<DropdownMenuItem asChild className="rounded-md hover:bg-hover-bg cursor-pointer">
<Link href="/settings/teams" className="flex w-full h-full text-foreground/90">Teams</Link>
</DropdownMenuItem>

View File

@ -8,6 +8,7 @@ import { setupNewSubscription } from "@/lib/actions/billing";
import { SubmitButton } from "@/components/ui/submit-button";
import { Button } from "@/components/ui/button";
import { siteConfig } from "@/lib/home";
import { isLocalMode } from "@/lib/config";
// Create SUBSCRIPTION_PLANS using stripePriceId from siteConfig
export const SUBSCRIPTION_PLANS = {
@ -16,15 +17,6 @@ export const SUBSCRIPTION_PLANS = {
ENTERPRISE: siteConfig.cloudPricingItems.find(item => item.name === 'Enterprise')?.stripePriceId || '',
};
interface PlanComparisonProps {
accountId?: string | null;
returnUrl?: string;
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, isCompact }: { tier: typeof siteConfig.cloudPricingItems[number]; isCompact?: boolean }) => {
return (
@ -44,6 +36,15 @@ const PriceDisplay = ({ tier, isCompact }: { tier: typeof siteConfig.cloudPricin
);
};
interface PlanComparisonProps {
accountId?: string | null;
returnUrl?: string;
isManaged?: boolean;
onPlanSelect?: (planId: string) => void;
className?: string;
isCompact?: boolean; // When true, uses vertical stacked layout for modals
}
export function PlanComparison({
accountId,
returnUrl = typeof window !== 'undefined' ? window.location.href : '',
@ -75,6 +76,17 @@ export function PlanComparison({
fetchCurrentPlan();
}, [accountId]);
// For local development mode, show a message instead
if (isLocalMode()) {
return (
<div className={cn("p-4 bg-muted/30 border border-border rounded-lg text-center", className)}>
<p className="text-sm text-muted-foreground">
Running in local development mode - billing features are disabled
</p>
</div>
);
}
return (
<div
className={cn(

View File

@ -0,0 +1,211 @@
"use client"
import { X, Zap, Github, Check } from "lucide-react"
import Link from "next/link"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Portal } from "@/components/ui/portal"
import { cn } from "@/lib/utils"
import { setupNewSubscription } from "@/lib/actions/billing"
import { SubmitButton } from "@/components/ui/submit-button"
import { siteConfig } from "@/lib/home"
import { isLocalMode } from "@/lib/config"
interface PricingAlertProps {
open: boolean
onOpenChange: (open: boolean) => void
closeable?: boolean
accountId?: string | null | undefined
}
export function PricingAlert({ open, onOpenChange, closeable = true, accountId }: PricingAlertProps) {
const returnUrl = typeof window !== 'undefined' ? window.location.href : '';
// Skip rendering in local development mode
if (isLocalMode() || !open) return null;
// Filter plans to show only Pro and Enterprise
const premiumPlans = siteConfig.cloudPricingItems.filter(plan =>
plan.name === 'Pro' || plan.name === 'Enterprise'
);
return (
<Portal>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto py-8 px-4"
>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={closeable ? () => onOpenChange(false) : undefined}
aria-hidden="true"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
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-3 border border-border"
)}
role="dialog"
aria-modal="true"
aria-labelledby="pricing-modal-title"
>
<div className="p-6">
{/* Close button */}
{closeable && (
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close dialog"
>
<X className="h-5 w-5" />
</button>
)}
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center p-2 bg-primary/10 rounded-full mb-3">
<Zap className="h-5 w-5 text-primary" />
</div>
<h2 id="pricing-modal-title" className="text-2xl font-medium tracking-tight mb-2">
Choose Your Suna Experience
</h2>
<p className="text-muted-foreground max-w-lg mx-auto">
Due to overwhelming demand and AI costs, we're currently focusing on delivering
our best experience to dedicated users. Select your preferred option below.
</p>
</div>
{/* Plan comparison - 3 column layout */}
<div className="grid md:grid-cols-3 gap-4 mb-6">
{/* Self-Host Option */}
<div className="rounded-xl bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border hover:border-muted-foreground/30 transition-all duration-300">
<div className="flex flex-col gap-4 p-4">
<p className="text-sm flex items-center">Open Source</p>
<div className="flex items-baseline mt-2">
<span className="text-2xl font-semibold">Self-host</span>
</div>
<p className="text-sm mt-2">Full control with your own infrastructure</p>
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary/10 border-primary/20 text-primary w-fit">
hours / month
</div>
</div>
<div className="px-4 pb-4">
<div className="flex items-start gap-2 mb-3">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-muted-foreground">No usage limitations</span>
</div>
<Link
href="https://github.com/kortix-ai/suna"
target="_blank"
rel="noopener noreferrer"
className="h-10 w-full flex items-center justify-center gap-2 text-sm font-normal tracking-wide rounded-full px-4 cursor-pointer transition-all ease-out active:scale-95 bg-secondary/10 text-secondary shadow-[0px_1px_2px_0px_rgba(255,255,255,0.16)_inset,0px_3px_3px_-1.5px_rgba(16,24,40,0.24),0px_1px_1px_-0.5px_rgba(16,24,40,0.20)]"
>
<Github className="h-4 w-4" />
<span>View on GitHub</span>
</Link>
</div>
</div>
{/* Pro Plan */}
<div className="rounded-xl md:shadow-[0px_61px_24px_-10px_rgba(0,0,0,0.01),0px_34px_20px_-8px_rgba(0,0,0,0.05),0px_15px_15px_-6px_rgba(0,0,0,0.09),0px_4px_8px_-2px_rgba(0,0,0,0.10),0px_0px_0px_1px_rgba(0,0,0,0.08)] bg-accent relative transform hover:scale-105 transition-all duration-300">
<div className="absolute -top-3 -right-3">
<span className="bg-gradient-to-b from-secondary/50 from-[1.92%] to-secondary to-[100%] text-white h-6 inline-flex w-fit items-center justify-center px-3 rounded-full text-xs font-medium shadow-[0px_6px_6px_-3px_rgba(0,0,0,0.08),0px_3px_3px_-1.5px_rgba(0,0,0,0.08),0px_1px_1px_-0.5px_rgba(0,0,0,0.08),0px_0px_0px_1px_rgba(255,255,255,0.12)_inset,0px_1px_0px_0px_rgba(255,255,255,0.12)_inset]">
Most Popular
</span>
</div>
<div className="flex flex-col gap-4 p-4">
<p className="text-sm flex items-center font-medium">Pro</p>
<div className="flex items-baseline mt-2">
<span className="text-2xl font-semibold">{premiumPlans[0]?.price || "$19"}</span>
<span className="ml-2">/month</span>
</div>
<p className="text-sm mt-2">Supercharge your productivity with {premiumPlans[0]?.hours || "500 hours"} of Suna</p>
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary/10 border-primary/20 text-primary w-fit">
{premiumPlans[0]?.hours || "500 hours"}/month
</div>
</div>
<div className="px-4 pb-4">
<div className="flex items-start gap-2 mb-3">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-muted-foreground">Perfect for individuals and small teams</span>
</div>
<form>
<input type="hidden" name="accountId" value={accountId || ''} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={
premiumPlans[0]?.stripePriceId || ''
} />
<SubmitButton
pendingText="..."
formAction={setupNewSubscription}
className="h-10 w-full flex items-center justify-center text-sm font-medium tracking-wide rounded-full px-4 cursor-pointer transition-all ease-out active:scale-95 bg-primary text-primary-foreground shadow-[inset_0_1px_2px_rgba(255,255,255,0.25),0_3px_3px_-1.5px_rgba(16,24,40,0.06),0_1px_1px_rgba(16,24,40,0.08)]"
>
Get Started Now
</SubmitButton>
</form>
</div>
</div>
{/* Enterprise Plan */}
<div className="rounded-xl bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border hover:border-muted-foreground/30 transition-all duration-300">
<div className="flex flex-col gap-4 p-4">
<p className="text-sm flex items-center font-medium">Enterprise</p>
<div className="flex items-baseline mt-2">
<span className="text-2xl font-semibold">{premiumPlans[1]?.price || "$99"}</span>
<span className="ml-2">/month</span>
</div>
<p className="text-sm mt-2">Unlock boundless potential with {premiumPlans[1]?.hours || "2000 hours"} of Suna</p>
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary/10 border-primary/20 text-primary w-fit">
{premiumPlans[1]?.hours || "2000 hours"}/month
</div>
</div>
<div className="px-4 pb-4">
<div className="flex items-start gap-2 mb-3">
<Check className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-muted-foreground">Ideal for larger organizations and power users</span>
</div>
<form>
<input type="hidden" name="accountId" value={accountId || ''} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={
premiumPlans[1]?.stripePriceId || ''
} />
<SubmitButton
pendingText="..."
formAction={setupNewSubscription}
className="h-10 w-full flex items-center justify-center text-sm font-normal tracking-wide rounded-full px-4 cursor-pointer transition-all ease-out active:scale-95 bg-gradient-to-b from-secondary/50 from-[1.92%] to-secondary to-[100%] text-white shadow-[0px_1px_2px_0px_rgba(255,255,255,0.16)_inset,0px_3px_3px_-1.5px_rgba(16,24,40,0.24),0px_1px_1px_-0.5px_rgba(16,24,40,0.20)]"
>
Upgrade to Enterprise
</SubmitButton>
</form>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
</Portal>
)
}

View File

@ -1,9 +1,10 @@
import { AlertCircle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Portal } from "@/components/ui/portal";
import { PlanComparison } from "./PlanComparison";
import { PlanComparison } from "./plan-comparison";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import { isLocalMode } from "@/lib/config";
interface BillingErrorAlertProps {
message?: string;
@ -25,8 +26,9 @@ export function BillingErrorAlert({
isOpen
}: BillingErrorAlertProps) {
const returnUrl = typeof window !== 'undefined' ? window.location.href : '';
if (!isOpen) return null;
// Skip rendering in local development mode
if (isLocalMode() || !isOpen) return null;
return (
<Portal>

View File

@ -1,149 +1,94 @@
"use client"
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { AlertCircle, X, Zap, Github } from "lucide-react"
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Clock, Github, X } from "lucide-react"
import Link from "next/link"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Portal } from "@/components/ui/portal"
import { cn } from "@/lib/utils"
import { setupNewSubscription } from "@/lib/actions/billing"
import { SubmitButton } from "@/components/ui/submit-button"
import { siteConfig } from "@/lib/home"
interface MaintenanceAlertProps {
open: boolean
onOpenChange: (open: boolean) => void
closeable?: boolean
accountId?: string | null | undefined
}
export function MaintenanceAlert({ open, onOpenChange, closeable = true, accountId }: MaintenanceAlertProps) {
const returnUrl = typeof window !== 'undefined' ? window.location.href : '';
if (!open) return null;
// Filter plans to show only Pro and Enterprise
const premiumPlans = siteConfig.cloudPricingItems.filter(plan =>
plan.name === 'Pro' || plan.name === 'Enterprise'
);
export function MaintenanceAlert({ open, onOpenChange, closeable = true }: MaintenanceAlertProps) {
return (
<Portal>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto py-4"
<AlertDialog open={open} onOpenChange={closeable ? onOpenChange : undefined}>
<AlertDialogContent className="max-w-2xl w-[90vw] p-0 border-0 shadow-lg overflow-hidden rounded-2xl z-[9999]">
<motion.div
className="relative"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
>
{/* Background pattern */}
<div className="absolute inset-0 bg-accent/20 opacity-20">
<div className="absolute inset-0 bg-grid-white/10 [mask-image:radial-gradient(white,transparent_85%)]" />
</div>
{closeable && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 z-20 rounded-full hover:bg-background/80"
onClick={() => onOpenChange(false)}
>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={closeable ? () => onOpenChange(false) : undefined}
aria-hidden="true"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
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-lg shadow-xl w-full max-w-md mx-3"
)}
role="dialog"
aria-modal="true"
aria-labelledby="free-tier-modal-title"
>
<div className="p-4">
{/* Close button */}
{closeable && (
<button
onClick={() => onOpenChange(false)}
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close dialog"
>
<X className="h-4 w-4" />
</button>
)}
{/* Header */}
<div className="text-center mb-4">
<div className="inline-flex items-center justify-center p-1.5 bg-primary/10 rounded-full mb-2">
<Zap className="h-4 w-4 text-primary" />
</div>
<h2 id="free-tier-modal-title" className="text-lg font-medium tracking-tight mb-1">
Free Tier Unavailable At This Time
</h2>
<p className="text-xs text-muted-foreground">
Due to extremely high demand, we cannot offer a free tier at the moment. Upgrade to Pro to continue using our service.
</p>
</div>
{/* Custom plan comparison wrapper to show Pro, Enterprise and Self-Host side by side */}
<div className="grid grid-cols-3 gap-2 mb-3">
{premiumPlans.map((tier) => (
<div key={tier.name} className="border border-border rounded-lg p-3">
<div className="text-center mb-2">
<h3 className="font-medium">{tier.name}</h3>
<p className="text-sm font-bold">{tier.price}/mo</p>
<p className="text-xs text-muted-foreground">{tier.hours}/month</p>
</div>
<form>
<input type="hidden" name="accountId" value={accountId || ''} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={
tier.name === 'Pro'
? siteConfig.cloudPricingItems.find(item => item.name === 'Pro')?.stripePriceId || ''
: siteConfig.cloudPricingItems.find(item => item.name === 'Enterprise')?.stripePriceId || ''
} />
<SubmitButton
pendingText="..."
formAction={setupNewSubscription}
className={cn(
"w-full font-medium transition-colors h-7 rounded-md text-xs",
tier.buttonColor
)}
>
Upgrade
</SubmitButton>
</form>
</div>
))}
{/* Self-host Option as the third card */}
<div className="border border-border rounded-lg p-3">
<div className="text-center mb-2">
<h3 className="font-medium">Self-Host</h3>
<p className="text-sm font-bold">Free</p>
<p className="text-xs text-muted-foreground">Open Source</p>
</div>
<Link
href="https://github.com/kortix-ai/suna"
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-1 h-7 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-md text-xs transition-all"
>
<Github className="h-3.5 w-3.5" />
<span>Self-Host</span>
</Link>
</div>
</div>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
)}
<AlertDialogHeader className="gap-6 px-8 pt-10 pb-6 relative z-10">
<motion.div
className="flex items-center justify-center"
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
<div className="flex size-16 items-center justify-center rounded-full bg-gradient-to-t from-primary/20 to-secondary/10 backdrop-blur-md">
<div className="flex size-12 items-center justify-center rounded-full bg-gradient-to-t from-primary to-primary/80 shadow-md">
<Clock className="h-6 w-6 text-white" />
</div>
</motion.div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</Portal>
<motion.div
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<AlertDialogTitle className="text-2xl font-bold text-center text-primary bg-clip-text">
High Demand Notice
</AlertDialogTitle>
</motion.div>
<motion.div
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
>
<AlertDialogDescription className="text-base text-center leading-relaxed">
Due to exceptionally high demand, our service is currently experiencing slower response times.
We recommend returning tomorrow when our systems will be operating at normal capacity.
<span className="mt-4 block font-medium text-primary">Thank you for your understanding. We will notify you via email once the service is fully operational again.</span>
</AlertDialogDescription>
</motion.div>
</AlertDialogHeader>
<AlertDialogFooter className="p-8 pt-4 border-t border-border/40 bg-background/40 backdrop-blur-sm">
<Link
href="https://github.com/kortix-ai/suna"
target="_blank"
rel="noopener noreferrer"
className="mx-auto w-full flex items-center justify-center gap-3 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-full px-8 py-3 transition-all hover:shadow-md"
>
<Github className="h-5 w-5 transition-transform group-hover:scale-110" />
<span>Explore Self-Hosted Version</span>
</Link>
</AlertDialogFooter>
</motion.div>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -273,12 +273,12 @@ export function NavUserWithTeams({
Billing
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
{/* <DropdownMenuItem asChild>
<Link href="/settings">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
<div className="flex items-center gap-2">
<Sun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />

View File

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { isLocalMode } from '@/lib/config';
interface BillingErrorState {
message: string;
@ -16,6 +17,12 @@ export function useBillingError() {
const [billingError, setBillingError] = useState<BillingErrorState | null>(null);
const handleBillingError = useCallback((error: any) => {
// In local mode, don't process billing errors
if (isLocalMode()) {
console.log('Running in local development mode - billing checks are disabled');
return false;
}
// Case 1: Error is already a formatted billing error detail object
if (error && (error.message || error.subscription)) {
setBillingError({

View File

@ -0,0 +1,55 @@
// Environment mode types
export enum EnvMode {
LOCAL = 'local',
STAGING = 'staging',
PRODUCTION = 'production',
}
// Configuration object
interface Config {
ENV_MODE: EnvMode;
IS_LOCAL: boolean;
}
// Determine the environment mode from environment variables
const getEnvironmentMode = (): EnvMode => {
// Get the environment mode from the environment variable, if set
const envMode = process.env.NEXT_PUBLIC_ENV_MODE?.toLowerCase();
// First check if the environment variable is explicitly set
if (envMode) {
if (envMode === EnvMode.LOCAL) {
console.log('Using explicitly set LOCAL environment mode');
return EnvMode.LOCAL;
} else if (envMode === EnvMode.STAGING) {
console.log('Using explicitly set STAGING environment mode');
return EnvMode.STAGING;
} else if (envMode === EnvMode.PRODUCTION) {
console.log('Using explicitly set PRODUCTION environment mode');
return EnvMode.PRODUCTION;
}
}
// If no valid environment mode is set, fall back to defaults based on NODE_ENV
if (process.env.NODE_ENV === 'development') {
console.log('Defaulting to LOCAL environment mode in development');
return EnvMode.LOCAL;
} else {
console.log('Defaulting to PRODUCTION environment mode');
return EnvMode.PRODUCTION;
}
};
// Get the environment mode once to ensure consistency
const currentEnvMode = getEnvironmentMode();
// Create the config object
export const config: Config = {
ENV_MODE: currentEnvMode,
IS_LOCAL: currentEnvMode === EnvMode.LOCAL,
};
// Helper function to check if we're in local mode (for component conditionals)
export const isLocalMode = (): boolean => {
return config.IS_LOCAL;
};