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:
parent
eafb3775e8
commit
f7f7be2ef0
@ -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"
|
||||
]
|
||||
|
@ -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' };
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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) {
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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
18
src/credits/index.ts
Normal 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);
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
10
src/types/index.d.ts
vendored
@ -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
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user