From ee341522f5eac11dbad0eba9e2f5d5bd11183729 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 19:29:21 +0800 Subject: [PATCH] feat: enhance billing and credits management with new components and improved layout --- messages/en.json | 5 +- src/actions/create-credit-checkout-session.ts | 4 +- .../(protected)/settings/billing/page.tsx | 20 +- .../(protected)/settings/credits/page.tsx | 35 +-- .../settings/billing/billing-card.tsx | 294 +++++++++--------- .../settings/billing/credits-balance-card.tsx | 143 +++++++++ .../settings/credits/credit-packages.tsx | 203 +++++------- 7 files changed, 397 insertions(+), 307 deletions(-) create mode 100644 src/components/settings/billing/credits-balance-card.tsx diff --git a/messages/en.json b/messages/en.json index 4c2f480..70fab14 100644 --- a/messages/en.json +++ b/messages/en.json @@ -587,9 +587,12 @@ "description": "Manage your credits", "balance": { "title": "Credit Balance", + "description": "Your credit balance", "credits": "Credits", "creditsDescription": "You have {credits} credits", - "creditsExpired": "Credits expired" + "creditsExpired": "Credits expired", + "creditsAdded": "Credits have been added to your account", + "viewTransactions": "View Transactions" }, "packages": { "balance": "Credit Balance", diff --git a/src/actions/create-credit-checkout-session.ts b/src/actions/create-credit-checkout-session.ts index 826a512..0c326ab 100644 --- a/src/actions/create-credit-checkout-session.ts +++ b/src/actions/create-credit-checkout-session.ts @@ -90,10 +90,10 @@ export const createCreditCheckoutSession = actionClient // Create checkout session with credit-specific URLs const successUrl = getUrlWithLocale( - `${Routes.SettingsCredits}?session_id={CHECKOUT_SESSION_ID}`, + `${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`, locale ); - const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, locale); + const cancelUrl = getUrlWithLocale(Routes.SettingsBilling, locale); const params: CreateCreditCheckoutParams = { packageId, diff --git a/src/app/[locale]/(protected)/settings/billing/page.tsx b/src/app/[locale]/(protected)/settings/billing/page.tsx index 2b061d8..dddba6b 100644 --- a/src/app/[locale]/(protected)/settings/billing/page.tsx +++ b/src/app/[locale]/(protected)/settings/billing/page.tsx @@ -1,5 +1,23 @@ import BillingCard from '@/components/settings/billing/billing-card'; +import CreditsBalanceCard from '@/components/settings/billing/credits-balance-card'; +import { CreditPackages } from '@/components/settings/credits/credit-packages'; +import { websiteConfig } from '@/config/website'; export default function BillingPage() { - return ; + return ( +
+ {/* Billing and Credits Balance Cards */} +
+ + {websiteConfig.credits.enableCredits && } +
+ + {/* Credit Packages */} + {websiteConfig.credits.enableCredits && ( +
+ +
+ )} +
+ ); } diff --git a/src/app/[locale]/(protected)/settings/credits/page.tsx b/src/app/[locale]/(protected)/settings/credits/page.tsx index e85f741..fcbbab9 100644 --- a/src/app/[locale]/(protected)/settings/credits/page.tsx +++ b/src/app/[locale]/(protected)/settings/credits/page.tsx @@ -1,29 +1,16 @@ -import { CreditPackages } from '@/components/settings/credits/credit-packages'; import { CreditTransactionsPageClient } from '@/components/settings/credits/credit-transactions-page'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useTranslations } from 'next-intl'; +import { websiteConfig } from '@/config/website'; +import { Routes } from '@/routes'; +import { redirect } from 'next/navigation'; +/** + * Credits page, show credit transactions + */ export default function CreditsPage() { - const t = useTranslations('Dashboard.settings.credits'); + // If credits are disabled, redirect to billing page + if (!websiteConfig.credits.enableCredits) { + redirect(Routes.SettingsBilling); + } - return ( - - - - {t('tabs.balance')} - - - {t('tabs.transactions')} - - - - - - - - - - - - ); + return ; } diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index 9275d34..fd2d2af 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -90,87 +90,81 @@ export default function BillingCard() { // Render loading skeleton if (isPageLoading) { return ( -
- - - {t('currentPlan.title')} - {t('currentPlan.description')} - - -
- - - -
-
- - - -
-
+ + + {t('currentPlan.title')} + {t('currentPlan.description')} + + +
+ + + +
+
+ + + +
); } // Render error state if (loadPaymentError) { return ( -
- - - {t('currentPlan.title')} - {t('currentPlan.description')} - - -
{loadPaymentError}
-
- - - -
-
+ + + {t('currentPlan.title')} + {t('currentPlan.description')} + + +
{loadPaymentError}
+
+ + + +
); } // currentPlanFromStore maybe null, so we need to check if it is null if (!currentPlanFromStore) { return ( -
- - - {t('currentPlan.title')} - {t('currentPlan.description')} - - -
- {t('currentPlan.noPlan')} -
-
- - - -
-
+ + + {t('currentPlan.title')} + {t('currentPlan.description')} + + +
+ {t('currentPlan.noPlan')} +
+
+ + + +
); } @@ -179,98 +173,96 @@ export default function BillingCard() { // console.log('billing card, currentUser', currentUser); return ( -
- - - - {t('currentPlan.title')} - - {t('currentPlan.description')} - - - {/* Plan name and status */} -
-
{currentPlan?.name}
- {subscription && ( - - {subscription?.status === 'trialing' - ? t('status.trial') - : subscription?.status === 'active' - ? t('status.active') - : ''} - - )} + + + + {t('currentPlan.title')} + + {t('currentPlan.description')} + + + {/* Plan name and status */} +
+
{currentPlan?.name}
+ {subscription && ( + + {subscription?.status === 'trialing' + ? t('status.trial') + : subscription?.status === 'active' + ? t('status.active') + : ''} + + )} +
+ + {/* Free plan message */} + {isFreePlan && ( +
+ {t('freePlanMessage')}
+ )} - {/* Free plan message */} - {isFreePlan && ( -
- {t('freePlanMessage')} + {/* Lifetime plan message */} + {isLifetimeMember && ( +
+ {t('lifetimeMessage')} +
+ )} + + {/* Subscription plan message */} + {subscription && currentPrice && ( +
+
+ {t('price')}{' '} + {formatPrice(currentPrice.amount, currentPrice.currency)} /{' '} + {currentPrice.interval === PlanIntervals.MONTH + ? t('interval.month') + : currentPrice.interval === PlanIntervals.YEAR + ? t('interval.year') + : t('interval.oneTime')}
- )} - {/* Lifetime plan message */} - {isLifetimeMember && ( -
- {t('lifetimeMessage')} -
- )} - - {/* Subscription plan message */} - {subscription && currentPrice && ( -
+ {nextBillingDate && (
- {t('price')}{' '} - {formatPrice(currentPrice.amount, currentPrice.currency)} /{' '} - {currentPrice.interval === PlanIntervals.MONTH - ? t('interval.month') - : currentPrice.interval === PlanIntervals.YEAR - ? t('interval.year') - : t('interval.oneTime')} + {t('nextBillingDate')} {nextBillingDate}
+ )} - {nextBillingDate && ( -
- {t('nextBillingDate')} {nextBillingDate} + {subscription.status === 'trialing' && + subscription.currentPeriodEnd && ( +
+ {t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
)} +
+ )} + + + {/* user is on free plan, show upgrade plan button */} + {isFreePlan && ( + + )} - {subscription.status === 'trialing' && - subscription.currentPeriodEnd && ( -
- {t('trialEnds')} {formatDate(subscription.currentPeriodEnd)} -
- )} -
- )} - - - {/* user is on free plan, show upgrade plan button */} - {isFreePlan && ( - - )} + {/* user is lifetime member, show manage billing button */} + {isLifetimeMember && currentUser && ( + + {t('manageBilling')} + + )} - {/* user is lifetime member, show manage billing button */} - {isLifetimeMember && currentUser && ( - - {t('manageBilling')} - - )} - - {/* user has subscription, show manage subscription button */} - {subscription && currentUser && ( - - {t('manageSubscription')} - - )} - - -
+ {/* user has subscription, show manage subscription button */} + {subscription && currentUser && ( + + {t('manageSubscription')} + + )} + + ); } diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx new file mode 100644 index 0000000..4483a6e --- /dev/null +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { websiteConfig } from '@/config/website'; +import { useCredits } from '@/hooks/use-credits'; +import { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; +import { cn } from '@/lib/utils'; +import { Routes } from '@/routes'; +import { CoinsIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +export default function CreditsBalanceCard() { + const t = useTranslations('Dashboard.settings.credits.balance'); + const searchParams = useSearchParams(); + const localeRouter = useLocaleRouter(); + const hasHandledSession = useRef(false); + + // Use the credits hook to get balance + const { balance, isLoading, error, refresh } = useCredits(); + + // Don't render if credits are disabled + if (!websiteConfig.credits.enableCredits) { + return null; + } + + // Check for payment success and show success message + useEffect(() => { + const sessionId = searchParams.get('session_id'); + if (sessionId && !hasHandledSession.current) { + hasHandledSession.current = true; + // Show success toast (delayed to avoid React lifecycle conflicts) + setTimeout(() => { + toast.success(t('creditsAdded')); + }, 0); + + // Refresh credits data to show updated balance + refresh(); + + // Clean up URL parameters + const url = new URL(window.location.href); + url.searchParams.delete('session_id'); + localeRouter.replace(Routes.SettingsBilling + url.search); + } + }, [searchParams, localeRouter, refresh]); + + // Render loading skeleton + if (isLoading) { + return ( + + + {t('title')} + {t('description')} + + +
+ + +
+
+ + + +
+ ); + } + + // Render error state + if (error) { + return ( + + + {t('title')} + {t('description')} + + +
{error}
+
+ + + +
+ ); + } + + return ( + + + {t('title')} + {t('description')} + + + {/* Credits balance display */} +
+
+ +
+ {balance.toLocaleString()} +
+
+ {/* {t('available')} */} +
+ + {/* Balance information */} + {/*
{t('message')}
*/} +
+ + + +
+ ); +} diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index f293186..8aedc1b 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -9,17 +9,12 @@ import { CardTitle, } from '@/components/ui/card'; import { getCreditPackages } from '@/config/credits-config'; -import { useCredits } from '@/hooks/use-credits'; +import { websiteConfig } from '@/config/website'; import { useCurrentUser } from '@/hooks/use-current-user'; -import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; -import { Routes } from '@/routes'; -import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useSearchParams } from 'next/navigation'; -import { useEffect, useRef } from 'react'; -import { toast } from 'sonner'; import { CreditCheckoutButton } from './credit-checkout-button'; /** @@ -28,137 +23,89 @@ import { CreditCheckoutButton } from './credit-checkout-button'; */ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); - const searchParams = useSearchParams(); - const localeRouter = useLocaleRouter(); - const hasHandledSession = useRef(false); - - // Use the new useCredits hook - const { balance, isLoading, refresh } = useCredits(); // Get current user const currentUser = useCurrentUser(); + // Don't render if credits are disabled + if (!websiteConfig.credits.enableCredits) { + return null; + } + // show only enabled packages const creditPackages = Object.values(getCreditPackages()).filter( (pkg) => !pkg.disabled && pkg.price.priceId ); - // Check for payment success and show success message - useEffect(() => { - const sessionId = searchParams.get('session_id'); - if (sessionId && !hasHandledSession.current) { - hasHandledSession.current = true; - // Show success toast (delayed to avoid React lifecycle conflicts) - setTimeout(() => { - toast.success(t('creditsAdded')); - }, 0); - - // Refresh credits data to show updated balance - refresh(); - - // Clean up URL parameters - const url = new URL(window.location.href); - url.searchParams.delete('session_id'); - // Use Routes.SettingsCredits + url.search to properly handle locale routing - localeRouter.replace(Routes.SettingsCredits + url.search); - } - }, [searchParams, localeRouter, refresh]); - return ( -
- - - - {t('balance')} - - - -
-
- {/* */} -
- {isLoading ? ( - - ) : ( -
- {balance.toLocaleString()} -
- )} -
-
-
-
-
- - - - {t('title')} - - {t('description')} - - - -
- {creditPackages.map((creditPackage) => ( - - {creditPackage.popular && ( -
- - {t('popular')} - -
- )} - - - {/* Price and Credits - Left/Right Layout */} -
-
-
- - {creditPackage.credits.toLocaleString()} -
-
-
-
- {formatPrice( - creditPackage.price.amount, - creditPackage.price.currency - )} -
-
-
- -
- - {creditPackage.description} -
- - {/* purchase button using checkout */} - + + {t('title')} + + {t('description')} + + + +
+ {creditPackages.map((creditPackage) => ( + + {creditPackage.popular && ( +
+ - {t('purchase')} - - - - ))} -
- -
-
+ {t('popular')} + +
+ )} + + + {/* Price and Credits - Left/Right Layout */} +
+
+
+ + {creditPackage.credits.toLocaleString()} +
+
+
+
+ {formatPrice( + creditPackage.price.amount, + creditPackage.price.currency + )} +
+
+
+ +
+ + {creditPackage.description} +
+ + {/* purchase button using checkout */} + + {t('purchase')} + +
+
+ ))} +
+ + ); }