prmbr-image-mksaas/src/components/settings/billing/billing-card.tsx

277 lines
9.6 KiB
TypeScript

'use client';
import { CustomerPortalButton } from '@/components/pricing/customer-portal-button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { usePricePlans } from '@/config/price-config';
import { useMounted } from '@/hooks/use-mounted';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner';
export default function BillingCard() {
const t = useTranslations('Dashboard.settings.billing');
const searchParams = useSearchParams();
const localeRouter = useLocaleRouter();
const hasHandledSession = useRef(false);
const mounted = useMounted();
// Get user session for customer ID
const { data: session, isPending: isLoadingSession } =
authClient.useSession();
const currentUser = session?.user;
// TanStack Query hook for current plan and subscription
const {
data: paymentData,
isLoading: isLoadingPayment,
error: loadPaymentError,
refetch: refetchPayment,
} = useCurrentPlan(currentUser?.id);
const currentPlan = paymentData?.currentPlan;
const subscription = paymentData?.subscription;
// Get price plans with translations - must be called here to maintain hook order
const pricePlans = usePricePlans();
const plans = Object.values(pricePlans);
// Convert current plan to a plan with translations
const currentPlanWithTranslations = currentPlan
? plans.find((plan) => plan.id === currentPlan?.id)
: null;
const isFreePlan = currentPlanWithTranslations?.isFree || false;
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
// Get current period start date
const currentPeriodStart = subscription?.currentPeriodStart
? formatDate(subscription.currentPeriodStart)
: null;
// Get current period end date
const currentPeriodEnd = subscription?.currentPeriodEnd
? formatDate(subscription.currentPeriodEnd)
: null;
// Get current trial end date
const trialEndDate = subscription?.trialEndDate
? formatDate(subscription.trialEndDate)
: null;
// Retry payment data fetching
const handleRetry = useCallback(() => {
// console.log('handleRetry, refetch payment info');
refetchPayment();
}, [refetchPayment]);
// Check for payment success and show success message
useEffect(() => {
const sessionId = searchParams.get('session_id');
if (sessionId && !hasHandledSession.current) {
hasHandledSession.current = true;
setTimeout(() => {
toast.success(t('paymentSuccess'));
}, 0);
const url = new URL(window.location.href);
url.searchParams.delete('session_id');
localeRouter.replace(Routes.SettingsBilling + url.search);
}
}, [searchParams, localeRouter]);
// Render loading skeleton if not mounted or in a loading state
const isPageLoading = isLoadingPayment || isLoadingSession;
if (!mounted || isPageLoading || !paymentData) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="flex items-center justify-start space-x-4">
<Skeleton className="h-8 w-1/5" />
</div>
<div className="text-sm text-muted-foreground space-y-2">
<Skeleton className="h-6 w-3/5" />
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Skeleton className="h-8 w-1/4" />
</CardFooter>
</Card>
);
}
// Render error state
if (loadPaymentError) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">
{loadPaymentError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button
variant="outline"
className="cursor-pointer"
onClick={handleRetry}
>
<RefreshCwIcon className="size-4 mr-1" />
{t('retry')}
</Button>
</CardFooter>
</Card>
);
}
// currentPlan maybe null, so we need to check if it is null
if (!currentPlanWithTranslations) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
{t('currentPlan.noPlan')}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
</CardFooter>
</Card>
);
}
// console.log('billing card, currentPlan', currentPlan);
// console.log('billing card, subscription', subscription);
// console.log('billing card, currentUser', currentUser);
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Plan name and status */}
<div className="flex items-center justify-start space-x-4">
<div className="text-3xl font-medium">
{currentPlanWithTranslations?.name}
</div>
{subscription &&
(subscription.status === 'trialing' ||
subscription.status === 'active') && (
<Badge variant="outline" className="text-xs">
{subscription.status === 'trialing' ? (
<div className="flex items-center space-x-2">
<ClockIcon className="size-3 mr-1 text-amber-600" />
{t('status.trial')}
</div>
) : (
<div className="flex items-center space-x-2">
<CheckCircleIcon className="size-3 mr-1 text-green-600" />
{t('status.active')}
</div>
)}
</Badge>
)}
</div>
{/* Free plan message */}
{isFreePlan && (
<div className="text-sm text-muted-foreground">
{t('freePlanMessage')}
</div>
)}
{/* Lifetime plan message */}
{isLifetimeMember && (
<div className="text-sm text-muted-foreground">
{t('lifetimeMessage')}
</div>
)}
{/* Subscription plan message */}
{subscription && (
<div className="text-sm text-muted-foreground space-y-2">
{currentPeriodStart && (
<div className="text-muted-foreground">
{t('periodStartDate')} {currentPeriodStart}
</div>
)}
{currentPeriodEnd && (
<div className="text-muted-foreground">
{t('periodEndDate')} {currentPeriodEnd}
</div>
)}
{subscription.status === 'trialing' && trialEndDate && (
<div className="text-amber-600">
{t('trialEnds')} {trialEndDate}
</div>
)}
</div>
)}
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
{/* user is on free plan, show upgrade plan button */}
{isFreePlan && (
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
)}
{/* user is lifetime member, show manage billing button */}
{isLifetimeMember && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageBilling')}
</CustomerPortalButton>
)}
{/* user has subscription, show manage subscription button */}
{subscription && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageSubscription')}
</CustomerPortalButton>
)}
</CardFooter>
</Card>
);
}