feat: add credits config in website config

- Added a new credits management system with configurable credit packages in website.tsx.
- Replaced hardcoded credit package definitions with a dynamic retrieval system using getCreditPackages and getCreditPackageById functions.
- Updated CreditPackages and StripePaymentForm components to utilize the new credit package structure.
- Removed obsolete CREDIT_PACKAGES constant from constants.ts to streamline the codebase.
- Enhanced type definitions for credit packages in types.ts for better clarity and maintainability.
- Updated README.md to reflect changes in credit packages configuration.
This commit is contained in:
javayhu 2025-07-07 00:04:45 +08:00
parent eafb3775e8
commit f7f7be2ef0
10 changed files with 163 additions and 91 deletions

View File

@ -23,6 +23,7 @@
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",
"src/types/index.d.ts",
"public/sw.js"
]
@ -81,6 +82,7 @@
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/payment/types.ts",
"src/credits/types.ts",
"src/types/index.d.ts",
"public/sw.js"
]

View File

@ -1,6 +1,6 @@
'use server';
import { CREDIT_PACKAGES } from '@/lib/constants';
import { getCreditPackageById } from '@/credits';
import {
addMonthlyFreeCredits,
addRegisterGiftCredits,
@ -77,7 +77,7 @@ export const createCreditPaymentIntent = actionClient
const { packageId } = parsedInput;
// Find the credit package
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
return { success: false, error: 'Invalid credit package' };
}
@ -127,7 +127,7 @@ export const confirmCreditPayment = actionClient
const { packageId, paymentIntentId } = parsedInput;
// Find the credit package
const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
return { success: false, error: 'Invalid credit package' };
}

View File

@ -1,11 +1,25 @@
'use client';
import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action';
import {
createCreditPaymentIntent,
getCreditsAction,
} from '@/actions/credits.action';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CREDIT_PACKAGES } from '@/lib/constants';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getCreditPackageById, getCreditPackages } from '@/credits';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { useTransactionStore } from '@/stores/transaction-store';
@ -21,6 +35,7 @@ export function CreditPackages() {
const [loadingPackage, setLoadingPackage] = useState<string | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const { refreshTrigger } = useTransactionStore();
const [paymentDialog, setPaymentDialog] = useState<{
isOpen: boolean;
packageId: string | null;
@ -67,8 +82,12 @@ export function CreditPackages() {
clientSecret: result.data.clientSecret,
});
} else {
const errorMessage = result?.data?.error || t('failedToCreatePaymentIntent');
console.error('CreditPackages, failed to create payment intent:', errorMessage);
const errorMessage =
result?.data?.error || t('failedToCreatePaymentIntent');
console.error(
'CreditPackages, failed to create payment intent:',
errorMessage
);
toast.error(errorMessage);
}
} catch (error) {
@ -98,15 +117,13 @@ export function CreditPackages() {
});
};
const getPackageInfo = (packageId: string) => {
return CREDIT_PACKAGES.find((pkg) => pkg.id === packageId);
};
return (
<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-semibold">{t('balance')}</CardTitle>
<CardTitle className="text-lg font-semibold">
{t('balance')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
@ -133,12 +150,20 @@ export function CreditPackages() {
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{CREDIT_PACKAGES.map((pkg) => (
<Card key={pkg.id} className={cn(`relative ${pkg.popular ? 'border-primary' : ''}`,
'shadow-none border-1 border-border')}>
{getCreditPackages().map((pkg) => (
<Card
key={pkg.id}
className={cn(
`relative ${pkg.popular ? 'border-primary' : ''}`,
'shadow-none border-1 border-border'
)}
>
{pkg.popular && (
<div className="absolute -top-3.5 left-1/2 transform -translate-x-1/2">
<Badge variant="default" className="bg-primary text-primary-foreground">
<Badge
variant="default"
className="bg-primary text-primary-foreground"
>
{t('popular')}
</Badge>
</div>
@ -203,7 +228,7 @@ export function CreditPackages() {
<StripePaymentForm
clientSecret={paymentDialog.clientSecret}
packageId={paymentDialog.packageId}
packageInfo={getPackageInfo(paymentDialog.packageId)!}
packageInfo={getCreditPackageById(paymentDialog.packageId)!}
onPaymentSuccess={handlePaymentSuccess}
onPaymentCancel={handlePaymentCancel}
/>

View File

@ -3,6 +3,7 @@
import { confirmCreditPayment } from '@/actions/credits.action';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
import type { CreditPackage } from '@/credits/types';
import { formatPrice } from '@/lib/formatter';
import { useTransactionStore } from '@/stores/transaction-store';
import {
@ -13,19 +14,15 @@ import {
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { CoinsIcon, Loader2Icon } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
interface StripePaymentFormProps {
clientSecret: string;
packageId: string;
packageInfo: {
credits: number;
price: number;
description: string;
};
packageInfo: CreditPackage;
onPaymentSuccess: () => void;
onPaymentCancel: () => void;
}
@ -47,13 +44,16 @@ export function StripePaymentForm(props: StripePaymentFormProps) {
}, []);
const { resolvedTheme: theme } = useTheme();
const options = useMemo(() => ({
clientSecret: props.clientSecret,
appearance: {
theme: (theme === "dark" ? "night" : "stripe") as "night" | "stripe",
},
loader: 'auto' as const,
}), [props.clientSecret, theme]);
const options = useMemo(
() => ({
clientSecret: props.clientSecret,
appearance: {
theme: (theme === 'dark' ? 'night' : 'stripe') as 'night' | 'stripe',
},
loader: 'auto' as const,
}),
[props.clientSecret, theme]
);
return (
<Elements stripe={stripePromise} options={options}>
@ -65,11 +65,7 @@ export function StripePaymentForm(props: StripePaymentFormProps) {
interface PaymentFormProps {
clientSecret: string;
packageId: string;
packageInfo: {
credits: number;
price: number;
description: string;
};
packageInfo: CreditPackage;
onPaymentSuccess: () => void;
onPaymentCancel: () => void;
}
@ -101,12 +97,12 @@ function PaymentForm({
// Confirm the payment using PaymentElement
const { error } = await stripe.confirmPayment({
elements,
redirect: "if_required",
redirect: 'if_required',
});
if (error) {
console.error('PaymentForm, payment error:', error);
throw new Error(error.message || "Payment failed");
throw new Error(error.message || 'Payment failed');
} else {
// The payment was successful
const paymentIntent = await stripe.retrievePaymentIntent(clientSecret);
@ -126,11 +122,11 @@ function PaymentForm({
// toast.success(`${packageInfo.credits} credits have been added to your account.`);
} else {
console.error('PaymentForm, payment error:', result?.data?.error);
throw new Error( result?.data?.error || 'Failed to confirm payment' );
throw new Error(result?.data?.error || 'Failed to confirm payment');
}
} else {
console.error('PaymentForm, no payment intent found');
throw new Error("No payment intent found");
throw new Error('No payment intent found');
}
}
} catch (error) {

View File

@ -129,4 +129,37 @@ export const websiteConfig: WebsiteConfig = {
},
},
},
credits: {
enableCredits: true,
packages: {
basic: {
id: 'basic',
credits: 100,
price: 990,
popular: false,
description: 'Perfect for getting started',
},
standard: {
id: 'standard',
credits: 200,
price: 1490,
popular: true,
description: 'Most popular package',
},
premium: {
id: 'premium',
credits: 500,
price: 3990,
popular: false,
description: 'Best value for heavy users',
},
enterprise: {
id: 'enterprise',
credits: 1000,
price: 6990,
popular: false,
description: 'Tailored for enterprises',
},
},
},
};

View File

@ -41,7 +41,7 @@ This document describes the credit management system implementation for the mksa
- `src/payment/types.ts` - Payment types (updated)
### Configuration
- `src/lib/constants.ts` - Credit packages configuration
- `src/config/website.tsx` - Credit packages configuration
- `env.example` - Environment variables template
### Pages
@ -98,23 +98,33 @@ await addCredits({
type: 'PURCHASE',
description: 'Credit purchase'
});
// Access credit packages from config
import { websiteConfig } from '@/config/website';
const creditPackages = Object.values(websiteConfig.credits.packages);
```
## Credit Packages Configuration
Edit `src/lib/constants.ts` to modify available credit packages:
Edit `src/config/website.tsx` to modify available credit packages:
```typescript
export const CREDIT_PACKAGES = [
{
id: 'basic',
credits: 100,
price: 9.99,
popular: false,
description: 'Perfect for getting started',
export const websiteConfig: WebsiteConfig = {
// ... other config
credits: {
enableCredits: true,
packages: {
basic: {
id: 'basic',
credits: 100,
price: 990, // Price in cents
popular: false,
description: 'Perfect for getting started',
},
// ... more packages
},
},
// ... more packages
];
};
```
## Webhook Events

18
src/credits/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { websiteConfig } from '@/config/website';
/**
* Get credit packages
* @returns Credit packages
*/
export function getCreditPackages() {
return Object.values(websiteConfig.credits.packages);
}
/**
* Get credit package by id
* @param id - Credit package id
* @returns Credit package
*/
export function getCreditPackageById(id: string) {
return getCreditPackages().find((pkg) => pkg.id === id);
}

View File

@ -2,9 +2,21 @@
* Credit transaction type enum
*/
export enum CREDIT_TRANSACTION_TYPE {
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // credits earned by monthly refresh
REGISTER_GIFT = 'REGISTER_GIFT', // credits earned by register gift
PURCHASE = 'PURCHASE', // credits earned by purchase
USAGE = 'USAGE', // credits spent by usage
EXPIRE = 'EXPIRE', // credits expired
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh
REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift
PURCHASE = 'PURCHASE', // Credits earned by purchase
USAGE = 'USAGE', // Credits spent by usage
EXPIRE = 'EXPIRE', // Credits expired
}
/**
* Credit package
*/
export interface CreditPackage {
id: string; // Unique identifier for the package
credits: number; // Number of credits in the package
price: number; // Price of the package in cents
popular: boolean; // Whether the package is popular
name?: string; // Display name of the package
description?: string; // Description of the package
}

View File

@ -1,38 +1,6 @@
export const PLACEHOLDER_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg==';
// credit package definition (price in cents)
export const CREDIT_PACKAGES = [
{
id: 'basic',
credits: 100,
price: 990, // 9.90 USD in cents
popular: false,
description: 'Perfect for getting started',
},
{
id: 'standard',
credits: 200,
price: 1490, // 14.90 USD in cents
popular: true,
description: 'Most popular package',
},
{
id: 'premium',
credits: 500,
price: 3990, // 39.90 USD in cents
popular: false,
description: 'Best value for heavy users',
},
{
id: 'enterprise',
credits: 1000,
price: 6990, // 69.90 USD in cents
popular: false,
description: 'Tailored for enterprises',
},
] as const;
// free monthly credits (10% of the smallest package)
export const FREE_MONTHLY_CREDITS = 50;
@ -41,5 +9,3 @@ export const REGISTER_GIFT_CREDITS = 100;
// default credit expiration days
export const CREDIT_EXPIRE_DAYS = 30;

10
src/types/index.d.ts vendored
View File

@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import type { PricePlan } from '@/payment/types';
import type { CreditPackage } from '@/credits/types';
/**
* website config, without translations
@ -17,6 +18,7 @@ export type WebsiteConfig = {
storage: StorageConfig;
payment: PaymentConfig;
price: PriceConfig;
credits: CreditsConfig;
};
/**
@ -148,6 +150,14 @@ export interface PriceConfig {
plans: Record<string, PricePlan>; // Plans indexed by ID
}
/**
* Credits configuration
*/
export interface CreditsConfig {
enableCredits: boolean; // Whether to enable credits
packages: Record<string, CreditPackage>; // Packages indexed by ID
}
/**
* menu item, used for navbar links, sidebar links, footer links
*/