feat: add credits section to avatar configuration and update translations

- Added "Credits" entry to the avatar configuration for navigation.
- Updated English and Chinese translation files to include "Credits" label.
- Refactored error messages in credit payment actions for clarity.
- Enhanced loading state management in CreditPackages component.
- Replaced icons in CreditPackages component for improved UI consistency.
This commit is contained in:
javayhu 2025-07-05 23:25:37 +08:00
parent fe2b1bbe39
commit 13bee49f90
6 changed files with 46 additions and 35 deletions

View File

@ -432,6 +432,7 @@
"avatar": {
"dashboard": "Dashboard",
"billing": "Billing",
"credits": "Credits",
"settings": "Settings"
}
},

View File

@ -433,6 +433,7 @@
"avatar": {
"dashboard": "工作台",
"billing": "账单",
"credits": "积分",
"settings": "设置"
}
},

View File

@ -1,18 +1,17 @@
'use server';
import { CREDIT_PACKAGES } from '@/lib/constants';
import {
addMonthlyFreeCredits,
addRegisterGiftCredits,
consumeCredits,
getUserCredits,
addCredits,
} from '@/lib/credits';
import { getSession } from '@/lib/server';
import { confirmPaymentIntent, createPaymentIntent } from '@/payment';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
import { createPaymentIntent, confirmPaymentIntent } from '@/payment';
import { CREDIT_PACKAGES } from '@/lib/constants';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const actionClient = createSafeActionClient();
@ -73,7 +72,7 @@ export const createCreditPaymentIntent = actionClient
.schema(createPaymentIntentSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) return { success: false, error: 'User not authenticated' };
if (!session) return { success: false, error: 'Unauthorized' };
const { packageId } = parsedInput;
@ -115,7 +114,7 @@ export const confirmCreditPayment = actionClient
.schema(confirmPaymentSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) return { success: false, error: 'User not authenticated' };
if (!session) return { success: false, error: 'Unauthorized' };
const { packageId, paymentIntentId } = parsedInput;

View File

@ -8,29 +8,29 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { CREDIT_PACKAGES } from '@/lib/constants';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { CheckIcon, CoinsIcon, Loader2Icon } from 'lucide-react';
import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Separator } from '../../ui/separator';
import { StripePaymentForm } from './stripe-payment-form';
import { toast } from 'sonner';
export function CreditPackages() {
const [loadingCredits, setLoadingCredits] = useState(true);
const [loadingPackage, setLoadingPackage] = useState<string | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [paymentDialog, setPaymentDialog] = useState<{
isOpen: boolean;
clientSecret: string | null;
packageId: string | null;
clientSecret: string | null;
}>({
isOpen: false,
clientSecret: null,
packageId: null,
clientSecret: null,
});
const [credits, setCredits] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const fetchCredits = async () => {
try {
setLoading(true);
setLoadingCredits(true);
const result = await getCreditsAction();
if (result?.data?.success) {
console.log('CreditPackages, fetched credits:', result.data.credits);
@ -44,7 +44,7 @@ export function CreditPackages() {
console.error('CreditPackages, failed to fetch credits:', error);
toast.error('Failed to fetch credits');
} finally {
setLoading(false);
setLoadingCredits(false);
}
};
@ -55,13 +55,12 @@ export function CreditPackages() {
const handlePurchase = async (packageId: string) => {
try {
setLoadingPackage(packageId);
const result = await createCreditPaymentIntent({ packageId });
if (result?.data?.success && result?.data?.clientSecret) {
setPaymentDialog({
isOpen: true,
clientSecret: result.data.clientSecret,
packageId,
clientSecret: result.data.clientSecret,
});
} else {
const errorMessage = result?.data?.error || 'Failed to create payment intent';
@ -77,11 +76,11 @@ export function CreditPackages() {
};
const handlePaymentSuccess = () => {
console.log('CreditPackages, payment successful');
console.log('CreditPackages, payment success');
setPaymentDialog({
isOpen: false,
clientSecret: null,
packageId: null,
clientSecret: null,
});
// Refresh credit balance without page reload
@ -95,8 +94,8 @@ export function CreditPackages() {
console.log('CreditPackages, payment cancelled');
setPaymentDialog({
isOpen: false,
clientSecret: null,
packageId: null,
clientSecret: null,
});
};
@ -108,14 +107,14 @@ export function CreditPackages() {
<div className="space-y-6">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-medium">Credit Balance</CardTitle>
<CardTitle className="text-lg font-semibold">Credit Balance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex items-center space-x-2">
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
<div className="text-2xl font-bold">
{loading ? (
{loadingCredits ? (
<span className="animate-pulse">...</span>
) : (
credits?.toLocaleString() || 0
@ -127,7 +126,7 @@ export function CreditPackages() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-lg font-medium">Credit Packages</h2>
<h2 className="text-lg font-semibold">Credit Packages</h2>
<p className="text-sm text-muted-foreground">
Purchase additional credits to use our services
</p>
@ -153,7 +152,8 @@ export function CreditPackages() {
<div className="flex items-center justify-between py-2">
<div className="text-left">
<div className="text-2xl font-semibold flex items-center gap-2">
<CoinsIcon className="h-4 w-4 text-muted-foreground" /> {pkg.credits.toLocaleString()}
<CoinsIcon className="h-4 w-4 text-muted-foreground" />
{pkg.credits.toLocaleString()}
</div>
</div>
<div className="text-right">
@ -164,7 +164,7 @@ export function CreditPackages() {
</div>
<div className="text-sm text-muted-foreground text-left py-2 flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-green-500" />
<CircleCheckBigIcon className="h-4 w-4 text-green-500" />
{pkg.description}
</div>

View File

@ -28,15 +28,23 @@ interface StripePaymentFormProps {
onPaymentCancel: () => void;
}
/**
* StripePaymentForm is a component that displays a payment form for a credit package.
* It uses the Stripe Elements API to display a payment form.
*
* @param props - The props for the StripePaymentForm component.
* @returns The StripePaymentForm component.
*/
export function StripePaymentForm(props: StripePaymentFormProps) {
const { resolvedTheme: theme } = useTheme();
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
}
const stripePromise = useMemo(() => {
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set');
}
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}, []);
const { resolvedTheme: theme } = useTheme();
const options = useMemo(() => ({
clientSecret: props.clientSecret,
appearance: {
@ -110,11 +118,7 @@ function PaymentForm({
onPaymentSuccess();
} else {
console.error('PaymentForm, payment error:', result?.data?.error);
throw new Error(
result?.data?.error ||
result?.serverError ||
'Failed to confirm payment'
);
throw new Error( result?.data?.error || 'Failed to confirm payment' );
}
} else {
console.error('PaymentForm, no payment intent found');
@ -180,7 +184,7 @@ function PaymentForm({
</>
) : (
<>
Pay {formatPrice(packageInfo.price, 'USD')}
Pay {/* {formatPrice(packageInfo.price, 'USD')} */}
</>
)}
</Button>

View File

@ -3,6 +3,7 @@
import { Routes } from '@/routes';
import type { MenuItem } from '@/types';
import {
CoinsIcon,
CreditCardIcon,
LayoutDashboardIcon,
Settings2Icon,
@ -33,6 +34,11 @@ export function getAvatarLinks(): MenuItem[] {
href: Routes.SettingsBilling,
icon: <CreditCardIcon className="size-4 shrink-0" />,
},
{
title: t('credits'),
href: Routes.SettingsCredits,
icon: <CoinsIcon className="size-4 shrink-0" />,
},
{
title: t('settings'),
href: Routes.SettingsProfile,