feat(pricing): add yearly subscription tiers and enhance billing period toggle functionality

This commit is contained in:
sharath 2025-06-26 15:00:56 +00:00
parent 9036e22c4f
commit bec4494084
No known key found for this signature in database
6 changed files with 374 additions and 33 deletions

View File

@ -33,6 +33,14 @@ SUBSCRIPTION_TIERS = {
config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400}, # 50 hours config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400}, # 50 hours
config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800}, # 125 hours config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800}, # 125 hours
config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000}, # 200 hours config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000}, # 200 hours
# Yearly tiers (same usage limits, different billing period)
config.STRIPE_TIER_2_20_YEARLY_ID: {'name': 'tier_2_20_yearly', 'minutes': 120, 'cost': 20}, # 2 hours/month, $204/year
config.STRIPE_TIER_6_50_YEARLY_ID: {'name': 'tier_6_50_yearly', 'minutes': 360, 'cost': 50}, # 6 hours/month, $510/year
config.STRIPE_TIER_12_100_YEARLY_ID: {'name': 'tier_12_100_yearly', 'minutes': 720, 'cost': 100}, # 12 hours/month, $1020/year
config.STRIPE_TIER_25_200_YEARLY_ID: {'name': 'tier_25_200_yearly', 'minutes': 1500, 'cost': 200}, # 25 hours/month, $2040/year
config.STRIPE_TIER_50_400_YEARLY_ID: {'name': 'tier_50_400_yearly', 'minutes': 3000, 'cost': 400}, # 50 hours/month, $4080/year
config.STRIPE_TIER_125_800_YEARLY_ID: {'name': 'tier_125_800_yearly', 'minutes': 7500, 'cost': 800}, # 125 hours/month, $8160/year
config.STRIPE_TIER_200_1000_YEARLY_ID: {'name': 'tier_200_1000_yearly', 'minutes': 12000, 'cost': 1000}, # 200 hours/month, $10200/year
} }
# Pydantic models for request/response validation # Pydantic models for request/response validation
@ -127,7 +135,15 @@ async def get_user_subscription(user_id: str) -> Optional[Dict]:
config.STRIPE_TIER_25_200_ID, config.STRIPE_TIER_25_200_ID,
config.STRIPE_TIER_50_400_ID, config.STRIPE_TIER_50_400_ID,
config.STRIPE_TIER_125_800_ID, config.STRIPE_TIER_125_800_ID,
config.STRIPE_TIER_200_1000_ID config.STRIPE_TIER_200_1000_ID,
# Yearly tiers
config.STRIPE_TIER_2_20_YEARLY_ID,
config.STRIPE_TIER_6_50_YEARLY_ID,
config.STRIPE_TIER_12_100_YEARLY_ID,
config.STRIPE_TIER_25_200_YEARLY_ID,
config.STRIPE_TIER_50_400_YEARLY_ID,
config.STRIPE_TIER_125_800_YEARLY_ID,
config.STRIPE_TIER_200_1000_YEARLY_ID
]: ]:
our_subscriptions.append(sub) our_subscriptions.append(sub)

View File

@ -49,6 +49,15 @@ class Configuration:
STRIPE_TIER_125_800_ID_PROD: str = 'price_1RILb3G6l1KZGqIrbJA766tN' STRIPE_TIER_125_800_ID_PROD: str = 'price_1RILb3G6l1KZGqIrbJA766tN'
STRIPE_TIER_200_1000_ID_PROD: str = 'price_1RILb3G6l1KZGqIrmauYPOiN' STRIPE_TIER_200_1000_ID_PROD: str = 'price_1RILb3G6l1KZGqIrmauYPOiN'
# Yearly subscription tier IDs - Production (15% discount)
STRIPE_TIER_2_20_YEARLY_ID_PROD: str = 'price_1ReHB5G6l1KZGqIrD70I1xqM'
STRIPE_TIER_6_50_YEARLY_ID_PROD: str = 'price_1ReHAsG6l1KZGqIrlAog487C'
STRIPE_TIER_12_100_YEARLY_ID_PROD: str = 'price_1ReHAWG6l1KZGqIrBHer2PQc'
STRIPE_TIER_25_200_YEARLY_ID_PROD: str = 'price_1ReH9uG6l1KZGqIrsvMLHViC'
STRIPE_TIER_50_400_YEARLY_ID_PROD: str = 'price_1ReH9fG6l1KZGqIrsPtu5KIA'
STRIPE_TIER_125_800_YEARLY_ID_PROD: str = 'price_1ReH9GG6l1KZGqIrfgqaJyat'
STRIPE_TIER_200_1000_YEARLY_ID_PROD: str = 'price_1ReH8qG6l1KZGqIrK1akY90q'
# Subscription tier IDs - Staging # Subscription tier IDs - Staging
STRIPE_FREE_TIER_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrw14abxeL' STRIPE_FREE_TIER_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrw14abxeL'
STRIPE_TIER_2_20_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrCRu0E4Gi' STRIPE_TIER_2_20_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrCRu0E4Gi'
@ -59,6 +68,15 @@ class Configuration:
STRIPE_TIER_125_800_ID_STAGING: str = 'price_1RIKNrG6l1KZGqIrjKT0yGvI' STRIPE_TIER_125_800_ID_STAGING: str = 'price_1RIKNrG6l1KZGqIrjKT0yGvI'
STRIPE_TIER_200_1000_ID_STAGING: str = 'price_1RIKQ2G6l1KZGqIrum9n8SI7' STRIPE_TIER_200_1000_ID_STAGING: str = 'price_1RIKQ2G6l1KZGqIrum9n8SI7'
# Yearly subscription tier IDs - Staging (15% discount)
STRIPE_TIER_2_20_YEARLY_ID_STAGING: str = 'price_1ReGogG6l1KZGqIrEyBTmtPk'
STRIPE_TIER_6_50_YEARLY_ID_STAGING: str = 'price_1ReGoJG6l1KZGqIr0DJWtoOc'
STRIPE_TIER_12_100_YEARLY_ID_STAGING: str = 'price_1ReGnZG6l1KZGqIr0ThLEl5S'
STRIPE_TIER_25_200_YEARLY_ID_STAGING: str = 'price_1ReGmzG6l1KZGqIre31mqoEJ'
STRIPE_TIER_50_400_YEARLY_ID_STAGING: str = 'price_1ReGmgG6l1KZGqIrn5nBc7e5'
STRIPE_TIER_125_800_YEARLY_ID_STAGING: str = 'price_1ReGmMG6l1KZGqIrvE2ycrAX'
STRIPE_TIER_200_1000_YEARLY_ID_STAGING: str = 'price_1ReGlXG6l1KZGqIrlgurP5GU'
# Computed subscription tier IDs based on environment # Computed subscription tier IDs based on environment
@property @property
def STRIPE_FREE_TIER_ID(self) -> str: def STRIPE_FREE_TIER_ID(self) -> str:
@ -108,6 +126,49 @@ class Configuration:
return self.STRIPE_TIER_200_1000_ID_STAGING return self.STRIPE_TIER_200_1000_ID_STAGING
return self.STRIPE_TIER_200_1000_ID_PROD return self.STRIPE_TIER_200_1000_ID_PROD
# Yearly tier computed properties
@property
def STRIPE_TIER_2_20_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_2_20_YEARLY_ID_STAGING
return self.STRIPE_TIER_2_20_YEARLY_ID_PROD
@property
def STRIPE_TIER_6_50_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_6_50_YEARLY_ID_STAGING
return self.STRIPE_TIER_6_50_YEARLY_ID_PROD
@property
def STRIPE_TIER_12_100_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_12_100_YEARLY_ID_STAGING
return self.STRIPE_TIER_12_100_YEARLY_ID_PROD
@property
def STRIPE_TIER_25_200_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_25_200_YEARLY_ID_STAGING
return self.STRIPE_TIER_25_200_YEARLY_ID_PROD
@property
def STRIPE_TIER_50_400_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_50_400_YEARLY_ID_STAGING
return self.STRIPE_TIER_50_400_YEARLY_ID_PROD
@property
def STRIPE_TIER_125_800_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_125_800_YEARLY_ID_STAGING
return self.STRIPE_TIER_125_800_YEARLY_ID_PROD
@property
def STRIPE_TIER_200_1000_YEARLY_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_200_1000_YEARLY_ID_STAGING
return self.STRIPE_TIER_200_1000_YEARLY_ID_PROD
# LLM API keys # LLM API keys
ANTHROPIC_API_KEY: str = None ANTHROPIC_API_KEY: str = None
OPENAI_API_KEY: Optional[str] = None OPENAI_API_KEY: Optional[str] = None

View File

@ -21,7 +21,7 @@ export default function PersonalAccountSettingsPage({
<> <>
<div className="space-y-6 w-full"> <div className="space-y-6 w-full">
<Separator className="border-subtle dark:border-white/10" /> <Separator className="border-subtle dark:border-white/10" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0 w-full max-w-6xl mx-auto px-4"> <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0 w-full max-w-7xl mx-auto px-4">
<aside className="lg:w-1/4 p-1"> <aside className="lg:w-1/4 p-1">
<nav className="flex flex-col space-y-1"> <nav className="flex flex-col space-y-1">
{items.map((item) => ( {items.map((item) => (

View File

@ -5,7 +5,7 @@ import type { PricingTier } from '@/lib/home';
import { siteConfig } from '@/lib/home'; import { siteConfig } from '@/lib/home';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { CheckIcon } from 'lucide-react'; import { CheckIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -56,6 +56,7 @@ interface PricingTierProps {
isAuthenticated?: boolean; isAuthenticated?: boolean;
returnUrl: string; returnUrl: string;
insideDialog?: boolean; insideDialog?: boolean;
billingPeriod?: 'monthly' | 'yearly';
} }
// Components // Components
@ -123,6 +124,46 @@ function PriceDisplay({ price, isCompact }: PriceDisplayProps) {
); );
} }
function BillingPeriodToggle({
billingPeriod,
setBillingPeriod
}: {
billingPeriod: 'monthly' | 'yearly';
setBillingPeriod: (period: 'monthly' | 'yearly') => void;
}) {
return (
<div className="flex items-center justify-center gap-3">
<span className={cn("text-sm font-medium", billingPeriod === 'monthly' ? 'text-foreground' : 'text-muted-foreground')}>
Monthly
</span>
<div
className="relative bg-muted rounded-full p-1 cursor-pointer"
onClick={() => setBillingPeriod(billingPeriod === 'monthly' ? 'yearly' : 'monthly')}
>
<div className="flex">
<div className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200",
billingPeriod === 'monthly'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}>
Monthly
</div>
<div className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200",
billingPeriod === 'yearly'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}>
Yearly
</div>
</div>
</div>
<span className={cn("text-sm font-medium", billingPeriod === 'yearly' ? 'text-foreground' : 'text-muted-foreground')}>
Yearly
</span>
</div>
);
}
function PricingTier({ function PricingTier({
tier, tier,
isCompact = false, isCompact = false,
@ -135,6 +176,7 @@ function PricingTier({
isAuthenticated = false, isAuthenticated = false,
returnUrl, returnUrl,
insideDialog = false, insideDialog = false,
billingPeriod = 'monthly',
}: PricingTierProps) { }: PricingTierProps) {
// Auto-select the correct plan only on initial load - simplified since no more Custom tier // Auto-select the correct plan only on initial load - simplified since no more Custom tier
const handleSubscribe = async (planStripePriceId: string) => { const handleSubscribe = async (planStripePriceId: string) => {
@ -217,7 +259,18 @@ function PricingTier({
} }
}; };
const tierPriceId = tier.stripePriceId; const tierPriceId = billingPeriod === 'yearly' && tier.yearlyStripePriceId
? tier.yearlyStripePriceId
: tier.stripePriceId;
const displayPrice = billingPeriod === 'yearly' && tier.yearlyPrice
? tier.yearlyPrice
: tier.price;
// Find the current tier (moved outside conditional for JSX access)
const currentTier = siteConfig.cloudPricingItems.find(
(p) => p.stripePriceId === currentSubscription?.price_id || p.yearlyStripePriceId === currentSubscription?.price_id,
);
const isCurrentActivePlan = const isCurrentActivePlan =
isAuthenticated && currentSubscription?.price_id === tierPriceId; isAuthenticated && currentSubscription?.price_id === tierPriceId;
const isScheduled = isAuthenticated && currentSubscription?.has_schedule; const isScheduled = isAuthenticated && currentSubscription?.has_schedule;
@ -269,11 +322,6 @@ function PricingTier({
</span> </span>
); );
} else { } else {
// Find the current tier
const currentTier = siteConfig.cloudPricingItems.find(
(p) => p.stripePriceId === currentSubscription?.price_id,
);
const currentPriceString = currentSubscription const currentPriceString = currentSubscription
? currentTier?.price || '$0' ? currentTier?.price || '$0'
: '$0'; : '$0';
@ -287,6 +335,14 @@ function PricingTier({
? 0 ? 0
: parseFloat(selectedPriceString.replace(/[^\d.]/g, '') || '0') * 100; : parseFloat(selectedPriceString.replace(/[^\d.]/g, '') || '0') * 100;
// Check if current subscription is monthly and target is yearly for same tier
const currentIsMonthly = currentTier && currentSubscription?.price_id === currentTier.stripePriceId;
const currentIsYearly = currentTier && currentSubscription?.price_id === currentTier.yearlyStripePriceId;
const targetIsMonthly = tier.stripePriceId === tierPriceId;
const targetIsYearly = tier.yearlyStripePriceId === tierPriceId;
const isSameTierDifferentBilling = currentTier && currentTier.name === tier.name &&
((currentIsMonthly && targetIsYearly) || (currentIsYearly && targetIsMonthly));
if ( if (
currentAmount === 0 && currentAmount === 0 &&
targetAmount === 0 && targetAmount === 0 &&
@ -297,17 +353,49 @@ function PricingTier({
buttonVariant = 'secondary'; buttonVariant = 'secondary';
buttonClassName = 'bg-primary/5 hover:bg-primary/10 text-primary'; buttonClassName = 'bg-primary/5 hover:bg-primary/10 text-primary';
} else { } else {
if (targetAmount > currentAmount) { if (targetAmount > currentAmount || (currentIsMonthly && targetIsYearly && targetAmount >= currentAmount)) {
buttonText = 'Upgrade'; // Allow upgrade to higher tier OR switch from monthly to yearly at same/higher tier
buttonVariant = tier.buttonColor as ButtonVariant; // But prevent yearly to monthly switches even if target amount is higher
buttonClassName = if (currentIsYearly && targetIsMonthly) {
'bg-primary hover:bg-primary/90 text-primary-foreground';
} else if (targetAmount < currentAmount) {
buttonText = '-'; buttonText = '-';
buttonDisabled = true; buttonDisabled = true;
buttonVariant = 'secondary'; buttonVariant = 'secondary';
buttonClassName = buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'; 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else if (currentIsMonthly && targetIsYearly && targetAmount === currentAmount) {
buttonText = 'Switch to Yearly';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else {
buttonText = 'Upgrade';
buttonVariant = tier.buttonColor as ButtonVariant;
buttonClassName = 'bg-primary hover:bg-primary/90 text-primary-foreground';
}
} else if (targetAmount < currentAmount && !(currentIsYearly && targetIsMonthly && targetAmount === currentAmount)) {
buttonText = '-';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else if (isSameTierDifferentBilling) {
// Allow switching between monthly and yearly for same tier
if (currentIsMonthly && targetIsYearly) {
buttonText = 'Switch to Yearly';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else if (currentIsYearly && targetIsMonthly) {
// Prevent downgrade from yearly to monthly
buttonText = '-';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else {
buttonText = 'Select Plan';
buttonVariant = tier.buttonColor as ButtonVariant;
buttonClassName =
'bg-primary hover:bg-primary/90 text-primary-foreground';
}
} else { } else {
buttonText = 'Select Plan'; buttonText = 'Select Plan';
buttonVariant = tier.buttonColor as ButtonVariant; buttonVariant = tier.buttonColor as ButtonVariant;
@ -350,21 +438,58 @@ function PricingTier({
<p className="text-sm flex items-center gap-2"> <p className="text-sm flex items-center gap-2">
{tier.name} {tier.name}
{tier.isPopular && ( {tier.isPopular && (
<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-2 rounded-full text-sm 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]"> <span className="bg-gradient-to-b from-secondary/50 from-[1.92%] to-secondary to-[100%] text-white inline-flex w-fit items-center justify-center px-1.5 py-0.5 rounded-full text-[10px] 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]">
Popular Popular
</span> </span>
)} )}
{/* Show upgrade badge for yearly plans when user is on monthly */}
{isAuthenticated && currentSubscription && billingPeriod === 'yearly' &&
currentTier && currentSubscription.price_id === currentTier.stripePriceId &&
tier.yearlyStripePriceId && (currentTier.name === tier.name ||
parseFloat(tier.price.slice(1)) >= parseFloat(currentTier.price.slice(1))) && (
<span className="bg-green-500/10 text-green-700 text-[10px] font-medium px-1.5 py-0.5 rounded-full">
Recommended
</span>
)}
{isAuthenticated && statusBadge} {isAuthenticated && statusBadge}
</p> </p>
<div className="flex items-baseline mt-2"> <div className="flex items-baseline mt-2">
<PriceDisplay price={tier.price} isCompact={insideDialog} /> {billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0' ? (
<span className="ml-2">{tier.price !== '$0' ? '/month' : ''}</span> <div className="flex flex-col">
<div className="flex items-baseline gap-2">
<PriceDisplay price={`$${Math.round(parseFloat(tier.yearlyPrice.slice(1)) / 12)}`} isCompact={insideDialog} />
{tier.discountPercentage && (
<span className="text-xs line-through text-muted-foreground">
${Math.round(parseFloat(tier.originalYearlyPrice?.slice(1) || '0') / 12)}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">/month</span>
<span className="text-xs text-muted-foreground">billed yearly</span>
</div>
</div>
) : (
<div className="flex items-baseline">
<PriceDisplay price={displayPrice} isCompact={insideDialog} />
<span className="ml-2">{displayPrice !== '$0' ? '/month' : ''}</span>
</div>
)}
</div> </div>
<p className="text-sm mt-2">{tier.description}</p> <p className="text-sm mt-2">{tier.description}</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"> {billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? (
{tier.price}/month <div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-green-50 border-green-200 text-green-700 w-fit">
Save ${Math.round((parseFloat(tier.originalYearlyPrice?.slice(1) || '0') - parseFloat(tier.yearlyPrice.slice(1))) / 12)} per month
</div> </div>
) : (
<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">
{billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0'
? `$${Math.round(parseFloat(tier.yearlyPrice.slice(1)) / 12)}/month (billed yearly)`
: `${displayPrice}/month`
}
</div>
)}
</div> </div>
<div className={cn( <div className={cn(
@ -375,7 +500,7 @@ function PricingTier({
<ul className="space-y-3"> <ul className="space-y-3">
{tier.features.map((feature) => ( {tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-2"> <li key={feature} className="flex items-center gap-2">
<div className="size-5 rounded-full border border-primary/20 flex items-center justify-center"> <div className="size-5 min-w-5 rounded-full border border-primary/20 flex items-center justify-center">
<CheckIcon className="size-3 text-primary" /> <CheckIcon className="size-3 text-primary" />
</div> </div>
<span className="text-sm">{feature}</span> <span className="text-sm">{feature}</span>
@ -423,13 +548,45 @@ export function PricingSection({
const [deploymentType, setDeploymentType] = useState<'cloud' | 'self-hosted'>( const [deploymentType, setDeploymentType] = useState<'cloud' | 'self-hosted'>(
'cloud', 'cloud',
); );
const [planLoadingStates, setPlanLoadingStates] = useState<Record<string, boolean>>({});
const { data: subscriptionData, isLoading: isFetchingPlan, error: subscriptionQueryError } = useSubscription(); const { data: subscriptionData, isLoading: isFetchingPlan, error: subscriptionQueryError } = useSubscription();
// Derive authentication and subscription status from the hook data // Derive authentication and subscription status from the hook data
const isAuthenticated = !!subscriptionData && subscriptionQueryError === null; const isAuthenticated = !!subscriptionData && subscriptionQueryError === null;
const currentSubscription = subscriptionData || null; const currentSubscription = subscriptionData || null;
// Determine default billing period based on user's current subscription
const getDefaultBillingPeriod = (): 'monthly' | 'yearly' => {
if (!isAuthenticated || !currentSubscription) {
// Default to yearly for non-authenticated users or users without subscription
return 'yearly';
}
// Find current tier to determine if user is on monthly or yearly plan
const currentTier = siteConfig.cloudPricingItems.find(
(p) => p.stripePriceId === currentSubscription.price_id || p.yearlyStripePriceId === currentSubscription.price_id,
);
if (currentTier) {
// Check if current subscription is yearly
if (currentTier.yearlyStripePriceId === currentSubscription.price_id) {
return 'yearly';
} else if (currentTier.stripePriceId === currentSubscription.price_id) {
return 'monthly';
}
}
// Default to yearly if we can't determine current plan type
return 'yearly';
};
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>(getDefaultBillingPeriod());
const [planLoadingStates, setPlanLoadingStates] = useState<Record<string, boolean>>({});
// Update billing period when subscription data changes
useEffect(() => {
setBillingPeriod(getDefaultBillingPeriod());
}, [isAuthenticated, currentSubscription?.price_id]);
const handlePlanSelect = (planId: string) => { const handlePlanSelect = (planId: string) => {
setPlanLoadingStates((prev) => ({ ...prev, [planId]: true })); setPlanLoadingStates((prev) => ({ ...prev, [planId]: true }));
}; };
@ -498,15 +655,22 @@ export function PricingSection({
</> </>
)} )}
{deploymentType === 'cloud' && (
<BillingPeriodToggle
billingPeriod={billingPeriod}
setBillingPeriod={setBillingPeriod}
/>
)}
{deploymentType === 'cloud' && ( {deploymentType === 'cloud' && (
<div className={cn( <div className={cn(
"grid gap-4 w-full mx-auto", "grid gap-4 w-full mx-auto",
{ {
"px-6 max-w-7xl": !insideDialog, "px-6 max-w-7xl": !insideDialog,
"max-w-5xl": insideDialog "max-w-7xl": insideDialog
}, },
insideDialog insideDialog
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 2xl:grid-cols-4"
: "min-[650px]:grid-cols-2 lg:grid-cols-4", : "min-[650px]:grid-cols-2 lg:grid-cols-4",
!insideDialog && "grid-rows-1 items-stretch" !insideDialog && "grid-rows-1 items-stretch"
)}> )}>
@ -524,6 +688,7 @@ export function PricingSection({
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
returnUrl={returnUrl} returnUrl={returnUrl}
insideDialog={insideDialog} insideDialog={insideDialog}
billingPeriod={billingPeriod}
/> />
))} ))}
</div> </div>

View File

@ -21,6 +21,14 @@ export interface SubscriptionTiers {
TIER_50_400: SubscriptionTierData; TIER_50_400: SubscriptionTierData;
TIER_125_800: SubscriptionTierData; TIER_125_800: SubscriptionTierData;
TIER_200_1000: SubscriptionTierData; TIER_200_1000: SubscriptionTierData;
// Yearly plans with 15% discount
TIER_2_20_YEARLY: SubscriptionTierData;
TIER_6_50_YEARLY: SubscriptionTierData;
TIER_12_100_YEARLY: SubscriptionTierData;
TIER_25_200_YEARLY: SubscriptionTierData;
TIER_50_400_YEARLY: SubscriptionTierData;
TIER_125_800_YEARLY: SubscriptionTierData;
TIER_200_1000_YEARLY: SubscriptionTierData;
} }
// Configuration object // Configuration object
@ -64,6 +72,35 @@ const PROD_TIERS: SubscriptionTiers = {
priceId: 'price_1RILb3G6l1KZGqIrmauYPOiN', priceId: 'price_1RILb3G6l1KZGqIrmauYPOiN',
name: '200h/$1000', name: '200h/$1000',
}, },
// Yearly plans with 15% discount (12x monthly price with 15% off)
TIER_2_20_YEARLY: {
priceId: 'price_1ReHB5G6l1KZGqIrD70I1xqM',
name: '2h/$204/year',
},
TIER_6_50_YEARLY: {
priceId: 'price_1ReHAsG6l1KZGqIrlAog487C',
name: '6h/$510/year',
},
TIER_12_100_YEARLY: {
priceId: 'price_1ReHAWG6l1KZGqIrBHer2PQc',
name: '12h/$1020/year',
},
TIER_25_200_YEARLY: {
priceId: 'price_1ReH9uG6l1KZGqIrsvMLHViC',
name: '25h/$2040/year',
},
TIER_50_400_YEARLY: {
priceId: 'price_1ReH9fG6l1KZGqIrsPtu5KIA',
name: '50h/$4080/year',
},
TIER_125_800_YEARLY: {
priceId: 'price_1ReH9GG6l1KZGqIrfgqaJyat',
name: '125h/$8160/year',
},
TIER_200_1000_YEARLY: {
priceId: 'price_1ReH8qG6l1KZGqIrK1akY90q',
name: '200h/$10200/year',
},
} as const; } as const;
// Staging tier IDs // Staging tier IDs
@ -100,6 +137,35 @@ const STAGING_TIERS: SubscriptionTiers = {
priceId: 'price_1RIKQ2G6l1KZGqIrum9n8SI7', priceId: 'price_1RIKQ2G6l1KZGqIrum9n8SI7',
name: '200h/$1000', name: '200h/$1000',
}, },
// Yearly plans with 15% discount (12x monthly price with 15% off)
TIER_2_20_YEARLY: {
priceId: 'price_1ReGogG6l1KZGqIrEyBTmtPk',
name: '2h/$204/year',
},
TIER_6_50_YEARLY: {
priceId: 'price_1ReGoJG6l1KZGqIr0DJWtoOc',
name: '6h/$510/year',
},
TIER_12_100_YEARLY: {
priceId: 'price_1ReGnZG6l1KZGqIr0ThLEl5S',
name: '12h/$1020/year',
},
TIER_25_200_YEARLY: {
priceId: 'price_1ReGmzG6l1KZGqIre31mqoEJ',
name: '25h/$2040/year',
},
TIER_50_400_YEARLY: {
priceId: 'price_1ReGmgG6l1KZGqIrn5nBc7e5',
name: '50h/$4080/year',
},
TIER_125_800_YEARLY: {
priceId: 'price_1ReGmMG6l1KZGqIrvE2ycrAX',
name: '125h/$8160/year',
},
TIER_200_1000_YEARLY: {
priceId: 'price_1ReGlXG6l1KZGqIrlgurP5GU',
name: '200h/$10200/year',
},
} as const; } as const;
// Determine the environment mode from environment variables // Determine the environment mode from environment variables

View File

@ -39,6 +39,7 @@ interface UpgradePlan {
export interface PricingTier { export interface PricingTier {
name: string; name: string;
price: string; price: string;
yearlyPrice?: string; // Add yearly price support
description: string; description: string;
buttonText: string; buttonText: string;
buttonColor: string; buttonColor: string;
@ -47,8 +48,12 @@ export interface PricingTier {
hours: string; hours: string;
features: string[]; features: string[];
stripePriceId: string; stripePriceId: string;
yearlyStripePriceId?: string; // Add yearly price ID support
upgradePlans: UpgradePlan[]; upgradePlans: UpgradePlan[];
hidden?: boolean; // Optional property to hide plans from display while keeping them in code hidden?: boolean; // Optional property to hide plans from display while keeping them in code
billingPeriod?: 'monthly' | 'yearly'; // Add billing period support
originalYearlyPrice?: string; // For showing crossed-out price
discountPercentage?: number; // For showing discount badge
} }
export const siteConfig = { export const siteConfig = {
@ -117,7 +122,7 @@ export const siteConfig = {
/** @deprecated */ /** @deprecated */
hours: '60 min', hours: '60 min',
features: [ features: [
'$5 of usage', '$5/month usage',
'Public Projects', 'Public Projects',
'Basic Model (Limited capabilities)', 'Basic Model (Limited capabilities)',
], ],
@ -127,6 +132,9 @@ export const siteConfig = {
{ {
name: 'Plus', name: 'Plus',
price: '$20', price: '$20',
yearlyPrice: '$204',
originalYearlyPrice: '$240',
discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-primary text-white dark:text-black', buttonColor: 'bg-primary text-white dark:text-black',
@ -134,16 +142,20 @@ export const siteConfig = {
/** @deprecated */ /** @deprecated */
hours: '2 hours', hours: '2 hours',
features: [ features: [
'$20 of usage', '$20/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
}, },
{ {
name: 'Pro', name: 'Pro',
price: '$50', price: '$50',
yearlyPrice: '$510',
originalYearlyPrice: '$600',
discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
@ -151,76 +163,92 @@ export const siteConfig = {
/** @deprecated */ /** @deprecated */
hours: '6 hours', hours: '6 hours',
features: [ features: [
'$50 of usage', '$50/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_50.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_50.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_50_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
}, },
{ {
name: 'Business', name: 'Business',
price: '$100', price: '$100',
yearlyPrice: '$1020',
originalYearlyPrice: '$1200',
discountPercentage: 15,
description: 'Everything in Pro, plus:', description: 'Everything in Pro, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '12 hours', hours: '12 hours',
features: [ features: [
'12 hours', '$100/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
'Priority support', 'Priority support',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_12_100.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_12_100.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_12_100_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
hidden: true, hidden: true,
}, },
{ {
name: 'Ultra', name: 'Ultra',
price: '$200', price: '$200',
yearlyPrice: '$2040',
originalYearlyPrice: '$2400',
discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-primary text-white dark:text-black', buttonColor: 'bg-primary text-white dark:text-black',
isPopular: false, isPopular: false,
hours: '25 hours', hours: '25 hours',
features: [ features: [
'$200 of usage', '$200/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_200.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_200.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_200_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
}, },
{ {
name: 'Enterprise', name: 'Enterprise',
price: '$400', price: '$400',
yearlyPrice: '$4080',
originalYearlyPrice: '$4800',
discountPercentage: 15,
description: 'Everything in Ultra, plus:', description: 'Everything in Ultra, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '50 hours', hours: '50 hours',
features: [ features: [
'50 hours', '$400/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
'Priority support', 'Priority support',
'Custom integrations', 'Custom integrations',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_50_400.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_50_400.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_50_400_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
hidden: true, hidden: true,
}, },
{ {
name: 'Scale', name: 'Scale',
price: '$800', price: '$800',
yearlyPrice: '$8160',
originalYearlyPrice: '$9600',
discountPercentage: 15,
description: 'Everything in Enterprise, plus:', description: 'Everything in Enterprise, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '125 hours', hours: '125 hours',
features: [ features: [
'125 hours', '$800/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
'Priority support', 'Priority support',
@ -228,19 +256,23 @@ export const siteConfig = {
'Dedicated account manager', 'Dedicated account manager',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_125_800.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_125_800.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_125_800_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
hidden: true, hidden: true,
}, },
{ {
name: 'Premium', name: 'Premium',
price: '$1000', price: '$1000',
yearlyPrice: '$10200',
originalYearlyPrice: '$12000',
discountPercentage: 15,
description: 'Everything in Scale, plus:', description: 'Everything in Scale, plus:',
buttonText: 'Try Free', buttonText: 'Try Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '200 hours', hours: '200 hours',
features: [ features: [
'200 hours', '$1000/month usage',
'Private projects', 'Private projects',
'Access to intelligent Model (Full Suna)', 'Access to intelligent Model (Full Suna)',
'Priority support', 'Priority support',
@ -249,6 +281,7 @@ export const siteConfig = {
'Custom SLA', 'Custom SLA',
], ],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_200_1000.priceId, stripePriceId: config.SUBSCRIPTION_TIERS.TIER_200_1000.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_200_1000_YEARLY.priceId,
upgradePlans: [], upgradePlans: [],
hidden: true, hidden: true,
}, },