feat: add CreditDetailViewer component and enhance credit transaction details in English and Chinese

This commit is contained in:
javayhu 2025-07-10 21:43:43 +08:00
parent b75e9eb282
commit 263440742a
6 changed files with 272 additions and 17 deletions

View File

@ -637,6 +637,10 @@
"SUBSCRIPTION_RENEWAL": "Subscription Renewal",
"LIFETIME_MONTHLY": "Lifetime Monthly"
},
"detailViewer": {
"title": "Credit Transaction Detail",
"close": "Close"
},
"expired": "Expired",
"never": "Never"
}

View File

@ -638,6 +638,10 @@
"SUBSCRIPTION_RENEWAL": "订阅续费",
"LIFETIME_MONTHLY": "终身月度"
},
"detailViewer": {
"title": "积分交易详情",
"close": "关闭"
},
"expired": "已过期",
"never": "永不"
}

View File

@ -74,7 +74,8 @@ export const getCreditTransactionsAction = actionClient
remainingAmount: creditTransaction.remainingAmount,
paymentId: creditTransaction.paymentId,
expirationDate: creditTransaction.expirationDate,
expirationDateProcessedAt: creditTransaction.expirationDateProcessedAt,
expirationDateProcessedAt:
creditTransaction.expirationDateProcessedAt,
createdAt: creditTransaction.createdAt,
updatedAt: creditTransaction.updatedAt,
})
@ -108,7 +109,10 @@ export const getCreditTransactionsAction = actionClient
console.error('get credit transactions error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch credit transactions',
error:
error instanceof Error
? error.message
: 'Failed to fetch credit transactions',
};
}
});

View File

@ -29,6 +29,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { CreditDetailViewer } from '@/credits/credit-detail-viewer';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { formatDate } from '@/lib/formatter';
import {
@ -223,7 +224,10 @@ export function CreditTransactionsTable({
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge variant={getTransactionTypeBadgeVariant(transaction.type)}>
<Badge
variant={getTransactionTypeBadgeVariant(transaction.type)}
className="cursor-pointer hover:bg-accent transition-colors"
>
{getTransactionTypeIcon(transaction.type)}
{getTransactionTypeDisplayName(transaction.type)}
</Badge>
@ -238,18 +242,7 @@ export function CreditTransactionsTable({
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<span
className={`font-medium ${
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{transaction.amount > 0 ? '+' : ''}
{transaction.amount.toLocaleString()}
</span>
</div>
);
return <CreditDetailViewer transaction={transaction} />;
},
},
{
@ -543,7 +536,7 @@ export function CreditTransactionsTable({
</Table>
</div>
<div className="flex items-center justify-between px-4">
<div className="flex items-center justify-between">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{total > 0 && <span>{tTable('totalRecords', { count: total })}</span>}
</div>

View File

@ -0,0 +1,250 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import { Separator } from '@/components/ui/separator';
import { useIsMobile } from '@/hooks/use-mobile';
import { formatDate } from '@/lib/formatter';
import {
ClockIcon,
GiftIcon,
MinusCircleIcon,
RefreshCwIcon,
ShoppingCartIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { CREDIT_TRANSACTION_TYPE } from './types';
// Define the credit transaction interface (matching the one in the table)
export interface CreditTransaction {
id: string;
userId: string;
type: string;
description: string | null;
amount: number;
remainingAmount: number | null;
paymentId: string | null;
expirationDate: Date | null;
expirationDateProcessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface CreditDetailViewerProps {
transaction: CreditTransaction;
}
export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) {
const t = useTranslations('Dashboard.settings.credits.transactions');
const isMobile = useIsMobile();
// Get transaction type icon
const getTransactionTypeIcon = (type: string) => {
switch (type) {
case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH:
return <RefreshCwIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT:
return <GiftIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.PURCHASE:
return <ShoppingCartIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.USAGE:
return <MinusCircleIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.EXPIRE:
return <ClockIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL:
return <RefreshCwIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY:
return <GiftIcon className="h-5 w-5" />;
default:
return null;
}
};
// Get transaction type badge variant
const getTransactionTypeBadgeVariant = (type: string) => {
switch (type) {
case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT:
case CREDIT_TRANSACTION_TYPE.PURCHASE:
case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH:
case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL:
case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY:
return 'outline' as const;
case CREDIT_TRANSACTION_TYPE.USAGE:
case CREDIT_TRANSACTION_TYPE.EXPIRE:
return 'destructive' as const;
default:
return 'outline' as const;
}
};
// Get transaction type display name
const getTransactionTypeDisplayName = (type: string) => {
switch (type) {
case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH:
return t('types.MONTHLY_REFRESH');
case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT:
return t('types.REGISTER_GIFT');
case CREDIT_TRANSACTION_TYPE.PURCHASE:
return t('types.PURCHASE');
case CREDIT_TRANSACTION_TYPE.USAGE:
return t('types.USAGE');
case CREDIT_TRANSACTION_TYPE.EXPIRE:
return t('types.EXPIRE');
case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL:
return t('types.SUBSCRIPTION_RENEWAL');
case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY:
return t('types.LIFETIME_MONTHLY');
default:
return type;
}
};
return (
<Drawer direction={isMobile ? 'bottom' : 'right'}>
<DrawerTrigger asChild>
<Button
variant="link"
className="cursor-pointer text-foreground w-fit px-3 text-left h-auto"
>
<div className="flex items-center gap-2">
<span
className={`font-medium ${
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{transaction.amount > 0 ? '+' : ''}
{transaction.amount.toLocaleString()}
</span>
</div>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('detailViewer.title')}</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
<div className="grid gap-4">
<div className="flex items-center gap-2">
{/* Transaction Type Badge */}
<Badge
variant={getTransactionTypeBadgeVariant(transaction.type)}
className="px-2 py-1"
>
{getTransactionTypeIcon(transaction.type)}
{getTransactionTypeDisplayName(transaction.type)}
</Badge>
</div>
{/* Basic Information */}
<div className="grid gap-3">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.amount')}:
</span>
<span
className={`font-medium ${
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{transaction.amount > 0 ? '+' : ''}
{transaction.amount.toLocaleString()}
</span>
</div>
{transaction.remainingAmount !== null && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.remainingAmount')}:
</span>
<span className="font-medium">
{transaction.remainingAmount.toLocaleString()}
</span>
</div>
)}
{transaction.description && (
<div className="grid gap-3">
<span className="text-muted-foreground text-xs">
{t('columns.description')}:
</span>
<span className="break-words">{transaction.description}</span>
</div>
)}
{transaction.paymentId && (
<div className="grid gap-3">
<span className="text-muted-foreground text-xs">
{t('columns.paymentId')}:
</span>
<span
className="font-mono text-sm cursor-pointer hover:bg-accent px-2 py-1 rounded border break-all"
onClick={() => {
navigator.clipboard.writeText(transaction.paymentId!);
toast.success(t('paymentIdCopied'));
}}
>
{transaction.paymentId}
</span>
</div>
)}
{transaction.expirationDate && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.expirationDate')}:
</span>
<span>{formatDate(transaction.expirationDate)}</span>
</div>
)}
{transaction.expirationDateProcessedAt && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.expirationDateProcessedAt')}:
</span>
<span>
{formatDate(transaction.expirationDateProcessedAt)}
</span>
</div>
)}
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid gap-3">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.createdAt')}:
</span>
<span>{formatDate(transaction.createdAt)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
{t('columns.updatedAt')}:
</span>
<span>{formatDate(transaction.updatedAt)}</span>
</div>
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline" className="cursor-pointer">
{t('detailViewer.close')}
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@ -828,7 +828,7 @@ export class StripeProvider implements PaymentProvider {
userId,
amount: Number.parseInt(credits),
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
description: `Credit package purchase: ${packageId} - ${credits} credits for $${amount}`,
description: `+${credits} credits for package ${packageId} (${amount})`,
paymentId: session.id,
expireDays: creditPackage.expireDays,
});