feat: add credit statistics in credits balance card
This commit is contained in:
parent
e3aa8eab55
commit
ac02ea780a
3
.gitignore
vendored
3
.gitignore
vendored
@ -38,6 +38,9 @@ certificates
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# claude code
|
||||
.claude
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
@ -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",
|
||||
|
@ -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": "积分余额",
|
||||
|
106
src/actions/get-credit-stats.ts
Normal file
106
src/actions/get-credit-stats.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
@ -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>
|
||||
|
@ -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: {
|
||||
|
Loading…
Reference in New Issue
Block a user