cancel plan

This commit is contained in:
marko-kraemer 2025-08-25 15:40:19 -07:00
parent 4171e3259b
commit ad48d9ebe8
3 changed files with 309 additions and 364 deletions

View File

@ -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<SubscriptionStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 !==
</div>
)}
{/* 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 && (
<div className="mb-6">
<CreditBalanceDisplay
@ -152,11 +293,157 @@ export function BillingModal({ open, onOpenChange, returnUrl = typeof window !==
onPurchaseClick={() => setShowCreditPurchaseModal(true)}
/>
</div>
)}
)} */}
<PricingSection returnUrl={returnUrl} showTitleAndTabs={false} />
{subscriptionData && (
{/* Minimalistic Subscription Management Section */}
{subscriptionData?.subscription && (
<div className="mt-6 pt-6 border-t border-border">
<div className="space-y-3">
{/* Subscription Status Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at
? 'Plan Status'
: 'Current Plan'}
</span>
{commitmentInfo?.has_commitment && (
<Badge variant="outline" className="text-xs px-2 py-0">
{commitmentInfo.months_remaining || 0}mo left
</Badge>
)}
</div>
<Badge variant={
subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at
? 'destructive'
: 'secondary'
} className="text-xs">
{subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at
? 'Ending ' + getEffectiveCancellationDate()
: 'Active'}
</Badge>
</div>
{/* Cancellation Alert */}
{(subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at) && (
<Alert className="py-2 border-destructive/30 bg-destructive/5">
<AlertTriangle className="h-4 w-4 text-destructive" />
<AlertDescription className="text-xs text-destructive">
{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.'
}
</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="flex gap-2">
{/* Cancel/Reactivate Button */}
{!(subscriptionData.subscription.cancel_at_period_end || subscriptionData.subscription.cancel_at) ? (
<Dialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-xs"
disabled={isCancelling}
>
{isCancelling ? (
<div className="flex items-center gap-1">
<div className="animate-spin h-3 w-3 border border-current border-t-transparent rounded-full" />
Processing...
</div>
) : (
commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel
? 'Schedule End'
: 'Cancel Plan'
)}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-lg">
{commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel
? 'Schedule Cancellation'
: 'Cancel Subscription'}
</DialogTitle>
<DialogDescription className="text-sm">
{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.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelDialog(false)}
disabled={isCancelling}
size="sm"
>
Keep Plan
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={isCancelling}
size="sm"
>
{isCancelling ? 'Processing...' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
variant="default"
size="sm"
onClick={handleReactivate}
disabled={isCancelling}
className="text-xs bg-green-600 hover:bg-green-700 text-white"
>
{isCancelling ? (
<div className="flex items-center gap-1">
<div className="animate-spin h-3 w-3 border border-white border-t-transparent rounded-full" />
Processing...
</div>
) : (
'Reactivate Plan'
)}
</Button>
)}
{/* Manage Subscription Button */}
<Button
onClick={handleManageSubscription}
disabled={isManaging}
variant="outline"
size="sm"
className="text-xs"
>
{isManaging ? 'Loading...' : 'Dashboard'}
</Button>
</div>
</div>
</div>
)}
{/* Legacy Manage Button for non-subscription users */}
{subscriptionData && !subscriptionData.subscription && (
<Button
onClick={handleManageSubscription}
disabled={isManaging}

View File

@ -1,343 +0,0 @@
'use client';
import { useState } from 'react';
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertTriangle,
Shield,
CheckCircle,
RotateCcw,
Clock
} from 'lucide-react';
import { toast } from 'sonner';
import { cancelSubscription, reactivateSubscription } from '@/lib/api';
import { useSubscriptionCommitment } from '@/hooks/react-query';
interface SubscriptionStatusManagementProps {
subscription?: {
id: string;
status: string;
cancel_at_period_end: boolean;
cancel_at?: number;
current_period_end: number;
};
subscriptionId?: string;
onSubscriptionUpdate?: () => void;
className?: string;
}
export default function SubscriptionStatusManagement({
subscription,
subscriptionId,
onSubscriptionUpdate,
className,
}: SubscriptionStatusManagementProps) {
const [isLoading, setIsLoading] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const {
data: commitmentInfo,
isLoading: commitmentLoading,
error: commitmentError
} = useSubscriptionCommitment(subscriptionId || subscription?.id);
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Get the effective cancellation date (could be period end or cancel_at for yearly commitments)
const getEffectiveCancellationDate = () => {
if (subscription.cancel_at) {
// Yearly commitment cancellation - use cancel_at timestamp
return formatDate(subscription.cancel_at);
}
// Regular cancellation - use current period end
return formatDate(subscription.current_period_end);
};
const formatEndDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateString;
}
};
const handleCancel = async () => {
setIsLoading(true);
try {
const response = await cancelSubscription();
if (response.success) {
toast.success(response.message);
setShowCancelDialog(false);
onSubscriptionUpdate?.();
} else {
toast.error(response.message);
}
} catch (error: any) {
console.error('Error cancelling subscription:', error);
toast.error(error.message || 'Failed to cancel subscription');
} finally {
setIsLoading(false);
}
};
const handleReactivate = async () => {
setIsLoading(true);
try {
const response = await reactivateSubscription();
if (response.success) {
toast.success(response.message);
onSubscriptionUpdate?.();
} else {
toast.error(response.message);
}
} catch (error: any) {
console.error('Error reactivating subscription:', error);
toast.error(error.message || 'Failed to reactivate subscription');
} finally {
setIsLoading(false);
}
};
// Loading state
if (commitmentLoading) {
return (
<Card className={className}>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground">Loading subscription status...</span>
</div>
</CardContent>
</Card>
);
}
// Don't render if no subscription
if (!subscription) {
return null;
}
const hasCommitment = commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel;
// Check both cancel_at_period_end (regular cancellation) and cancel_at (yearly commitment cancellation)
const isAlreadyCancelled = subscription.cancel_at_period_end || !!subscription.cancel_at;
const canCancel = !isAlreadyCancelled;
const canReactivate = isAlreadyCancelled;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Shield className="h-5 w-5 text-blue-600" />
Subscription Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<Badge variant={isAlreadyCancelled ? 'destructive' : 'secondary'}>
{isAlreadyCancelled
? subscription.cancel_at
? 'Cancelling at commitment end'
: 'Cancelling at period end'
: 'Active'}
</Badge>
</div>
{/* Commitment Information */}
{commitmentInfo?.has_commitment && (
<>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Commitment Type</span>
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border-blue-200">
12-Month Commitment
</Badge>
</div>
{commitmentInfo.months_remaining !== undefined && commitmentInfo.months_remaining > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Time Remaining</span>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
{commitmentInfo.months_remaining} months
</div>
</div>
)}
{commitmentInfo.commitment_end_date && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Commitment Ends</span>
<span className="text-sm text-muted-foreground">
{formatEndDate(commitmentInfo.commitment_end_date)}
</span>
</div>
)}
</>
)}
{/* Commitment Warning for Active Subscriptions */}
{hasCommitment && !isAlreadyCancelled && (
<Alert>
<Shield className="h-4 w-4" />
<AlertDescription className="text-sm">
You have {commitmentInfo?.months_remaining} months remaining in
your yearly commitment. If you cancel, your subscription will end
on{' '}
{commitmentInfo?.commitment_end_date
? formatEndDate(commitmentInfo.commitment_end_date)
: 'your commitment end date'}{' '}
and you'll continue to have access until then.
</AlertDescription>
</Alert>
)}
{/* Cannot Cancel Warning */}
{hasCommitment && !commitmentInfo?.can_cancel && !isAlreadyCancelled && (
<Alert className="border-blue-200 bg-blue-50">
<Shield className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800">
Your subscription cannot be cancelled during the commitment period, but you can schedule
it to end when your commitment expires. You can upgrade to a higher plan at any time.
</AlertDescription>
</Alert>
)}
{/* Already Cancelled Status */}
{isAlreadyCancelled && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
{subscription.cancel_at ? (
<>
Your subscription is scheduled to end on{' '}
{getEffectiveCancellationDate()}{' '}
(end of commitment period). You'll continue to have access until then.
</>
) : (
<>
Your subscription will end on{' '}
{getEffectiveCancellationDate()}. You'll continue
to have access until then.
</>
)}
</AlertDescription>
</Alert>
)}
{/* Commitment Completed Message */}
{commitmentInfo?.has_commitment && commitmentInfo?.can_cancel && !isAlreadyCancelled && (
<div className="flex items-center gap-2 text-sm text-green-600">
<CheckCircle className="h-4 w-4" />
<span>Commitment period completed - you can now cancel anytime</span>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
{canCancel && (
<Dialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
{hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{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)}).
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelDialog(false)}
disabled={isLoading}
>
Keep Subscription
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={isLoading}
>
{isLoading ? 'Processing...' : hasCommitment ? 'Yes, Schedule Cancellation' : 'Yes, Cancel'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{canReactivate && (
<Button
variant="default"
size="sm"
onClick={handleReactivate}
disabled={isLoading}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
{isLoading ? 'Reactivating...' : 'Reactivate Subscription'}
</Button>
)}
{!canCancel && !canReactivate && !hasCommitment && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4" />
Subscription is active
</div>
)}
</div>
{/* Help Text */}
<div className="text-xs text-muted-foreground mt-4 p-3 bg-muted/50 rounded-md">
<strong>Need help?</strong> Contact support if you have questions
about your subscription or need assistance with changes.
</div>
</CardContent>
</Card>
);
}

View File

@ -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