diff --git a/frontend/src/components/billing/billing-modal.tsx b/frontend/src/components/billing/billing-modal.tsx index 9dd09940..714dd408 100644 --- a/frontend/src/components/billing/billing-modal.tsx +++ b/frontend/src/components/billing/billing-modal.tsx @@ -4,21 +4,41 @@ import { useEffect, useState } from 'react'; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; import { PricingSection } from '@/components/home/sections/pricing-section'; import { CreditBalanceDisplay, CreditPurchaseModal } from '@/components/billing/credit-purchase'; import { isLocalMode } from '@/lib/config'; import { getSubscription, createPortalSession, + cancelSubscription, + reactivateSubscription, SubscriptionStatus, } from '@/lib/api'; import { useAuth } from '@/components/AuthProvider'; +import { useSubscriptionCommitment } from '@/hooks/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { subscriptionKeys } from '@/hooks/react-query/subscriptions/keys'; import { Skeleton } from '@/components/ui/skeleton'; -import { X, Zap } from 'lucide-react'; +import { + X, + Zap, + AlertTriangle, + Shield, + CheckCircle, + RotateCcw, + Clock +} from 'lucide-react'; +import { toast } from 'sonner'; interface BillingModalProps { open: boolean; @@ -29,32 +49,76 @@ interface BillingModalProps { export function BillingModal({ open, onOpenChange, returnUrl = typeof window !== 'undefined' ? window?.location?.href || '/' : '/', showUsageLimitAlert = false }: BillingModalProps) { const { session, isLoading: authLoading } = useAuth(); + const queryClient = useQueryClient(); const [subscriptionData, setSubscriptionData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isManaging, setIsManaging] = useState(false); const [showCreditPurchaseModal, setShowCreditPurchaseModal] = useState(false); + const [showCancelDialog, setShowCancelDialog] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + // Get commitment info for the subscription (only if we have a valid ID) + const { + data: commitmentInfo, + isLoading: commitmentLoading, + error: commitmentError, + refetch: refetchCommitment + } = useSubscriptionCommitment(subscriptionData?.subscription?.id || null); + + // Simple function to fetch subscription data + const fetchSubscriptionData = async () => { + if (!session) return; + + try { + setIsLoading(true); + const data = await getSubscription(); + setSubscriptionData(data); + setError(null); + return data; + } catch (err) { + console.error('Failed to get subscription:', err); + setError(err instanceof Error ? err.message : 'Failed to load subscription data'); + } finally { + setIsLoading(false); + } + }; useEffect(() => { - async function fetchSubscription() { - if (!open || authLoading || !session) return; - - try { - setIsLoading(true); - const data = await getSubscription(); - setSubscriptionData(data); - setError(null); - } catch (err) { - console.error('Failed to get subscription:', err); - setError(err instanceof Error ? err.message : 'Failed to load subscription data'); - } finally { - setIsLoading(false); - } - } - - fetchSubscription(); + if (!open || authLoading || !session) return; + fetchSubscriptionData(); }, [open, session, authLoading]); + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatEndDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } catch { + return dateString; + } + }; + + // Get the effective cancellation date (could be period end or cancel_at for yearly commitments) + const getEffectiveCancellationDate = () => { + if (subscriptionData?.subscription?.cancel_at) { + // Yearly commitment cancellation - use cancel_at timestamp + return formatDate(subscriptionData.subscription.cancel_at); + } + // Regular cancellation - use current period end + return formatDate(subscriptionData?.subscription?.current_period_end || 0); + }; + const handleManageSubscription = async () => { try { setIsManaging(true); @@ -68,6 +132,83 @@ export function BillingModal({ open, onOpenChange, returnUrl = typeof window !== } }; + const handleCancel = async () => { + setIsCancelling(true); + const originalState = subscriptionData; + + try { + console.log('Cancelling subscription...'); + setShowCancelDialog(false); + + // Optimistic update - show cancelled state immediately + if (subscriptionData?.subscription) { + const optimisticState = { + ...subscriptionData, + subscription: { + ...subscriptionData.subscription, + cancel_at_period_end: true, + ...(commitmentInfo?.has_commitment && commitmentInfo.commitment_end_date ? { + cancel_at: Math.floor(new Date(commitmentInfo.commitment_end_date).getTime() / 1000) + } : {}) + } + }; + setSubscriptionData(optimisticState); + } + + const response = await cancelSubscription(); + + if (response.success) { + toast.success(response.message); + } else { + setSubscriptionData(originalState); + toast.error(response.message); + } + } catch (error: any) { + console.error('Error cancelling subscription:', error); + setSubscriptionData(originalState); + toast.error(error.message || 'Failed to cancel subscription'); + } finally { + setIsCancelling(false); + } + }; + + const handleReactivate = async () => { + setIsCancelling(true); + const originalState = subscriptionData; + + try { + console.log('Reactivating subscription...'); + + // Optimistic update - show active state immediately + if (subscriptionData?.subscription) { + const optimisticState = { + ...subscriptionData, + subscription: { + ...subscriptionData.subscription, + cancel_at_period_end: false, + cancel_at: undefined + } + }; + setSubscriptionData(optimisticState); + } + + const response = await reactivateSubscription(); + + if (response.success) { + toast.success(response.message); + } else { + setSubscriptionData(originalState); + toast.error(response.message); + } + } catch (error: any) { + console.error('Error reactivating subscription:', error); + setSubscriptionData(originalState); + toast.error(error.message || 'Failed to reactivate subscription'); + } finally { + setIsCancelling(false); + } + }; + // Local mode content if (isLocalMode()) { return ( @@ -143,7 +284,7 @@ export function BillingModal({ open, onOpenChange, returnUrl = typeof window !== )} - {/* Credit Balance Display - Only show for users who can purchase credits */} + {/* Credit Balance Display - Only show for users who can purchase credits {subscriptionData?.can_purchase_credits && (
setShowCreditPurchaseModal(true)} />
- )} + )} */} - {subscriptionData && ( + {/* Minimalistic Subscription Management Section */} + {subscriptionData?.subscription && ( +
+
+ {/* Subscription Status Row */} +
+
+ + {subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at + ? 'Plan Status' + : 'Current Plan'} + + {commitmentInfo?.has_commitment && ( + + {commitmentInfo.months_remaining || 0}mo left + + )} +
+ + {subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at + ? 'Ending ' + getEffectiveCancellationDate() + : 'Active'} + +
+ + {/* Cancellation Alert */} + {(subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at) && ( + + + + {subscriptionData.subscription.cancel_at ? + 'Your plan is scheduled to end at commitment completion. You can reactivate anytime.' : + 'Your plan is scheduled to end at period completion. You can reactivate anytime.' + } + + + )} + + {/* Action Buttons */} +
+ {/* Cancel/Reactivate Button */} + {!(subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at) ? ( + + + + + + + + {commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel + ? 'Schedule Cancellation' + : 'Cancel Subscription'} + + + {commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel ? ( + <> + Your subscription will be scheduled to end on{' '} + {commitmentInfo?.commitment_end_date + ? formatEndDate(commitmentInfo.commitment_end_date) + : 'your commitment end date'} + . You'll keep full access until then. + + ) : ( + <> + Your subscription will end on{' '} + {formatDate(subscriptionData.subscription.current_period_end)}. + You'll keep access until then. + + )} + + + + + + + + + ) : ( + + )} + + {/* Manage Subscription Button */} + +
+
+
+ )} + + {/* Legacy Manage Button for non-subscription users */} + {subscriptionData && !subscriptionData.subscription && ( - - - - - {hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'} - - - {hasCommitment ? ( - <> - Are you sure you want to schedule your subscription for cancellation? - Since you have a yearly commitment, your subscription will be - scheduled to end on{' '} - {commitmentInfo?.commitment_end_date - ? formatEndDate(commitmentInfo.commitment_end_date) - : 'your commitment end date'} - . You'll continue to have full access until then. - - ) : ( - <> - Are you sure you want to cancel your subscription? - You'll continue to have access until the end of your - current billing period ( - {formatDate(subscription.current_period_end)}). - - )} - - - - - - - - - )} - - {canReactivate && ( - - )} - - {!canCancel && !canReactivate && !hasCommitment && ( -
- - Subscription is active -
- )} - - - {/* Help Text */} -
- Need help? Contact support if you have questions - about your subscription or need assistance with changes. -
- - - ); -} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f0df0043..5a169c83 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1587,6 +1587,7 @@ export interface SubscriptionStatus { id: string; status: string; cancel_at_period_end: boolean; + cancel_at?: number; // timestamp for yearly commitment cancellations current_period_end: number; // timestamp }; // Credit information