284 lines
9.9 KiB
TypeScript
284 lines
9.9 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 { getPricePlans } from '@/config/price-config';
|
|
import { useMounted } from '@/hooks/use-mounted';
|
|
import { usePayment } 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();
|
|
|
|
const {
|
|
isLoading: isLoadingPayment,
|
|
error: loadPaymentError,
|
|
subscription,
|
|
currentPlan: currentPlanFromStore,
|
|
fetchPayment,
|
|
} = usePayment();
|
|
|
|
// Get user session for customer ID
|
|
const { data: session, isPending: isLoadingSession } =
|
|
authClient.useSession();
|
|
const currentUser = session?.user;
|
|
|
|
// Get price plans with translations - must be called here to maintain hook order
|
|
const pricePlans = getPricePlans();
|
|
const plans = Object.values(pricePlans);
|
|
|
|
// Convert current plan from store to a plan with translations
|
|
const currentPlan = currentPlanFromStore
|
|
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
|
|
: null;
|
|
const isFreePlan = currentPlan?.isFree || false;
|
|
const isLifetimeMember = currentPlan?.isLifetime || false;
|
|
|
|
// Get subscription price details
|
|
const currentPrice =
|
|
subscription &&
|
|
currentPlan?.prices.find(
|
|
(price) => price.priceId === subscription?.priceId
|
|
);
|
|
|
|
// Get current period start date
|
|
const currentPeriodStart = subscription?.currentPeriodStart
|
|
? formatDate(subscription.currentPeriodStart)
|
|
: null;
|
|
|
|
// Format next billing date if subscription is active
|
|
const nextBillingDate = subscription?.currentPeriodEnd
|
|
? formatDate(subscription.currentPeriodEnd)
|
|
: null;
|
|
|
|
// Retry payment data fetching
|
|
const handleRetry = useCallback(() => {
|
|
// console.log('handleRetry, refetch payment info');
|
|
fetchPayment(true);
|
|
}, [fetchPayment]);
|
|
|
|
// 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) {
|
|
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-6 w-1/5" />
|
|
</div>
|
|
<div className="text-sm text-muted-foreground space-y-2">
|
|
<Skeleton className="h-6 w-2/5" />
|
|
<Skeleton className="h-6 w-3/5" />
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
|
<Skeleton className="h-10 w-1/2" />
|
|
</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}</div>
|
|
</CardContent>
|
|
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background 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 (!currentPlan) {
|
|
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">{currentPlan?.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 && currentPrice && (
|
|
<div className="text-sm text-muted-foreground space-y-2">
|
|
{/* <div>
|
|
{t('price')}{' '}
|
|
{formatPrice(currentPrice.amount, currentPrice.currency)} /{' '}
|
|
{currentPrice.interval === PlanIntervals.MONTH
|
|
? t('interval.month')
|
|
: currentPrice.interval === PlanIntervals.YEAR
|
|
? t('interval.year')
|
|
: t('interval.oneTime')}
|
|
</div> */}
|
|
|
|
{currentPeriodStart && (
|
|
<div className="text-muted-foreground">
|
|
{t('periodStartDate')} {currentPeriodStart}
|
|
</div>
|
|
)}
|
|
|
|
{nextBillingDate && (
|
|
<div className="text-muted-foreground">
|
|
{t('nextBillingDate')} {nextBillingDate}
|
|
</div>
|
|
)}
|
|
|
|
{subscription.status === 'trialing' &&
|
|
subscription.currentPeriodEnd && (
|
|
<div className="text-amber-600">
|
|
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background 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>
|
|
);
|
|
}
|