feat: add credit statistics in credits balance card

This commit is contained in:
javayhu 2025-07-12 21:29:28 +08:00
parent e3aa8eab55
commit ac02ea780a
6 changed files with 209 additions and 12 deletions

3
.gitignore vendored
View File

@ -38,6 +38,9 @@ certificates
# vercel
.vercel
# claude code
.claude
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -584,7 +584,7 @@
},
"credits": {
"title": "Credits",
"description": "Manage your credits",
"description": "Manage your credit transactions",
"balance": {
"title": "Credit Balance",
"description": "Your credit balance",
@ -592,7 +592,10 @@
"creditsDescription": "You have {credits} credits",
"creditsExpired": "Credits expired",
"creditsAdded": "Credits have been added to your account",
"viewTransactions": "View Transactions"
"viewTransactions": "View Credit Transactions",
"subscriptionCredits": "{credits} credits from subscription this month",
"lifetimeCredits": "{credits} credits from lifetime plan this month",
"expiringCredits": "{credits} credits expiring on {date}"
},
"packages": {
"balance": "Credit Balance",

View File

@ -585,12 +585,18 @@
},
"credits": {
"title": "积分",
"description": "管理您的积分",
"description": "管理您的积分和消费记录",
"balance": {
"title": "积分余额",
"description": "您的积分余额",
"credits": "积分",
"creditsDescription": "您有 {credits} 积分",
"creditsExpired": "积分已过期"
"creditsExpired": "积分已过期",
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"subscriptionCredits": "本月订阅获得 {credits} 积分",
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
"expiringCredits": "{credits} 积分将在 {date} 过期"
},
"packages": {
"balance": "积分余额",

View File

@ -0,0 +1,106 @@
'use server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import { addDays } from 'date-fns';
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
// Create a safe action client
const actionClient = createSafeActionClient();
/**
* Get credit statistics for a user
*/
export const getCreditStatsAction = actionClient.action(async () => {
try {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
const db = await getDb();
const userId = session.user.id;
// Get credits expiring in the next 30 days
const thirtyDaysFromNow = addDays(new Date(), 30);
const expiringCredits = await db
.select({
amount: sum(creditTransaction.remainingAmount),
earliestExpiration: sql<Date>`MIN(${creditTransaction.expirationDate})`,
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
isNotNull(creditTransaction.expirationDate),
isNotNull(creditTransaction.remainingAmount),
gte(creditTransaction.remainingAmount, 1),
lte(creditTransaction.expirationDate, thirtyDaysFromNow),
gte(creditTransaction.expirationDate, new Date())
)
);
// Get credits from subscription renewals (recent 30 days)
const thirtyDaysAgo = addDays(new Date(), -30);
const subscriptionCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(
creditTransaction.type,
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
),
gte(creditTransaction.createdAt, thirtyDaysAgo)
)
);
// Get credits from monthly lifetime distribution (recent 30 days)
const lifetimeCredits = await db
.select({
amount: sum(creditTransaction.amount),
})
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY),
gte(creditTransaction.createdAt, thirtyDaysAgo)
)
);
return {
success: true,
data: {
expiringCredits: {
amount: Number(expiringCredits[0]?.amount) || 0,
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
},
subscriptionCredits: {
amount: Number(subscriptionCredits[0]?.amount) || 0,
},
lifetimeCredits: {
amount: Number(lifetimeCredits[0]?.amount) || 0,
},
},
};
} catch (error) {
console.error('get credit stats error:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to fetch credit statistics',
};
}
});

View File

@ -1,5 +1,6 @@
'use client';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { Button } from '@/components/ui/button';
import {
Card,
@ -12,13 +13,14 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { usePayment } from '@/hooks/use-payment';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter';
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 { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
export default function CreditsBalanceCard() {
@ -30,11 +32,47 @@ export default function CreditsBalanceCard() {
// Use the credits hook to get balance
const { balance, isLoading, error, refresh } = useCredits();
// Get payment info to check plan type
const { currentPlan } = usePayment();
// State for credit statistics
const [creditStats, setCreditStats] = useState<{
expiringCredits: {
amount: number;
earliestExpiration: string | Date | null;
};
subscriptionCredits: { amount: number };
lifetimeCredits: { amount: number };
} | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Don't render if credits are disabled
if (!websiteConfig.credits.enableCredits) {
return null;
}
// Function to fetch credit statistics
const fetchCreditStats = async () => {
setStatsLoading(true);
try {
const result = await getCreditStatsAction();
if (result?.data?.success && result.data.data) {
setCreditStats(result.data.data);
} else {
console.error('Failed to fetch credit stats:', result?.data?.error);
}
} catch (error) {
console.error('Failed to fetch credit stats:', error);
} finally {
setStatsLoading(false);
}
};
// Fetch stats on component mount
useEffect(() => {
fetchCreditStats();
}, []);
// Check for payment success and show success message
useEffect(() => {
const sessionId = searchParams.get('session_id');
@ -47,6 +85,8 @@ export default function CreditsBalanceCard() {
// Refresh credits data to show updated balance
refresh();
// Refresh credit stats
fetchCreditStats();
// Clean up URL parameters
const url = new URL(window.location.href);
@ -106,18 +146,57 @@ export default function CreditsBalanceCard() {
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Credits balance display */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-start space-x-4">
<div className="flex items-center space-x-2">
<CoinsIcon className="h-6 w-6 text-muted-foreground" />
{/* <CoinsIcon className="h-6 w-6 text-muted-foreground" /> */}
<div className="text-3xl font-medium">
{balance.toLocaleString()}
</div>
</div>
{/* <Badge variant="outline">{t('available')}</Badge> */}
{/* <Badge variant="outline">available</Badge> */}
</div>
{/* Balance information */}
{/* <div className="text-sm text-muted-foreground">{t('message')}</div> */}
<div className="text-sm text-muted-foreground space-y-2">
{/* Plan-based credits info */}
{!statsLoading && creditStats && (
<>
{/* Subscription credits (for paid plans) */}
{!currentPlan?.isFree &&
(creditStats.subscriptionCredits.amount > 0 ||
creditStats.lifetimeCredits.amount > 0) && (
<div className="flex items-center gap-2 text-muted-foreground">
<span>
{currentPlan?.isLifetime
? t('lifetimeCredits', {
credits: creditStats.lifetimeCredits.amount,
})
: t('subscriptionCredits', {
credits: creditStats.subscriptionCredits.amount,
})}
</span>
</div>
)}
{/* Expiring credits warning */}
{creditStats.expiringCredits.amount > 0 &&
creditStats.expiringCredits.earliestExpiration && (
<div className="flex items-center gap-2 text-muted-foreground">
<span>
{t('expiringCredits', {
credits: creditStats.expiringCredits.amount,
date: formatDate(
new Date(
creditStats.expiringCredits.earliestExpiration
)
),
})}
</span>
</div>
)}
</>
)}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button variant="default" className="cursor-pointer" asChild>

View File

@ -138,7 +138,7 @@ export const websiteConfig: WebsiteConfig = {
isLifetime: true,
credits: {
enable: true,
amount: 2000,
amount: 1000,
expireDays: 30,
},
},
@ -149,7 +149,7 @@ export const websiteConfig: WebsiteConfig = {
enableForFreePlan: false,
registerGiftCredits: {
enable: true,
credits: 100,
credits: 50,
expireDays: 30,
},
packages: {