Merge pull request #64 from MkSaaSHQ/dev/credits-v2

[feat] support credits v2
This commit is contained in:
javayhu 2025-07-13 00:57:08 +08:00 committed by GitHub
commit 368644b434
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 6660 additions and 381 deletions

7
.gitignore vendored
View File

@ -30,12 +30,17 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
certificates
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# claude code
.claude
# typescript
*.tsbuildinfo
next-env.d.ts
@ -53,4 +58,4 @@ next-env.d.ts
.wrangler
.dev.vars
.dev.vars*
!.dev.vars.example
!.dev.vars.example

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

@ -63,6 +63,7 @@ STORAGE_PUBLIC_URL=""
# https://mksaas.com/docs/payment#setup
# Get Stripe key and secret from https://dashboard.stripe.com
# -----------------------------------------------------------------------------
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
# Pro plan - monthly subscription
@ -71,6 +72,14 @@ NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=""
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=""
# Lifetime plan - one-time payment
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
# Credit package - basic
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=""
# Credit package - standard
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD=""
# Credit package - premium
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM=""
# Credit package - enterprise
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE=""
# -----------------------------------------------------------------------------
# Configurations
@ -153,6 +162,12 @@ NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
# -----------------------------------------------------------------------------
# Inngest
# https://mksaas.com/docs/jobs#setup
# -----------------------------------------------------------------------------
INNGEST_SIGNING_KEY=""
# -----------------------------------------------------------------------------
# AI
# https://mksaas.com/docs/ai

View File

@ -28,7 +28,21 @@
"save": "Save",
"loading": "Loading...",
"cancel": "Cancel",
"logoutFailed": "Failed to log out"
"logoutFailed": "Failed to log out",
"table": {
"totalRecords": "Total {count} records",
"noResults": "No results",
"loading": "Loading...",
"columns": "Columns",
"rowsPerPage": "Rows per page",
"page": "Page",
"firstPage": "First Page",
"lastPage": "Last Page",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"ascending": "Asc",
"descending": "Desc"
}
},
"PricingPage": {
"title": "Pricing",
@ -99,6 +113,24 @@
}
}
},
"CreditPackages": {
"basic": {
"name": "Basic",
"description": "Basic credits package description"
},
"standard": {
"name": "Standard",
"description": "Standard credits package description"
},
"premium": {
"name": "Premium",
"description": "Premium credits package description"
},
"enterprise": {
"name": "Enterprise",
"description": "Enterprise credits package description"
}
},
"NotFoundPage": {
"title": "404",
"message": "Sorry, the page you are looking for does not exist.",
@ -432,6 +464,7 @@
"avatar": {
"dashboard": "Dashboard",
"billing": "Billing",
"credits": "Credits",
"settings": "Settings"
}
},
@ -457,14 +490,6 @@
"banReason": "Ban Reason",
"banExpires": "Ban Expires"
},
"noResults": "No results",
"firstPage": "First Page",
"lastPage": "Last Page",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"rowsPerPage": "Rows per page",
"page": "Page",
"loading": "Loading...",
"admin": "Admin",
"user": "User",
"email": {
@ -474,8 +499,8 @@
"emailCopied": "Email copied to clipboard",
"banned": "Banned",
"active": "Active",
"joined": "Joined",
"updated": "Updated",
"joined": "Joined at",
"updated": "Updated at",
"ban": {
"reason": "Ban Reason",
"reasonPlaceholder": "Enter the reason for banning this user",
@ -546,6 +571,7 @@
"createCustomerPortalFailed": "Failed to open Stripe customer portal"
},
"price": "Price:",
"periodStartDate": "Period start date:",
"nextBillingDate": "Next billing date:",
"trialEnds": "Trial ends:",
"freePlanMessage": "You are currently on the free plan with limited features",
@ -554,7 +580,74 @@
"manageBilling": "Manage Billing",
"upgradePlan": "Upgrade Plan",
"retry": "Retry",
"errorMessage": "Failed to get data"
"errorMessage": "Failed to get data",
"paymentSuccess": "Payment successful"
},
"credits": {
"title": "Credits",
"description": "Manage your credit transactions",
"balance": {
"title": "Credit Balance",
"description": "Your credit balance",
"credits": "Credits",
"creditsDescription": "You have {credits} credits",
"creditsExpired": "Credits expired",
"creditsAdded": "Credits have been added to your account",
"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": {
"title": "Credit Packages",
"description": "Purchase additional credits to use our services",
"purchase": "Purchase",
"processing": "Processing...",
"popular": "Popular",
"completePurchase": "Complete Your Purchase",
"failedToFetchCredits": "Failed to fetch credits",
"failedToCreatePaymentIntent": "Failed to create payment intent",
"failedToInitiatePayment": "Failed to initiate payment",
"cancel": "Cancel",
"purchaseFailed": "Purchase credits failed",
"checkoutFailed": "Failed to create checkout session",
"loading": "Loading...",
"pay": "Pay"
},
"transactions": {
"title": "Credit Transactions",
"error": "Failed to get credit transactions",
"search": "Search credit transactions...",
"paymentIdCopied": "Payment ID copied to clipboard",
"columns": {
"columns": "Columns",
"id": "ID",
"type": "Type",
"description": "Description",
"amount": "Amount",
"remainingAmount": "Remaining Amount",
"paymentId": "Payment ID",
"expirationDate": "Expiration Date",
"expirationDateProcessedAt": "Expiration Date Processed At",
"createdAt": "Created At",
"updatedAt": "Updated At"
},
"types": {
"MONTHLY_REFRESH": "Monthly Refresh",
"REGISTER_GIFT": "Register Gift",
"PURCHASE": "Purchased Credits",
"USAGE": "Consumed Credits",
"EXPIRE": "Expired Credits",
"SUBSCRIPTION_RENEWAL": "Subscription Renewal",
"LIFETIME_MONTHLY": "Lifetime Monthly"
},
"detailViewer": {
"title": "Credit Transaction Detail",
"close": "Close"
},
"expired": "Expired",
"never": "Never"
}
},
"notification": {
"title": "Notification",

View File

@ -28,7 +28,21 @@
"saving": "保存中...",
"loading": "加载中...",
"cancel": "取消",
"logoutFailed": "退出失败"
"logoutFailed": "退出失败",
"table": {
"totalRecords": "总共 {count} 条记录",
"noResults": "无结果",
"loading": "加载中...",
"columns": "列",
"rowsPerPage": "每页行数",
"page": "页",
"firstPage": "第一页",
"lastPage": "最后一页",
"nextPage": "下一页",
"previousPage": "上一页",
"ascending": "升序",
"descending": "降序"
}
},
"PricingPage": {
"title": "价格",
@ -94,12 +108,29 @@
"feature-3": "专属支持",
"feature-4": "企业级安全",
"feature-5": "高级集成",
"feature-6": "自定义域名",
"feature-7": "自定义品牌",
"feature-8": "终身更新"
"feature-6": "自定义品牌",
"feature-7": "终身更新"
}
}
},
"CreditPackages": {
"basic": {
"name": "基础版",
"description": "基础版功能介绍放这里"
},
"standard": {
"name": "标准版",
"description": "标准版功能介绍放这里"
},
"premium": {
"name": "高级版",
"description": "高级版功能介绍放这里"
},
"enterprise": {
"name": "企业版",
"description": "企业版功能介绍放这里"
}
},
"NotFoundPage": {
"title": "404",
"message": "抱歉,您正在寻找的页面不存在",
@ -376,13 +407,13 @@
"comparator": {
"title": "Comparator 组件"
},
"faqs": {
"title": "FAQs 组件"
"faq": {
"title": "FAQ 组件"
},
"login": {
"title": "Login 组件"
},
"sign-up": {
"signup": {
"title": "Signup 组件"
},
"forgot-password": {
@ -433,6 +464,7 @@
"avatar": {
"dashboard": "工作台",
"billing": "账单",
"credits": "积分",
"settings": "设置"
}
},
@ -458,14 +490,6 @@
"banReason": "封禁原因",
"banExpires": "封禁到期时间"
},
"noResults": "没有结果",
"firstPage": "第一页",
"lastPage": "最后一页",
"nextPage": "下一页",
"previousPage": "上一页",
"rowsPerPage": "每页行数",
"page": "页",
"loading": "加载中...",
"admin": "管理员",
"user": "用户",
"email": {
@ -547,6 +571,7 @@
"createCustomerPortalFailed": "打开Stripe客户界面失败"
},
"price": "价格:",
"periodStartDate": "周期开始日期:",
"nextBillingDate": "下次账单日期:",
"trialEnds": "试用结束日期:",
"freePlanMessage": "您当前使用的是功能有限的免费方案",
@ -555,7 +580,74 @@
"manageBilling": "管理账单",
"upgradePlan": "升级方案",
"retry": "重试",
"errorMessage": "获取数据失败"
"errorMessage": "获取数据失败",
"paymentSuccess": "支付成功"
},
"credits": {
"title": "积分",
"description": "管理您的积分交易",
"balance": {
"title": "积分余额",
"description": "您的积分余额",
"credits": "积分",
"creditsDescription": "您有 {credits} 积分",
"creditsExpired": "积分已过期",
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"subscriptionCredits": "本月订阅获得 {credits} 积分",
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
"expiringCredits": "{credits} 积分将在 {date} 过期"
},
"packages": {
"title": "积分套餐",
"description": "购买积分以使用我们的更多服务",
"purchase": "购买",
"processing": "处理中...",
"popular": "热门",
"completePurchase": "请支付订单",
"failedToFetchCredits": "获取积分失败",
"failedToCreatePaymentIntent": "创建付款意向失败",
"failedToInitiatePayment": "发起付款失败",
"cancel": "取消",
"purchaseFailed": "购买积分失败",
"checkoutFailed": "创建支付会话失败",
"loading": "加载中...",
"pay": "支付"
},
"transactions": {
"title": "积分记录",
"error": "获取积分交易记录失败",
"search": "搜索积分交易记录...",
"paymentIdCopied": "支付ID已复制到剪贴板",
"columns": {
"columns": "列",
"id": "ID",
"type": "类型",
"description": "描述",
"amount": "金额",
"remainingAmount": "剩余金额",
"paymentId": "支付编号",
"expirationDate": "过期日期",
"expirationDateProcessedAt": "过期处理时间",
"createdAt": "创建时间",
"updatedAt": "更新时间"
},
"types": {
"MONTHLY_REFRESH": "每月赠送",
"REGISTER_GIFT": "注册赠送",
"PURCHASE": "购买积分",
"USAGE": "使用积分",
"EXPIRE": "过期积分",
"SUBSCRIPTION_RENEWAL": "订阅月度积分",
"LIFETIME_MONTHLY": "终身月度积分"
},
"detailViewer": {
"title": "积分交易详情",
"close": "关闭"
},
"expired": "已过期",
"never": "永不过期"
}
},
"notification": {
"title": "通知",

View File

@ -96,6 +96,7 @@
"fumadocs-core": "^15.5.3",
"fumadocs-mdx": "^11.6.8",
"fumadocs-ui": "^15.5.3",
"inngest": "^3.40.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.483.0",
"motion": "^12.4.3",

1600
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
'use server';
import { consumeCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
const actionClient = createSafeActionClient();
// consume credits schema
const consumeSchema = z.object({
amount: z.number().min(1),
description: z.string().optional(),
});
/**
* Consume credits
*/
export const consumeCreditsAction = actionClient
.schema(consumeSchema)
.action(async ({ parsedInput }) => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to consume credits');
return { success: false, error: 'Unauthorized' };
}
try {
await consumeCredits({
userId: session.user.id,
amount: parsedInput.amount,
description:
parsedInput.description || `Consume credits: ${parsedInput.amount}`,
});
return { success: true };
} catch (error) {
console.error('consume credits error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Something went wrong',
};
}
});

View File

@ -64,7 +64,7 @@ export const createCheckoutAction = actionClient
if (!plan) {
return {
success: false,
error: 'Plan not found',
error: 'Price plan not found',
};
}
@ -87,7 +87,7 @@ export const createCheckoutAction = actionClient
// Create the checkout session with localized URLs
const successUrl = getUrlWithLocale(
'/settings/billing?session_id={CHECKOUT_SESSION_ID}',
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
locale
);
const cancelUrl = getUrlWithLocale(Routes.Pricing, locale);

View File

@ -0,0 +1,124 @@
'use server';
import { websiteConfig } from '@/config/website';
import { getCreditPackageById } from '@/credits/server';
import { getSession } from '@/lib/server';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { createCreditCheckout } from '@/payment';
import type { CreateCreditCheckoutParams } from '@/payment/types';
import { Routes } from '@/routes';
import { getLocale } from 'next-intl/server';
import { createSafeActionClient } from 'next-safe-action';
import { cookies } from 'next/headers';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Credit checkout schema for validation
// metadata is optional, and may contain referral information if you need
const creditCheckoutSchema = z.object({
userId: z.string().min(1, { message: 'User ID is required' }),
packageId: z.string().min(1, { message: 'Package ID is required' }),
priceId: z.string().min(1, { message: 'Price ID is required' }),
metadata: z.record(z.string()).optional(),
});
/**
* Create a checkout session for a credit package
*/
export const createCreditCheckoutSession = actionClient
.schema(creditCheckoutSchema)
.action(async ({ parsedInput }) => {
const { userId, packageId, priceId, metadata } = parsedInput;
// Get the current user session for authorization
const session = await getSession();
if (!session) {
console.warn(
`unauthorized request to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Unauthorized',
};
}
// Only allow users to create their own checkout session
if (session.user.id !== userId) {
console.warn(
`current user ${session.user.id} is not authorized to create credit checkout session for user ${userId}`
);
return {
success: false,
error: 'Not authorized to do this action',
};
}
try {
// Get the current locale from the request
const locale = await getLocale();
// Find the credit package
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
return {
success: false,
error: 'Credit package not found',
};
}
// Add metadata to identify this as a credit purchase
const customMetadata: Record<string, string> = {
...metadata,
type: 'credit_purchase',
packageId,
credits: creditPackage.credits.toString(),
userId: session.user.id,
userName: session.user.name,
};
// https://datafa.st/docs/stripe-checkout-api
// if datafast analytics is enabled, add the revenue attribution to the metadata
if (websiteConfig.features.enableDatafastRevenueTrack) {
const cookieStore = await cookies();
customMetadata.datafast_visitor_id =
cookieStore.get('datafast_visitor_id')?.value ?? '';
customMetadata.datafast_session_id =
cookieStore.get('datafast_session_id')?.value ?? '';
}
// Create checkout session with credit-specific URLs
const successUrl = getUrlWithLocale(
`${Routes.SettingsBilling}?session_id={CHECKOUT_SESSION_ID}`,
locale
);
const cancelUrl = getUrlWithLocale(Routes.SettingsBilling, locale);
const params: CreateCreditCheckoutParams = {
packageId,
priceId,
customerEmail: session.user.email,
metadata: customMetadata,
successUrl,
cancelUrl,
locale,
};
const result = await createCreditCheckout(params);
// console.log('create credit checkout session result:', result);
return {
success: true,
data: result,
};
} catch (error) {
console.error('Create credit checkout session error:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to create checkout session',
};
}
});

View File

@ -0,0 +1,21 @@
'use server';
import { getUserCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createSafeActionClient } from 'next-safe-action';
const actionClient = createSafeActionClient();
/**
* Get current user's credits
*/
export const getCreditBalanceAction = actionClient.action(async () => {
const session = await getSession();
if (!session) {
console.warn('unauthorized request to get credit balance');
return { success: false, error: 'Unauthorized' };
}
const credits = await getUserCredits(session.user.id);
return { success: true, credits };
});

View File

@ -0,0 +1,110 @@
'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';
const CREDITS_EXPIRATION_DAYS = 31;
const CREDITS_MONTHLY_DAYS = 31;
// 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) {
console.warn('unauthorized request to get credit stats');
return {
success: false,
error: 'Unauthorized',
};
}
const db = await getDb();
const userId = session.user.id;
// Get credits expiring in the next CREDITS_EXPIRATION_DAYS days
const expirationDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS);
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, expirationDaysFromNow),
gte(creditTransaction.expirationDate, new Date())
)
);
// Get credits from subscription renewals (recent CREDITS_MONTHLY_DAYS days)
const monthlyRefreshDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS);
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, monthlyRefreshDaysAgo)
)
);
// Get credits from monthly lifetime distribution (recent CREDITS_MONTHLY_DAYS 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, monthlyRefreshDaysAgo)
)
);
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',
};
}
});

View File

@ -0,0 +1,124 @@
'use server';
import { getDb } from '@/db';
import { creditTransaction } from '@/db/schema';
import { getSession } from '@/lib/server';
import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
// Create a safe action client
const actionClient = createSafeActionClient();
// Define the schema for getCreditTransactions parameters
const getCreditTransactionsSchema = z.object({
pageIndex: z.number().min(0).default(0),
pageSize: z.number().min(1).max(100).default(10),
search: z.string().optional().default(''),
sorting: z
.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
)
.optional()
.default([]),
});
// Define sort field mapping
const sortFieldMap = {
type: creditTransaction.type,
amount: creditTransaction.amount,
remainingAmount: creditTransaction.remainingAmount,
description: creditTransaction.description,
createdAt: creditTransaction.createdAt,
updatedAt: creditTransaction.updatedAt,
expirationDate: creditTransaction.expirationDate,
expirationDateProcessedAt: creditTransaction.expirationDateProcessedAt,
paymentId: creditTransaction.paymentId,
} as const;
// Create a safe action for getting credit transactions
export const getCreditTransactionsAction = actionClient
.schema(getCreditTransactionsSchema)
.action(async ({ parsedInput }) => {
try {
const session = await getSession();
if (!session) {
return {
success: false,
error: 'Unauthorized',
};
}
const { pageIndex, pageSize, search, sorting } = parsedInput;
// search by type, amount, paymentId, description, and restrict to current user
const where = search
? and(
eq(creditTransaction.userId, session.user.id),
or(
ilike(creditTransaction.type, `%${search}%`),
ilike(creditTransaction.amount, `%${search}%`),
ilike(creditTransaction.remainingAmount, `%${search}%`),
ilike(creditTransaction.paymentId, `%${search}%`),
ilike(creditTransaction.description, `%${search}%`)
)
)
: eq(creditTransaction.userId, session.user.id);
const offset = pageIndex * pageSize;
// Get the sort configuration
const sortConfig = sorting[0];
const sortField = sortConfig?.id
? sortFieldMap[sortConfig.id as keyof typeof sortFieldMap]
: creditTransaction.createdAt;
const sortDirection = sortConfig?.desc ? desc : asc;
const db = await getDb();
const [items, [{ count }]] = await Promise.all([
db
.select({
id: creditTransaction.id,
userId: creditTransaction.userId,
type: creditTransaction.type,
description: creditTransaction.description,
amount: creditTransaction.amount,
remainingAmount: creditTransaction.remainingAmount,
paymentId: creditTransaction.paymentId,
expirationDate: creditTransaction.expirationDate,
expirationDateProcessedAt:
creditTransaction.expirationDateProcessedAt,
createdAt: creditTransaction.createdAt,
updatedAt: creditTransaction.updatedAt,
})
.from(creditTransaction)
.where(where)
.orderBy(sortDirection(sortField))
.limit(pageSize)
.offset(offset),
db
.select({ count: sql`count(*)` })
.from(creditTransaction)
.where(where),
]);
return {
success: true,
data: {
items,
total: Number(count),
},
};
} catch (error) {
console.error('get credit transactions error:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to fetch credit transactions',
};
}
});

View File

@ -44,8 +44,13 @@ export const getUsersAction = actionClient
try {
const { pageIndex, pageSize, search, sorting } = parsedInput;
// search by name, email, and customerId
const where = search
? or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`))
? or(
ilike(user.name, `%${search}%`),
ilike(user.email, `%${search}%`),
ilike(user.customerId, `%${search}%`)
)
: undefined;
const offset = pageIndex * pageSize;

View File

@ -1,10 +1,10 @@
"use client";
'use client';
import { Zap, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { Sparkles, Zap } from 'lucide-react';
export type QualityMode = "performance" | "quality";
export type QualityMode = 'performance' | 'quality';
interface QualityModeToggleProps {
value: QualityMode;
@ -25,9 +25,9 @@ export function QualityModeToggle({
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("performance");
onValueChange('performance');
toast({
description: "Switching to faster models for quicker generation",
description: 'Switching to faster models for quicker generation',
duration: 2000,
});
}}
@ -39,10 +39,10 @@ export function QualityModeToggle({
variant="secondary"
disabled={disabled}
onClick={() => {
onValueChange("quality");
onValueChange('quality');
toast({
description:
"Switching to higher quality models for better results",
'Switching to higher quality models for better results',
duration: 2000,
});
}}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState } from 'react';
export function Stopwatch({ startTime }: { startTime: number }) {
const [elapsed, setElapsed] = useState(0);
@ -12,6 +12,8 @@ export function Stopwatch({ startTime }: { startTime: number }) {
}, [startTime]);
return (
<div className="text-lg text-zinc-500 font-mono">{(elapsed / 1000).toFixed(1)}s</div>
<div className="text-lg text-zinc-500 font-mono">
{(elapsed / 1000).toFixed(1)}s
</div>
);
}

View File

@ -1,5 +1,5 @@
export const imageHelpers = {
base64ToBlob: (base64Data: string, type = "image/png"): Blob => {
base64ToBlob: (base64Data: string, type = 'image/png'): Blob => {
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
@ -13,7 +13,7 @@ export const imageHelpers = {
generateImageFileName: (provider: string): string => {
const uniqueId = Math.random().toString(36).substring(2, 8);
return `${provider}-${uniqueId}`.replace(/[^a-z0-9-]/gi, "");
return `${provider}-${uniqueId}`.replace(/[^a-z0-9-]/gi, '');
},
shareOrDownload: async (
@ -22,7 +22,7 @@ export const imageHelpers = {
): Promise<void> => {
const fileName = imageHelpers.generateImageFileName(provider);
const blob = imageHelpers.base64ToBlob(imageData);
const file = new File([blob], `${fileName}.png`, { type: "image/png" });
const file = new File([blob], `${fileName}.png`, { type: 'image/png' });
try {
if (navigator.share) {
@ -31,13 +31,13 @@ export const imageHelpers = {
title: `Image generated by ${provider}`,
});
} else {
throw new Error("Share API not available");
throw new Error('Share API not available');
}
} catch (error) {
// Fall back to download for any error (including share cancellation)
console.error("Error sharing/downloading:", error);
console.error('Error sharing/downloading:', error);
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
const link = document.createElement('a');
link.href = blobUrl;
link.download = `${fileName}.png`;
document.body.appendChild(link);
@ -48,6 +48,6 @@ export const imageHelpers = {
},
formatModelId: (modelId: string): string => {
return modelId.split("/").pop() || modelId;
return modelId.split('/').pop() || modelId;
},
};

View File

@ -0,0 +1,51 @@
'use client';
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useCredits } from '@/hooks/use-credits';
import { CoinsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
const CONSUME_CREDITS = 50;
export function ConsumeCreditCard() {
const { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
const [loading, setLoading] = useState(false);
const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true);
const success = await consumeCredits(
CONSUME_CREDITS,
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
);
setLoading(false);
if (success) {
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
} else {
toast.error('Failed to consume credits, please try again later.');
}
};
return (
<div className="flex flex-col items-center gap-8 p-4 border rounded-lg">
<div className="w-full flex flex-row items-center justify-end">
<CreditsBalanceButton />
</div>
<Button
variant="outline"
size="sm"
onClick={handleConsume}
disabled={isLoading || loading}
className="w-full cursor-pointer"
>
<CoinsIcon className="size-4" />
<span>Consume {CONSUME_CREDITS} credits</span>
</Button>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { ConsumeCreditCard } from '@/ai/text/components/consume-credit-card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
@ -28,7 +29,7 @@ export default async function AITextPage() {
<div className="max-w-4xl mx-auto space-y-8">
{/* about section */}
<div className="relative max-w-(--breakpoint-md) mx-auto mb-24 mt-8 md:mt-16">
<div className="mx-auto flex flex-col justify-between">
<div className="mx-auto flex flex-col justify-between gap-8">
<div className="flex flex-row items-center gap-8">
{/* avatar and name */}
<div className="flex items-center gap-8">
@ -48,6 +49,9 @@ export default async function AITextPage() {
</div>
</div>
</div>
{/* simulate consume credits */}
<ConsumeCreditCard />
</div>
</div>
</div>

View File

@ -26,7 +26,7 @@ export default async function BillingLayout({ children }: BillingLayoutProps) {
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6 space-y-10">
<div className="px-4 lg:px-6 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('billing.title')}

View File

@ -1,5 +1,23 @@
import BillingCard from '@/components/settings/billing/billing-card';
import CreditsBalanceCard from '@/components/settings/billing/credits-balance-card';
import { CreditPackages } from '@/components/settings/credits/credit-packages';
import { websiteConfig } from '@/config/website';
export default function BillingPage() {
return <BillingCard />;
return (
<div className="space-y-8">
{/* Billing and Credits Balance Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<BillingCard />
{websiteConfig.credits.enableCredits && <CreditsBalanceCard />}
</div>
{/* Credit Packages */}
{websiteConfig.credits.enableCredits && (
<div className="w-full">
<CreditPackages />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,46 @@
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { getTranslations } from 'next-intl/server';
interface CreditsLayoutProps {
children: React.ReactNode;
}
export default async function CreditsLayout({ children }: CreditsLayoutProps) {
const t = await getTranslations('Dashboard.settings');
const breadcrumbs = [
{
label: t('title'),
isCurrentPage: false,
},
{
label: t('credits.title'),
isCurrentPage: true,
},
];
return (
<>
<DashboardHeader breadcrumbs={breadcrumbs} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('credits.title')}
</h1>
<p className="text-muted-foreground mt-2">
{t('credits.description')}
</p>
</div>
{children}
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from 'lucide-react';
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -0,0 +1,16 @@
import { CreditTransactionsPageClient } from '@/components/settings/credits/credit-transactions-page';
import { websiteConfig } from '@/config/website';
import { Routes } from '@/routes';
import { redirect } from 'next/navigation';
/**
* Credits page, show credit transactions
*/
export default function CreditsPage() {
// If credits are disabled, redirect to billing page
if (!websiteConfig.credits.enableCredits) {
redirect(Routes.SettingsBilling);
}
return <CreditTransactionsPageClient />;
}

View File

@ -28,7 +28,7 @@ export default async function NotificationsLayout({
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6 space-y-10">
<div className="px-4 lg:px-6 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('notification.title')}

View File

@ -2,8 +2,10 @@ import { NewsletterFormCard } from '@/components/settings/notification/newslette
export default function NotificationPage() {
return (
<div className="grid gap-8 @lg/main:grid-cols-2">
<NewsletterFormCard />
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<NewsletterFormCard />
</div>
</div>
);
}

View File

@ -26,7 +26,7 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6 space-y-10">
<div className="px-4 lg:px-6 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('profile.title')}

View File

@ -4,8 +4,12 @@ import { UpdateNameCard } from '@/components/settings/profile/update-name-card';
export default function ProfilePage() {
return (
<div className="flex flex-col gap-8">
<UpdateAvatarCard />
<UpdateNameCard />
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateAvatarCard />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<UpdateNameCard />
</div>
</div>
);
}

View File

@ -28,7 +28,7 @@ export default async function SecurityLayout({
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6 space-y-10">
<div className="px-4 lg:px-6 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('security.title')}

View File

@ -4,8 +4,12 @@ import { PasswordCardWrapper } from '@/components/settings/security/password-car
export default function SecurityPage() {
return (
<div className="flex flex-col gap-8">
<PasswordCardWrapper />
<DeleteAccountCard />
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<PasswordCardWrapper />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<DeleteAccountCard />
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
import { CreditsProvider } from '@/providers/credits-provider';
import type { Translations } from 'fumadocs-ui/i18n';
import { RootProvider } from 'fumadocs-ui/provider';
import { useTranslations } from 'next-intl';
@ -25,6 +26,7 @@ interface ProvidersProps {
* - RootProvider: Provides the root provider for Fumadocs UI.
* - TooltipProvider: Provides the tooltip to the app.
* - PaymentProvider: Provides the payment state to the app.
* - CreditsProvider: Provides the credits state to the app.
*/
export function Providers({ children, locale }: ProvidersProps) {
const theme = useTheme();
@ -61,7 +63,9 @@ export function Providers({ children, locale }: ProvidersProps) {
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>{children}</PaymentProvider>
<PaymentProvider>
<CreditsProvider>{children}</CreditsProvider>
</PaymentProvider>
</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>

View File

@ -0,0 +1,20 @@
import { inngest } from '@/inngest/client';
import { NextResponse } from 'next/server';
// Opt out of caching; every request should send a new event
export const dynamic = 'force-dynamic';
// Create a simple async Next.js API route handler
export async function GET() {
console.log('Send event to Inngest start');
// Send your event payload to Inngest
await inngest.send({
name: 'test/hello.world',
data: {
email: 'testUser@example.com',
},
});
console.log('Send event to Inngest end');
return NextResponse.json({ message: 'Event sent!' });
}

View File

@ -0,0 +1,19 @@
import { serve } from 'inngest/next';
import { inngest } from '../../../inngest/client';
import { distributeCreditsDaily, helloWorld } from '../../../inngest/functions';
/**
* Inngest route
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*
* Next.js Edge Functions hosted on Vercel can also stream responses back to Inngest,
* giving you a much higher request timeout of 15 minutes (up from 10 seconds on the Vercel Hobby plan!).
* To enable this, set your runtime to "edge" (see Quickstart for Using Edge Functions | Vercel Docs)
* and add the streaming: "allow" option to your serve handler:
* https://www.inngest.com/docs/learn/serving-inngest-functions#framework-next-js
*/
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [helloWorld, distributeCreditsDaily],
});

View File

@ -24,6 +24,7 @@ import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
@ -149,7 +150,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
/>
<div>
<DrawerTitle>{user.name}</DrawerTitle>
<DrawerDescription>{user.email}</DrawerDescription>
{/* <DrawerDescription>{user.email}</DrawerDescription> */}
</div>
</div>
</DrawerHeader>
@ -188,12 +189,51 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
</div>
</div>
{/* information */}
<div className="text-muted-foreground">
{t('joined')}: {formatDate(user.createdAt)}
{/* email */}
{user.email && (
<div className="grid gap-3">
<span className="text-muted-foreground text-xs">
{t('columns.email')}:
</span>
<span
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
onClick={() => {
navigator.clipboard.writeText(user.email!);
toast.success(t('emailCopied'));
}}
>
{user.email}
</span>
</div>
)}
{/* customerId */}
{user.customerId && (
<div className="grid gap-3">
<span className="text-muted-foreground text-xs">
{t('columns.customerId')}:
</span>
<a
href={getStripeDashboardCustomerUrl(user.customerId)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm hover:underline hover:underline-offset-4 rounded break-all"
>
{user.customerId}
</a>
</div>
)}
</div>
{/* Timestamps */}
<div className="grid gap-3">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('joined')}:</span>
<span>{formatDate(user.createdAt)}</span>
</div>
<div className="text-muted-foreground">
{t('updated')}: {formatDate(user.updatedAt)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('updated')}:</span>
<span>{formatDate(user.updatedAt)}</span>
</div>
</div>
<Separator />

View File

@ -6,7 +6,7 @@ import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
export function UsersPageClient() {
@ -17,42 +17,44 @@ export function UsersPageClient() {
const [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
};
fetchUsers();
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<>
<UsersTable

View File

@ -6,6 +6,8 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
@ -27,6 +29,7 @@ import {
import type { User } from '@/lib/auth-types';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
import {
type ColumnDef,
type ColumnFiltersState,
@ -40,7 +43,6 @@ import {
useReactTable,
} from '@tanstack/react-table';
import {
ArrowUpDownIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
@ -68,20 +70,47 @@ function DataTableColumnHeader<TData, TValue>({
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
const tTable = useTranslations('Common.table');
if (!column.getCanSort()) {
return <div className={className}>{title}</div>;
}
const isSorted = column.getIsSorted(); // 'asc' | 'desc' | false
return (
<div className={className}>
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{title}
<ArrowUpDownIcon className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2 h-8 data-[state=open]:bg-accent"
>
{title}
{isSorted === 'asc' && <IconCaretUpFilled className="h-4 w-4" />}
{isSorted === 'desc' && <IconCaretDownFilled className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
<DropdownMenuRadioGroup
value={isSorted === false ? '' : isSorted}
onValueChange={(value) => {
if (value === 'asc') column.toggleSorting(false);
else if (value === 'desc') column.toggleSorting(true);
}}
>
<DropdownMenuRadioItem value="asc">
<span className="flex items-center gap-2">
{tTable('ascending')}
</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="desc">
<span className="flex items-center gap-2">
{tTable('descending')}
</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@ -115,7 +144,10 @@ export function UsersTable({
onSortingChange,
}: UsersTableProps) {
const t = useTranslations('Dashboard.admin.users');
const [sorting, setSorting] = useState<SortingState>([]);
const tTable = useTranslations('Common.table');
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@ -145,6 +177,8 @@ export function UsersTable({
const user = row.original;
return <UserDetailViewer user={user} />;
},
minSize: 120,
size: 140,
},
{
accessorKey: 'email',
@ -173,6 +207,8 @@ export function UsersTable({
</div>
);
},
minSize: 180,
size: 200,
},
{
accessorKey: 'role',
@ -193,6 +229,8 @@ export function UsersTable({
</div>
);
},
minSize: 100,
size: 120,
},
{
accessorKey: 'createdAt',
@ -207,6 +245,8 @@ export function UsersTable({
</div>
);
},
minSize: 140,
size: 160,
},
{
accessorKey: 'customerId',
@ -235,6 +275,8 @@ export function UsersTable({
</div>
);
},
minSize: 120,
size: 140,
},
{
accessorKey: 'banned',
@ -256,6 +298,8 @@ export function UsersTable({
</div>
);
},
minSize: 100,
size: 120,
},
{
accessorKey: 'banReason',
@ -270,6 +314,8 @@ export function UsersTable({
</div>
);
},
minSize: 120,
size: 140,
},
{
accessorKey: 'banExpires',
@ -287,6 +333,8 @@ export function UsersTable({
</div>
);
},
minSize: 140,
size: 160,
},
];
@ -395,16 +443,7 @@ export function UsersTable({
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t('loading')}
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@ -426,7 +465,7 @@ export function UsersTable({
colSpan={columns.length}
className="h-24 text-center"
>
{t('noResults')}
{loading ? tTable('loading') : tTable('noResults')}
</TableCell>
</TableRow>
)}
@ -440,7 +479,7 @@ export function UsersTable({
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{t('rowsPerPage')}
{tTable('rowsPerPage')}
</Label>
<Select
value={`${pageSize}`}
@ -466,7 +505,7 @@ export function UsersTable({
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{t('page')} {pageIndex + 1} {' / '}
{tTable('page')} {pageIndex + 1} {' / '}
{Math.max(1, Math.ceil(total / pageSize))}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
@ -476,7 +515,7 @@ export function UsersTable({
onClick={() => onPageChange(0)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('firstPage')}</span>
<span className="sr-only">{tTable('firstPage')}</span>
<ChevronsLeftIcon />
</Button>
<Button
@ -486,7 +525,7 @@ export function UsersTable({
onClick={() => onPageChange(pageIndex - 1)}
disabled={pageIndex === 0}
>
<span className="sr-only">{t('previousPage')}</span>
<span className="sr-only">{tTable('previousPage')}</span>
<ChevronLeftIcon />
</Button>
<Button
@ -496,7 +535,7 @@ export function UsersTable({
onClick={() => onPageChange(pageIndex + 1)}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('nextPage')}</span>
<span className="sr-only">{tTable('nextPage')}</span>
<ChevronRightIcon />
</Button>
<Button
@ -508,7 +547,7 @@ export function UsersTable({
}
disabled={pageIndex + 1 >= Math.ceil(total / pageSize)}
>
<span className="sr-only">{t('lastPage')}</span>
<span className="sr-only">{tTable('lastPage')}</span>
<ChevronsRightIcon />
</Button>
</div>

View File

@ -8,6 +8,7 @@ import {
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import React, { type ReactNode } from 'react';
import { CreditsBalanceButton } from '../layout/credits-balance-button';
import LocaleSwitcher from '../layout/locale-switcher';
import { ModeSwitcher } from '../layout/mode-switcher';
import { ThemeSelector } from '../layout/theme-selector';
@ -69,12 +70,13 @@ export function DashboardHeader({
</Breadcrumb>
{/* dashboard header actions on the right side */}
<div className="ml-auto flex items-center gap-3 px-4">
<div className="ml-auto flex items-center gap-3 pl-4">
{actions}
{isDemo && <ThemeSelector />}
<CreditsBalanceButton />
<ModeSwitcher />
<LocaleSwitcher />
{isDemo && <ThemeSelector />}
</div>
</div>
</header>

View File

@ -68,7 +68,7 @@ export function DashboardSidebar({
</SidebarHeader>
<SidebarContent>
<SidebarMain items={filteredSidebarLinks} />
{!isPending && mounted && <SidebarMain items={filteredSidebarLinks} />}
</SidebarContent>
<SidebarFooter className="flex flex-col gap-4">

View File

@ -0,0 +1,42 @@
'use client';
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';
export function CreditsBalanceButton() {
// If credits are not enabled, return null
if (!websiteConfig.credits.enableCredits) {
return null;
}
const router = useLocaleRouter();
// Use the new useCredits hook
const { balance, isLoading } = useCredits();
const handleClick = () => {
router.push(Routes.SettingsCredits);
};
return (
<Button
variant="outline"
size="sm"
className="h-8 gap-2 px-2 text-sm font-medium cursor-pointer"
onClick={handleClick}
>
<CoinsIcon className="h-4 w-4" />
<span className="">
{isLoading ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
balance.toLocaleString()
)}
</span>
</Button>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function CreditsBalanceMenu() {
// If credits are not enabled, return null
if (!websiteConfig.credits.enableCredits) {
return null;
}
const t = useTranslations('Marketing.avatar');
const router = useLocaleRouter();
// Use the new useCredits hook
const { balance, isLoading } = useCredits();
const handleClick = () => {
router.push(Routes.SettingsCredits);
};
return (
<div
className="flex items-center justify-between cursor-pointer w-full"
onClick={handleClick}
>
<div className="flex items-center space-x-2.5">
<CoinsIcon className="h-4 w-4" />
<p className="text-sm">{t('credits')}</p>
</div>
<div className="flex items-center">
<p className="text-sm font-medium">
{isLoading ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
balance.toLocaleString()
)}
</p>
</div>
</div>
);
}

View File

@ -94,7 +94,10 @@ export function NavbarMobile({
{isPending ? (
<Skeleton className="size-8 border rounded-full" />
) : currentUser ? (
<UserButtonMobile user={currentUser} />
<>
{/* <CreditsBalanceButton /> */}
<UserButtonMobile user={currentUser} />
</>
) : null}
<Button

View File

@ -6,8 +6,7 @@ import { Logo } from '@/components/layout/logo';
import { ModeSwitcher } from '@/components/layout/mode-switcher';
import { NavbarMobile } from '@/components/layout/navbar-mobile';
import { UserButton } from '@/components/layout/user-button';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button';
import { Button, buttonVariants } from '@/components/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
@ -25,8 +24,7 @@ import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { ArrowUpRightIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Skeleton } from '../ui/skeleton';
import LocaleSwitcher from './locale-switcher';
@ -222,7 +220,10 @@ export function Navbar({ scroll }: NavBarProps) {
{!mounted || isPending ? (
<Skeleton className="size-8 border rounded-full" />
) : currentUser ? (
<UserButton user={currentUser} />
<>
{/* <CreditsBalanceButton /> */}
<UserButton user={currentUser} />
</>
) : (
<div className="flex items-center gap-x-4">
<LoginWrapper mode="modal" asChild>

View File

@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { getAvatarLinks } from '@/config/avatar-config';
import { websiteConfig } from '@/config/website';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
@ -17,6 +18,7 @@ import { LogOutIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { CreditsBalanceMenu } from './credits-balance-menu';
interface UserButtonProps {
user: User;
@ -57,6 +59,7 @@ export function UserButton({ user }: UserButtonProps) {
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* show user name and email */}
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">{user.name}</p>
@ -67,6 +70,16 @@ export function UserButton({ user }: UserButtonProps) {
</div>
<DropdownMenuSeparator />
{/* show credits balance button if credits are enabled */}
{websiteConfig.credits.enableCredits && (
<>
<DropdownMenuItem className="cursor-pointer">
<CreditsBalanceMenu />
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{avatarLinks.map((item) => (
<DropdownMenuItem
key={item.title}

View File

@ -107,14 +107,14 @@ export function PricingCard({
<Card
className={cn(
'flex flex-col h-full',
plan.recommended && 'relative',
plan.popular && 'relative',
isCurrentPlan &&
'border-blue-500 shadow-lg shadow-blue-100 dark:shadow-blue-900/20',
className
)}
>
{/* show popular badge if plan is recommended */}
{plan.recommended && (
{plan.popular && (
<span
className="absolute inset-x-0 -top-3 mx-auto flex h-6 w-fit items-center rounded-full px-3 py-1 text-xs font-medium border
bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 border-purple-200 dark:border-purple-800 shadow-sm"
@ -166,7 +166,7 @@ export function PricingCard({
) : isCurrentPlan ? (
<Button
disabled
className="mt-4 w-full bg-blue-100 dark:bg-blue-800
className="mt-4 w-full bg-blue-100 dark:bg-blue-800
text-blue-700 dark:text-blue-100 hover:bg-blue-100 dark:hover:bg-blue-800 border border-blue-200 dark:border-blue-700"
>
{t('yourCurrentPlan')}
@ -203,7 +203,7 @@ export function PricingCard({
{hasTrialPeriod && (
<div className="my-4">
<span
className="inline-block px-2.5 py-1.5 text-xs font-medium rounded-md
className="inline-block px-2.5 py-1.5 text-xs font-medium rounded-md
bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800 shadow-sm"
>
{t('daysTrial', { days: price.trialPeriodDays as number })}

View File

@ -14,17 +14,23 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { getPricePlans } from '@/config/price-config';
import { usePayment } from '@/hooks/use-payment';
import { LocaleLink } from '@/i18n/navigation';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate, formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { PlanIntervals } from '@/payment/types';
import { Routes } from '@/routes';
import { RefreshCwIcon } from 'lucide-react';
import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
export default function BillingCard() {
const t = useTranslations('Dashboard.settings.billing');
const searchParams = useSearchParams();
const localeRouter = useLocaleRouter();
const hasHandledSession = useRef(false);
const {
isLoading: isLoadingPayment,
@ -57,6 +63,11 @@ export default function BillingCard() {
(price) => price.priceId === subscription?.priceId
);
// Get current period start date
const currentPeriodStart = subscription?.currentPeriodStart
? formatDate(subscription.currentPeriodStart)
: null;
// Format next billing date if subscription is active
const nextBillingDate = subscription?.currentPeriodEnd
? formatDate(subscription.currentPeriodEnd)
@ -66,82 +77,93 @@ export default function BillingCard() {
const isPageLoading = isLoadingPayment || isLoadingSession;
// console.log('billing card, isLoadingPayment', isLoadingPayment, 'isLoadingSession', isLoadingSession);
// Check for payment success and show success message
useEffect(() => {
const sessionId = searchParams.get('session_id');
if (sessionId && !hasHandledSession.current) {
hasHandledSession.current = true;
setTimeout(() => {
toast.success(t('paymentSuccess'));
}, 0);
const url = new URL(window.location.href);
url.searchParams.delete('session_id');
localeRouter.replace(Routes.SettingsBilling + url.search);
}
}, [searchParams, localeRouter]);
// Render loading skeleton
if (isPageLoading) {
return (
<div className="grid gap-8 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t('currentPlan.title')}</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
</CardContent>
<CardFooter>
<Skeleton className="h-10 w-full" />
</CardFooter>
</Card>
</div>
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="space-y-3">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-4/5" />
<Skeleton className="h-6 w-4/5" />
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Skeleton className="h-10 w-1/2" />
</CardFooter>
</Card>
);
}
// Render error state
if (loadPaymentError) {
return (
<div className="grid gap-8 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t('currentPlan.title')}</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-destructive text-sm">{loadPaymentError}</div>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full cursor-pointer"
onClick={() => refetch()}
>
<RefreshCwIcon className="size-4 mr-1" />
{t('retry')}
</Button>
</CardFooter>
</Card>
</div>
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">{loadPaymentError}</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button
variant="outline"
className="cursor-pointer"
onClick={() => refetch()}
>
<RefreshCwIcon className="size-4 mr-1" />
{t('retry')}
</Button>
</CardFooter>
</Card>
);
}
// currentPlanFromStore maybe null, so we need to check if it is null
if (!currentPlanFromStore) {
return (
<div className="grid gap-8 md:grid-cols-2">
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0 flex flex-col'
)}
>
<CardHeader>
<CardTitle>{t('currentPlan.title')}</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
{t('currentPlan.noPlan')}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
</CardFooter>
</Card>
</div>
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
{t('currentPlan.noPlan')}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-muted rounded-none">
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
</CardFooter>
</Card>
);
}
@ -150,98 +172,106 @@ export default function BillingCard() {
// console.log('billing card, currentUser', currentUser);
return (
<div className="grid md:grid-cols-2 gap-8">
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0 flex flex-col'
)}
>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Plan name and status */}
<div className="flex items-center justify-between">
<div className="text-3xl font-medium">{currentPlan?.name}</div>
{subscription && (
<Badge variant="outline">
{subscription?.status === 'trialing'
? t('status.trial')
: subscription?.status === 'active'
? t('status.active')
: ''}
</Badge>
)}
</div>
{/* Free plan message */}
{isFreePlan && (
<div className="text-sm text-muted-foreground">
{t('freePlanMessage')}
</div>
)}
{/* Lifetime plan message */}
{isLifetimeMember && (
<div className="text-sm text-muted-foreground">
{t('lifetimeMessage')}
</div>
)}
{/* Subscription plan message */}
{subscription && currentPrice && (
<div className="text-sm text-muted-foreground space-y-2">
<div>
{t('price')}{' '}
{formatPrice(currentPrice.amount, currentPrice.currency)} /{' '}
{currentPrice.interval === PlanIntervals.MONTH
? t('interval.month')
: currentPrice.interval === PlanIntervals.YEAR
? t('interval.year')
: t('interval.oneTime')}
</div>
{nextBillingDate && (
<div>
{t('nextBillingDate')} {nextBillingDate}
</div>
)}
{subscription.status === 'trialing' &&
subscription.currentPeriodEnd && (
<div className="text-amber-500">
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('currentPlan.title')}
</CardTitle>
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Plan name and status */}
<div className="flex items-center justify-start space-x-4">
<div className="text-3xl font-medium">{currentPlan?.name}</div>
{subscription &&
(subscription.status === 'trialing' ||
subscription.status === 'active') && (
<Badge variant="outline" className="text-xs">
{subscription.status === 'trialing' ? (
<div className="flex items-center space-x-2">
<ClockIcon className="size-3 mr-1 text-amber-600" />
{t('status.trial')}
</div>
) : (
<div className="flex items-center space-x-2">
<CheckCircleIcon className="size-3 mr-1 text-green-600" />
{t('status.active')}
</div>
)}
</div>
)}
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
{/* user is on free plan, show upgrade plan button */}
{isFreePlan && (
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
)}
</Badge>
)}
</div>
{/* user is lifetime member, show manage billing button */}
{isLifetimeMember && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageBilling')}
</CustomerPortalButton>
)}
{/* Free plan message */}
{isFreePlan && (
<div className="text-sm text-muted-foreground">
{t('freePlanMessage')}
</div>
)}
{/* user has subscription, show manage subscription button */}
{subscription && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageSubscription')}
</CustomerPortalButton>
)}
</CardFooter>
</Card>
</div>
{/* Lifetime plan message */}
{isLifetimeMember && (
<div className="text-sm text-muted-foreground">
{t('lifetimeMessage')}
</div>
)}
{/* Subscription plan message */}
{subscription && currentPrice && (
<div className="text-sm text-muted-foreground space-y-2">
{/* <div>
{t('price')}{' '}
{formatPrice(currentPrice.amount, currentPrice.currency)} /{' '}
{currentPrice.interval === PlanIntervals.MONTH
? t('interval.month')
: currentPrice.interval === PlanIntervals.YEAR
? t('interval.year')
: t('interval.oneTime')}
</div> */}
{currentPeriodStart && (
<div className="text-muted-foreground">
{t('periodStartDate')} {currentPeriodStart}
</div>
)}
{nextBillingDate && (
<div className="text-muted-foreground">
{t('nextBillingDate')} {nextBillingDate}
</div>
)}
{subscription.status === 'trialing' &&
subscription.currentPeriodEnd && (
<div className="text-amber-600">
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
</div>
)}
</div>
)}
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
{/* user is on free plan, show upgrade plan button */}
{isFreePlan && (
<Button variant="default" className="cursor-pointer" asChild>
<LocaleLink href={Routes.Pricing}>{t('upgradePlan')}</LocaleLink>
</Button>
)}
{/* user is lifetime member, show manage billing button */}
{isLifetimeMember && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageBilling')}
</CustomerPortalButton>
)}
{/* user has subscription, show manage subscription button */}
{subscription && currentUser && (
<CustomerPortalButton userId={currentUser.id} className="">
{t('manageSubscription')}
</CustomerPortalButton>
)}
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,220 @@
'use client';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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 { Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
export default function CreditsBalanceCard() {
const t = useTranslations('Dashboard.settings.credits.balance');
const searchParams = useSearchParams();
const localeRouter = useLocaleRouter();
const hasHandledSession = useRef(false);
// Use the credits hook to get balance
const {
balance,
isLoading: isLoadingBalance,
error,
refresh: refreshBalance,
} = 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 [isLoadingStats, setIsLoadingStats] = useState(true);
// Don't render if credits are disabled
if (!websiteConfig.credits.enableCredits) {
return null;
}
// Function to fetch credit statistics
const fetchCreditStats = async () => {
setIsLoadingStats(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 {
setIsLoadingStats(false);
}
};
// Fetch stats on component mount
useEffect(() => {
fetchCreditStats();
}, []);
// Check for payment success and show success message
useEffect(() => {
const sessionId = searchParams.get('session_id');
if (sessionId && !hasHandledSession.current) {
hasHandledSession.current = true;
// Show success toast (delayed to avoid React lifecycle conflicts)
setTimeout(() => {
toast.success(t('creditsAdded'));
}, 0);
// Refresh credits data to show updated balance
refreshBalance();
// Refresh credit stats
fetchCreditStats();
// Clean up URL parameters
const url = new URL(window.location.href);
url.searchParams.delete('session_id');
localeRouter.replace(Routes.SettingsBilling + url.search);
}
}, [searchParams, localeRouter, refreshBalance, fetchCreditStats]);
// Render loading skeleton
const isPageLoading = isLoadingBalance || isLoadingStats;
if (isPageLoading) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="flex items-center justify-start space-x-4">
<Skeleton className="h-6 w-1/5" />
</div>
<div className="text-sm text-muted-foreground space-y-2">
<Skeleton className="h-6 w-2/5" />
<Skeleton className="h-6 w-3/5" />
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Skeleton className="h-10 w-1/2" />
</CardFooter>
</Card>
);
}
// Render error state
if (error) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">{error}</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button variant="outline" className="cursor-pointer" asChild>
<LocaleLink href={Routes.SettingsCredits}>
{t('viewTransactions')}
</LocaleLink>
</Button>
</CardFooter>
</Card>
);
}
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
{/* Credits balance display */}
<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" /> */}
<div className="text-3xl font-medium">
{balance.toLocaleString()}
</div>
</div>
{/* <Badge variant="outline">available</Badge> */}
</div>
{/* Balance information */}
<div className="text-sm text-muted-foreground space-y-2">
{/* Plan-based credits info */}
{!isLoadingStats && 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-amber-600">
<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>
<LocaleLink href={Routes.SettingsCredits}>
{t('viewTransactions')}
</LocaleLink>
</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,135 @@
'use client';
import { createCreditCheckoutSession } from '@/actions/create-credit-checkout-session';
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
interface CreditCheckoutButtonProps {
userId: string;
packageId: string;
priceId: string;
metadata?: Record<string, string>;
variant?:
| 'default'
| 'outline'
| 'destructive'
| 'secondary'
| 'ghost'
| 'link'
| null;
size?: 'default' | 'sm' | 'lg' | 'icon' | null;
className?: string;
children?: React.ReactNode;
disabled?: boolean;
}
/**
* Credit Checkout Button
*
* This client component creates a Stripe checkout session for credit purchases
* and redirects to it. It's used to initiate the credit purchase process.
*
* NOTICE: Login is required when using this button.
*/
export function CreditCheckoutButton({
userId,
packageId,
priceId,
metadata,
variant = 'default',
size = 'default',
className,
children,
disabled = false,
}: CreditCheckoutButtonProps) {
const t = useTranslations('Dashboard.settings.credits.packages');
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
try {
setIsLoading(true);
const mergedMetadata = metadata ? { ...metadata } : {};
// add promotekit_referral to metadata if enabled promotekit affiliate
if (websiteConfig.features.enablePromotekitAffiliate) {
const promotekitReferral =
typeof window !== 'undefined'
? (window as any).promotekit_referral
: undefined;
if (promotekitReferral) {
console.log(
'create credit checkout button, promotekitReferral:',
promotekitReferral
);
mergedMetadata.promotekit_referral = promotekitReferral;
}
}
// add affonso_referral to metadata if enabled affonso affiliate
if (websiteConfig.features.enableAffonsoAffiliate) {
const affonsoReferral =
typeof document !== 'undefined'
? (() => {
const match = document.cookie.match(
/(?:^|; )affonso_referral=([^;]*)/
);
return match ? decodeURIComponent(match[1]) : null;
})()
: null;
if (affonsoReferral) {
console.log(
'create credit checkout button, affonsoReferral:',
affonsoReferral
);
mergedMetadata.affonso_referral = affonsoReferral;
}
}
// Create checkout session using server action
const result = await createCreditCheckoutSession({
userId,
packageId,
priceId,
metadata:
Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
});
// Redirect to checkout page
if (result?.data?.success && result.data.data?.url) {
window.location.href = result.data.data?.url;
} else {
console.error('Create credit checkout session error, result:', result);
toast.error(t('checkoutFailed'));
}
} catch (error) {
console.error('Create credit checkout session error:', error);
toast.error(t('checkoutFailed'));
} finally {
setIsLoading(false);
}
};
return (
<Button
variant={variant}
size={size}
className={className}
onClick={handleClick}
disabled={isLoading || disabled}
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
{t('loading')}
</>
) : (
children
)}
</Button>
);
}

View File

@ -0,0 +1,234 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Drawer,
DrawerClose,
DrawerContent,
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 {
BanknoteIcon,
ClockIcon,
CoinsIcon,
GemIcon,
GiftIcon,
HandCoinsIcon,
ShoppingCartIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { CREDIT_TRANSACTION_TYPE } from '../../../credits/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 <HandCoinsIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT:
return <GiftIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE:
return <ShoppingCartIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.USAGE:
return <CoinsIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.EXPIRE:
return <ClockIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL:
return <BanknoteIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY:
return <GemIcon className="h-5 w-5" />;
default:
return null;
}
};
// 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_PACKAGE:
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="outline"
className="hover:bg-accent transition-colors"
>
{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

@ -0,0 +1,119 @@
'use client';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { getCreditPackages } from '@/config/credits-config';
import { websiteConfig } from '@/config/website';
import { useCurrentUser } from '@/hooks/use-current-user';
import { usePayment } from '@/hooks/use-payment';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CreditCheckoutButton } from './credit-checkout-button';
/**
* Credit packages component
* @returns Credit packages component
*/
export function CreditPackages() {
// If credits are not enabled, return null
if (!websiteConfig.credits.enableCredits) {
return null;
}
const t = useTranslations('Dashboard.settings.credits.packages');
// Get current user and payment info
const currentUser = useCurrentUser();
const { currentPlan } = usePayment();
// Check if user is on free plan and enableForFreePlan is false
const isFreePlan = currentPlan?.isFree === true;
if (isFreePlan && !websiteConfig.credits.enableForFreePlan) {
return null;
}
// show only enabled packages
const creditPackages = Object.values(getCreditPackages()).filter(
(pkg) => !pkg.disabled && pkg.price.priceId
);
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{creditPackages.map((creditPackage) => (
<Card
key={creditPackage.id}
className={cn(
`relative ${creditPackage.popular ? 'border-primary' : ''}`,
'shadow-none border-1 border-border'
)}
>
{creditPackage.popular && (
<div className="absolute -top-3.5 left-1/2 transform -translate-x-1/2">
<Badge
variant="default"
className="bg-primary text-primary-foreground"
>
{t('popular')}
</Badge>
</div>
)}
<CardContent className="space-y-4">
{/* Price and Credits - Left/Right Layout */}
<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" />
{creditPackage.credits.toLocaleString()}
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{formatPrice(
creditPackage.price.amount,
creditPackage.price.currency
)}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground text-left py-2 flex items-center gap-2">
<CircleCheckBigIcon className="h-4 w-4 text-green-500" />
{creditPackage.description}
</div>
{/* purchase button using checkout */}
<CreditCheckoutButton
userId={currentUser?.id ?? ''}
packageId={creditPackage.id}
priceId={creditPackage.price.priceId}
className="w-full cursor-pointer mt-2"
variant={creditPackage.popular ? 'default' : 'outline'}
disabled={!creditPackage.price.priceId}
>
{t('purchase')}
</CreditCheckoutButton>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import type { CreditTransaction } from '@/components/settings/credits/credit-transactions-table';
import { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
export function CreditTransactionsPageClient() {
const t = useTranslations('Dashboard.settings.credits.transactions');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<CreditTransaction[]>([]);
const [total, setTotal] = useState(0);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await getCreditTransactionsAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error(
'CreditTransactions, fetch credit transactions error:',
error
);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting]);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<CreditTransactionsTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
);
}

View File

@ -0,0 +1,649 @@
'use client';
import { CreditDetailViewer } from '@/components/settings/credits/credit-detail-viewer';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { formatDate } from '@/lib/formatter';
import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons';
import {
IconCaretDownFilled,
IconCaretUpFilled,
IconSortAscending2,
} from '@tabler/icons-react';
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
BanknoteIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
ChevronsUpDownIcon,
ClockIcon,
CoinsIcon,
GemIcon,
GiftIcon,
HandCoinsIcon,
ShoppingCartIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '../../ui/badge';
import { Label } from '../../ui/label';
// Define the credit transaction interface
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 DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: any;
title: string;
}
function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
const tTable = useTranslations('Common.table');
// Only show dropdown for sortable columns
if (!column.getCanSort()) {
return <div className={className}>{title}</div>;
}
// Determine current sort state
const isSorted = column.getIsSorted(); // 'asc' | 'desc' | false
return (
<div className={className}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 data-[state=open]:bg-accent flex items-center gap-1"
>
<span>{title}</span>
{isSorted === 'asc' && <IconCaretUpFilled className="h-4 w-4" />}
{isSorted === 'desc' && <IconCaretDownFilled className="h-4 w-4" />}
{/* {!isSorted && <ChevronsUpDownIcon className="h-4 w-4" />} */}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
<DropdownMenuRadioGroup
value={isSorted === false ? '' : isSorted}
onValueChange={(value) => {
if (value === 'asc') column.toggleSorting(false);
else if (value === 'desc') column.toggleSorting(true);
}}
>
<DropdownMenuRadioItem value="asc">
<span className="flex items-center gap-2">
{tTable('ascending')}
</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="desc">
<span className="flex items-center gap-2">
{tTable('descending')}
</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
interface CreditTransactionsTableProps {
data: CreditTransaction[];
total: number;
pageIndex: number;
pageSize: number;
search: string;
loading?: boolean;
onSearch: (search: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onSortingChange?: (sorting: SortingState) => void;
}
export function CreditTransactionsTable({
data,
total,
pageIndex,
pageSize,
search,
loading,
onSearch,
onPageChange,
onPageSizeChange,
onSortingChange,
}: CreditTransactionsTableProps) {
const t = useTranslations('Dashboard.settings.credits.transactions');
const tTable = useTranslations('Common.table');
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// show fake data in demo website
const isDemo = process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true';
// Map column IDs to translation keys
const columnIdToTranslationKey = {
type: 'columns.type' as const,
amount: 'columns.amount' as const,
remainingAmount: 'columns.remainingAmount' as const,
description: 'columns.description' as const,
paymentId: 'columns.paymentId' as const,
expirationDate: 'columns.expirationDate' as const,
expirationDateProcessedAt: 'columns.expirationDateProcessedAt' as const,
createdAt: 'columns.createdAt' as const,
updatedAt: 'columns.updatedAt' as const,
} as const;
// Get transaction type icon
const getTransactionTypeIcon = (type: string) => {
switch (type) {
case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH:
return <HandCoinsIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT:
return <GiftIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE:
return <ShoppingCartIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.USAGE:
return <CoinsIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.EXPIRE:
return <ClockIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL:
return <BanknoteIcon className="h-5 w-5" />;
case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY:
return <GemIcon className="h-5 w-5" />;
default:
return null;
}
};
// 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_PACKAGE:
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;
}
};
// Table columns definition
const columns: ColumnDef<CreditTransaction>[] = [
{
accessorKey: 'type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.type')} />
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<Badge
variant="outline"
className="hover:bg-accent transition-colors"
>
{getTransactionTypeIcon(transaction.type)}
{getTransactionTypeDisplayName(transaction.type)}
</Badge>
</div>
);
},
},
{
accessorKey: 'amount',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.amount')} />
),
cell: ({ row }) => {
const transaction = row.original;
return <CreditDetailViewer transaction={transaction} />;
},
},
{
accessorKey: 'remainingAmount',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.remainingAmount')}
/>
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{transaction.remainingAmount !== null ? (
<span className="font-medium">
{transaction.remainingAmount.toLocaleString()}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
);
},
},
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.description')}
/>
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{transaction.description ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-[200px] truncate cursor-help">
{transaction.description}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs whitespace-pre-wrap">
{transaction.description}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-gray-400">-</span>
)}
</div>
);
},
},
{
accessorKey: 'paymentId',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.paymentId')} />
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{transaction.paymentId ? (
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent max-w-[150px]"
onClick={() => {
navigator.clipboard.writeText(transaction.paymentId!);
toast.success(t('paymentIdCopied'));
}}
>
<span className="truncate">{transaction.paymentId}</span>
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</div>
);
},
},
{
accessorKey: 'expirationDate',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.expirationDate')}
/>
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{transaction.expirationDate ? (
<span className="text-sm">
{formatDate(transaction.expirationDate)}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
);
},
},
{
accessorKey: 'expirationDateProcessedAt',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('columns.expirationDateProcessedAt')}
/>
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
{transaction.expirationDateProcessedAt ? (
<span className="text-sm">
{formatDate(transaction.expirationDateProcessedAt)}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
);
},
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.createdAt')} />
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<span className="text-sm">{formatDate(transaction.createdAt)}</span>
</div>
);
},
},
{
accessorKey: 'updatedAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('columns.updatedAt')} />
),
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center gap-2 pl-3">
<span className="text-sm">{formatDate(transaction.updatedAt)}</span>
</div>
);
},
},
];
const table = useReactTable({
data,
columns,
pageCount: Math.ceil(total / pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
pagination: { pageIndex, pageSize },
},
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next);
},
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (updater) => {
const next =
typeof updater === 'function'
? updater({ pageIndex, pageSize })
: updater;
if (next.pageIndex !== pageIndex) onPageChange(next.pageIndex);
if (next.pageSize !== pageSize) onPageSizeChange(next.pageSize);
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
manualSorting: true,
});
return (
<div className="w-full flex-col justify-start gap-6 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-1 items-center gap-4">
<Input
placeholder={t('search')}
value={search}
onChange={(event) => {
onSearch(event.target.value);
onPageChange(0);
}}
className="max-w-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer">
<span className="inline">{t('columns.columns')}</span>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{t(
columnIdToTranslationKey[
column.id as keyof typeof columnIdToTranslationKey
] || 'columns.columns'
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{loading ? tTable('loading') : tTable('noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<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>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{tTable('rowsPerPage')}
</Label>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
onPageSizeChange(Number(value));
onPageChange(0);
}}
>
<SelectTrigger
size="sm"
className="w-20 cursor-pointer"
id="rows-per-page"
>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{tTable('page')} {pageIndex + 1} {' / '}
{Math.max(1, Math.ceil(total / pageSize))}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">{tTable('firstPage')}</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(pageIndex - 1)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">{tTable('previousPage')}</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(pageIndex + 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{tTable('nextPage')}</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(Math.ceil(total / pageSize) - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{tTable('lastPage')}</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -165,12 +165,7 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
};
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0',
className
)}
>
<Card className={cn('w-full overflow-hidden pt-6 pb-0', className)}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t('newsletter.title')}

View File

@ -128,7 +128,7 @@ export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) {
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden py-0 pt-6 flex flex-col',
'w-full overflow-hidden py-0 pt-6 flex flex-col',
className
)}
>

View File

@ -110,7 +110,7 @@ export function UpdateNameCard({ className }: UpdateNameCardProps) {
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0 flex flex-col',
'w-full overflow-hidden pt-6 pb-0 flex flex-col',
className
)}
>

View File

@ -79,7 +79,7 @@ export function DeleteAccountCard() {
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl border-destructive/50 overflow-hidden pt-6 pb-0 flex flex-col'
'w-full border-destructive/50 overflow-hidden pt-6 pb-0 flex flex-col'
)}
>
<CardHeader>

View File

@ -80,11 +80,7 @@ export function PasswordCardWrapper() {
function PasswordSkeletonCard() {
const t = useTranslations('Dashboard.settings.security.updatePassword');
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-6 flex flex-col'
)}
>
<Card className={cn('w-full overflow-hidden pt-6 pb-6 flex flex-col')}>
<CardHeader>
<CardTitle className="text-lg font-semibold">{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>

View File

@ -55,7 +55,7 @@ export function ResetPasswordCard({ className }: ResetPasswordCardProps) {
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0 flex flex-col',
'w-full overflow-hidden pt-6 pb-0 flex flex-col',
className
)}
>

View File

@ -114,7 +114,7 @@ export function UpdatePasswordCard({ className }: UpdatePasswordCardProps) {
return (
<Card
className={cn(
'w-full max-w-lg md:max-w-xl overflow-hidden pt-6 pb-0 flex flex-col',
'w-full overflow-hidden pt-6 pb-0 flex flex-col',
className
)}
>

View File

@ -0,0 +1,58 @@
'use client';
import type { CreditPackage } from '@/credits/types';
import { useTranslations } from 'next-intl';
import { websiteConfig } from './website';
/**
* Get credit packages with translations for client components
*
* NOTICE: This function should only be used in client components.
* If you need to get the credit packages in server components, use getAllCreditPackages instead.
* Use this function when showing the credit packages to the user.
*
* docs:
* https://mksaas.com/docs/config/credits
*
* @returns The credit packages with translated content
*/
export function getCreditPackages(): Record<string, CreditPackage> {
const t = useTranslations('CreditPackages');
const creditConfig = websiteConfig.credits;
const packages: Record<string, CreditPackage> = {};
// Add translated content to each plan
if (creditConfig.packages.basic) {
packages.basic = {
...creditConfig.packages.basic,
name: t('basic.name'),
description: t('basic.description'),
};
}
if (creditConfig.packages.standard) {
packages.standard = {
...creditConfig.packages.standard,
name: t('standard.name'),
description: t('standard.description'),
};
}
if (creditConfig.packages.premium) {
packages.premium = {
...creditConfig.packages.premium,
name: t('premium.name'),
description: t('premium.description'),
};
}
if (creditConfig.packages.enterprise) {
packages.enterprise = {
...creditConfig.packages.enterprise,
name: t('enterprise.name'),
description: t('enterprise.description'),
};
}
return packages;
}

View File

@ -72,13 +72,13 @@ export function getNavbarLinks(): NestedMenuItem[] {
{
title: t('ai.title'),
items: [
// {
// title: t('ai.items.text.title'),
// description: t('ai.items.text.description'),
// icon: <SquarePenIcon className="size-4 shrink-0" />,
// href: Routes.AIText,
// external: false,
// },
{
title: t('ai.items.text.title'),
description: t('ai.items.text.description'),
icon: <SquarePenIcon className="size-4 shrink-0" />,
href: Routes.AIText,
external: false,
},
{
title: t('ai.items.image.title'),
description: t('ai.items.image.description'),

View File

@ -5,6 +5,7 @@ import type { NestedMenuItem } from '@/types';
import {
BellIcon,
CircleUserRoundIcon,
CoinsIcon,
CreditCardIcon,
LayoutDashboardIcon,
LockKeyholeIcon,
@ -13,6 +14,7 @@ import {
UsersRoundIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { websiteConfig } from './website';
/**
* Get sidebar config with translations
@ -66,6 +68,16 @@ export function getSidebarLinks(): NestedMenuItem[] {
href: Routes.SettingsBilling,
external: false,
},
...(websiteConfig.credits.enableCredits
? [
{
title: t('settings.credits.title'),
icon: <CoinsIcon className="size-4 shrink-0" />,
href: Routes.SettingsCredits,
external: false,
},
]
: []),
{
title: t('settings.security.title'),
icon: <LockKeyholeIcon className="size-4 shrink-0" />,

View File

@ -90,6 +90,11 @@ export const websiteConfig: WebsiteConfig = {
prices: [],
isFree: true,
isLifetime: false,
credits: {
enable: true,
amount: 50,
expireDays: 30,
},
},
pro: {
id: 'pro',
@ -111,7 +116,12 @@ export const websiteConfig: WebsiteConfig = {
],
isFree: false,
isLifetime: false,
recommended: true,
popular: true,
credits: {
enable: true,
amount: 1000,
expireDays: 30,
},
},
lifetime: {
id: 'lifetime',
@ -126,6 +136,70 @@ export const websiteConfig: WebsiteConfig = {
],
isFree: false,
isLifetime: true,
credits: {
enable: true,
amount: 1000,
expireDays: 30,
},
},
},
},
credits: {
enableCredits: true,
enableForFreePlan: false,
registerGiftCredits: {
enable: true,
credits: 50,
expireDays: 30,
},
packages: {
basic: {
id: 'basic',
popular: false,
credits: 100,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC!,
amount: 990,
currency: 'USD',
allowPromotionCode: true,
},
},
standard: {
id: 'standard',
popular: true,
credits: 200,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!,
amount: 1490,
currency: 'USD',
allowPromotionCode: true,
},
},
premium: {
id: 'premium',
popular: false,
credits: 500,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!,
amount: 3990,
currency: 'USD',
allowPromotionCode: true,
},
},
enterprise: {
id: 'enterprise',
popular: false,
credits: 1000,
expireDays: 30,
price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!,
amount: 6990,
currency: 'USD',
allowPromotionCode: true,
},
},
},
},

21
src/credits/client.ts Normal file
View File

@ -0,0 +1,21 @@
import { getCreditPackages } from '@/config/credits-config';
import type { CreditPackage } from './types';
/**
* Get credit packages, used in client components
* @returns Credit packages
*/
export function getCreditPackagesInClient(): CreditPackage[] {
return Object.values(getCreditPackages());
}
/**
* Get credit package by id, used in client components
* @param id - Credit package id
* @returns Credit package
*/
export function getCreditPackageByIdInClient(
id: string
): CreditPackage | undefined {
return getCreditPackagesInClient().find((pkg) => pkg.id === id);
}

626
src/credits/credits.ts Normal file
View File

@ -0,0 +1,626 @@
import { randomUUID } from 'crypto';
import { websiteConfig } from '@/config/website';
import { getDb } from '@/db';
import { creditTransaction, payment, user, userCredit } from '@/db/schema';
import { findPlanByPriceId } from '@/lib/price-plan';
import { addDays, isAfter } from 'date-fns';
import { and, asc, desc, eq, gt, isNull, not, or } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
* Get user's current credit balance
* @param userId - User ID
* @returns User's current credit balance
*/
export async function getUserCredits(userId: string): Promise<number> {
const db = await getDb();
const record = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
return record[0]?.currentCredits || 0;
}
/**
* Update user's current credit balance
* @param userId - User ID
* @param credits - New credit balance
*/
export async function updateUserCredits(userId: string, credits: number) {
const db = await getDb();
await db
.update(userCredit)
.set({ currentCredits: credits, updatedAt: new Date() })
.where(eq(userCredit.userId, userId));
}
/**
* Update user's last refresh time
* @param userId - User ID
* @param date - Last refresh time
*/
export async function updateUserLastRefreshAt(userId: string, date: Date) {
const db = await getDb();
await db
.update(userCredit)
.set({ lastRefreshAt: date, updatedAt: new Date() })
.where(eq(userCredit.userId, userId));
}
/**
* Write a credit transaction record
* @param params - Credit transaction parameters
*/
export async function saveCreditTransaction({
userId,
type,
amount,
description,
paymentId,
expirationDate,
}: {
userId: string;
type: string;
amount: number;
description: string;
paymentId?: string;
expirationDate?: Date;
}) {
if (!userId || !type || !description) {
console.error(
'saveCreditTransaction, invalid params',
userId,
type,
description
);
throw new Error('saveCreditTransaction, invalid params');
}
if (!Number.isFinite(amount) || amount === 0) {
console.error('saveCreditTransaction, invalid amount', userId, amount);
throw new Error('saveCreditTransaction, invalid amount');
}
const db = await getDb();
await db.insert(creditTransaction).values({
id: randomUUID(),
userId,
type,
amount,
// remaining amount is the same as amount for earn transactions
// remaining amount is null for spend transactions
remainingAmount: amount > 0 ? amount : null,
description,
paymentId,
expirationDate,
createdAt: new Date(),
updatedAt: new Date(),
});
}
/**
* Add credits (registration, monthly, purchase, etc.)
* @param params - Credit creation parameters
*/
export async function addCredits({
userId,
amount,
type,
description,
paymentId,
expireDays,
}: {
userId: string;
amount: number;
type: string;
description: string;
paymentId?: string;
expireDays?: number;
}) {
if (!userId || !type || !description) {
console.error('addCredits, invalid params', userId, type, description);
throw new Error('Invalid params');
}
if (!Number.isFinite(amount) || amount <= 0) {
console.error('addCredits, invalid amount', userId, amount);
throw new Error('Invalid amount');
}
if (
expireDays !== undefined &&
(!Number.isFinite(expireDays) || expireDays <= 0)
) {
console.error('addCredits, invalid expire days', userId, expireDays);
throw new Error('Invalid expire days');
}
// Process expired credits first
await processExpiredCredits(userId);
// Update user credit balance
const db = await getDb();
const current = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
// const newBalance = (current[0]?.currentCredits || 0) + amount;
if (current.length > 0) {
const newBalance = (current[0]?.currentCredits || 0) + amount;
console.log('addCredits, update user credit', userId, newBalance);
await db
.update(userCredit)
.set({
currentCredits: newBalance,
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
updatedAt: new Date(),
})
.where(eq(userCredit.userId, userId));
} else {
const newBalance = amount;
console.log('addCredits, insert user credit', userId, newBalance);
await db.insert(userCredit).values({
id: randomUUID(),
userId,
currentCredits: newBalance,
// lastRefreshAt: new Date(), // NOTE: we can not update this field here
createdAt: new Date(),
updatedAt: new Date(),
});
}
// Write credit transaction record
await saveCreditTransaction({
userId,
type,
amount,
description,
paymentId,
expirationDate: expireDays ? addDays(new Date(), expireDays) : undefined,
});
}
export async function hasEnoughCredits({
userId,
requiredCredits,
}: {
userId: string;
requiredCredits: number;
}) {
const balance = await getUserCredits(userId);
return balance >= requiredCredits;
}
/**
* Consume credits (FIFO, by expiration)
* @param params - Credit consumption parameters
*/
export async function consumeCredits({
userId,
amount,
description,
}: {
userId: string;
amount: number;
description: string;
}) {
if (!userId || !description) {
console.error('consumeCredits, invalid params', userId, description);
throw new Error('Invalid params');
}
if (!Number.isFinite(amount) || amount <= 0) {
console.error('consumeCredits, invalid amount', userId, amount);
throw new Error('Invalid amount');
}
// Process expired credits first
await processExpiredCredits(userId);
// Check balance
if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) {
console.error(
`consumeCredits, insufficient credits for user ${userId}, required: ${amount}`
);
throw new Error('Insufficient credits');
}
// FIFO consumption: consume from the earliest unexpired credits first
const db = await getDb();
const now = new Date();
const transactions = await db
.select()
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
// Exclude usage and expire records (these are consumption/expiration logs)
not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.USAGE)),
not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.EXPIRE)),
// Only include transactions with remaining amount > 0
gt(creditTransaction.remainingAmount, 0),
// Only include unexpired credits (either no expiration date or not yet expired)
or(
isNull(creditTransaction.expirationDate),
gt(creditTransaction.expirationDate, now)
)
)
)
.orderBy(
asc(creditTransaction.expirationDate),
asc(creditTransaction.createdAt)
);
// Consume credits
let remainingToDeduct = amount;
for (const transaction of transactions) {
if (remainingToDeduct <= 0) break;
const remainingAmount = transaction.remainingAmount || 0;
if (remainingAmount <= 0) continue;
// credits to consume at most in this transaction
const deductFromThis = Math.min(remainingAmount, remainingToDeduct);
await db
.update(creditTransaction)
.set({
remainingAmount: remainingAmount - deductFromThis,
updatedAt: new Date(),
})
.where(eq(creditTransaction.id, transaction.id));
remainingToDeduct -= deductFromThis;
}
// Update balance
const current = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
const newBalance = (current[0]?.currentCredits || 0) - amount;
await db
.update(userCredit)
.set({ currentCredits: newBalance, updatedAt: new Date() })
.where(eq(userCredit.userId, userId));
// Write usage record
await saveCreditTransaction({
userId,
type: CREDIT_TRANSACTION_TYPE.USAGE,
amount: -amount,
description,
});
}
/**
* Process expired credits
* @param userId - User ID
*/
export async function processExpiredCredits(userId: string) {
const now = new Date();
// Get all credit transactions that can expire (have expirationDate and not yet processed)
const db = await getDb();
const transactions = await db
.select()
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
// Exclude usage and expire records (these are consumption/expiration logs)
not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.USAGE)),
not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.EXPIRE)),
// Only include transactions with expirationDate set
not(isNull(creditTransaction.expirationDate)),
// Only include transactions not yet processed for expiration
isNull(creditTransaction.expirationDateProcessedAt),
// Only include transactions with remaining amount > 0
gt(creditTransaction.remainingAmount, 0)
)
);
let expiredTotal = 0;
// Process expired credit transactions
for (const transaction of transactions) {
if (
transaction.expirationDate &&
isAfter(now, transaction.expirationDate) &&
!transaction.expirationDateProcessedAt
) {
const remain = transaction.remainingAmount || 0;
if (remain > 0) {
expiredTotal += remain;
await db
.update(creditTransaction)
.set({
remainingAmount: 0,
expirationDateProcessedAt: now,
updatedAt: now,
})
.where(eq(creditTransaction.id, transaction.id));
}
}
}
if (expiredTotal > 0) {
// Deduct expired credits from balance
const current = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
const newBalance = Math.max(
0,
(current[0]?.currentCredits || 0) - expiredTotal
);
await db
.update(userCredit)
.set({ currentCredits: newBalance, updatedAt: now })
.where(eq(userCredit.userId, userId));
// Write expire record
await saveCreditTransaction({
userId,
type: CREDIT_TRANSACTION_TYPE.EXPIRE,
amount: -expiredTotal,
description: `Expire credits: ${expiredTotal}`,
});
console.log(
`processExpiredCredits, ${expiredTotal} credits expired for user ${userId}`
);
}
}
/**
* Add register gift credits
* @param userId - User ID
*/
export async function addRegisterGiftCredits(userId: string) {
if (!websiteConfig.credits.registerGiftCredits.enable) {
console.log('addRegisterGiftCredits, disabled');
return;
}
// Check if user has already received register gift credits
const db = await getDb();
const record = await db
.select()
.from(creditTransaction)
.where(
and(
eq(creditTransaction.userId, userId),
eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT)
)
)
.limit(1);
// add register gift credits if user has not received them yet
if (record.length === 0) {
const credits = websiteConfig.credits.registerGiftCredits.credits;
const expireDays = websiteConfig.credits.registerGiftCredits.expireDays;
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT,
description: `Register gift credits: ${credits}`,
expireDays,
});
console.log(
`addRegisterGiftCredits, ${credits} credits for user ${userId}`
);
}
}
/**
* Add free monthly credits
* @param userId - User ID
*/
export async function addMonthlyFreeCredits(userId: string) {
const freePlan = Object.values(websiteConfig.price.plans).find(
(plan) => plan.isFree && !plan.disabled
);
if (!freePlan) {
console.log('addMonthlyFreeCredits, no free plan found');
return;
}
if (
freePlan.disabled ||
!freePlan.credits?.enable ||
!freePlan.credits?.amount
) {
console.log(
'addMonthlyFreeCredits, plan disabled or credits disabled',
freePlan.id
);
return;
}
// Check last refresh time
const db = await getDb();
const record = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
const now = new Date();
let canAdd = false;
// never added credits before
if (!record[0]?.lastRefreshAt) {
canAdd = true;
} else {
const last = new Date(record[0].lastRefreshAt);
// different month or year means new month
canAdd =
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear();
}
// add credits if it's a new month
if (canAdd) {
const credits = freePlan.credits.amount;
const expireDays = freePlan.credits.expireDays;
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH,
description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expireDays,
});
console.log(
`addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
}
}
/**
* Add subscription renewal credits
* @param userId - User ID
* @param priceId - Price ID
*/
export async function addSubscriptionRenewalCredits(
userId: string,
priceId: string
) {
const pricePlan = findPlanByPriceId(priceId);
if (
!pricePlan ||
pricePlan.isFree ||
!pricePlan.credits ||
!pricePlan.credits.enable
) {
console.log(
`addSubscriptionRenewalCredits, no credits configured for plan ${priceId}`
);
return;
}
const credits = pricePlan.credits.amount;
const expireDays = pricePlan.credits.expireDays;
const now = new Date();
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL,
description: `Subscription renewal credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expireDays,
});
console.log(
`addSubscriptionRenewalCredits, ${credits} credits for user ${userId}, priceId: ${priceId}`
);
}
/**
* Add lifetime monthly credits
* @param userId - User ID
*/
export async function addLifetimeMonthlyCredits(userId: string) {
const lifetimePlan = Object.values(websiteConfig.price.plans).find(
(plan) => plan.isLifetime && !plan.disabled
);
if (
!lifetimePlan ||
lifetimePlan.disabled ||
!lifetimePlan.credits ||
!lifetimePlan.credits.enable
) {
console.log(
'addLifetimeMonthlyCredits, plan disabled or credits disabled',
lifetimePlan?.id
);
return;
}
// Check last refresh time to avoid duplicate monthly credits
const db = await getDb();
const record = await db
.select()
.from(userCredit)
.where(eq(userCredit.userId, userId))
.limit(1);
const now = new Date();
let canAdd = false;
// Check if user has never received lifetime credits or it's a new month
if (!record[0]?.lastRefreshAt) {
canAdd = true;
} else {
const last = new Date(record[0].lastRefreshAt);
// different month or year means new month
canAdd =
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear();
}
// Add credits if it's a new month
if (canAdd) {
const credits = lifetimePlan.credits.amount;
const expireDays = lifetimePlan.credits.expireDays;
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY,
description: `Lifetime monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expireDays,
});
// Update last refresh time for lifetime credits
await updateUserLastRefreshAt(userId, now);
console.log(
`addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
}
}
/**
* Distribute credits to all users based on their plan type
* This function is designed to be called by a cron job
*/
export async function distributeCreditsToAllUsers() {
console.log('distributing credits to all users start');
const db = await getDb();
// Get all users with their current active payments/subscriptions
const users = await db
.select({
userId: user.id,
email: user.email,
name: user.name,
})
.from(user)
.where(eq(user.banned, false)); // Only active users
console.log('distributing credits to all users, users count:', users.length);
let processedCount = 0;
let errorCount = 0;
for (const userRecord of users) {
try {
// Get user's current active subscription/payment
const activePayments = await db
.select()
.from(payment)
.where(
and(
eq(payment.userId, userRecord.userId),
or(eq(payment.status, 'active'), eq(payment.status, 'trialing'))
)
)
.orderBy(desc(payment.createdAt));
if (activePayments.length > 0) {
// User has active subscription - check what type
const activePayment = activePayments[0];
const pricePlan = findPlanByPriceId(activePayment.priceId);
if (pricePlan?.isLifetime) {
// Lifetime user - add monthly credits
await addLifetimeMonthlyCredits(userRecord.userId);
}
// Note: Subscription renewals are handled by Stripe webhooks, not here
} else {
// User has no active subscription - add free monthly credits if enabled
await addMonthlyFreeCredits(userRecord.userId);
}
processedCount++;
} catch (error) {
console.error(
`distributing credits to all users error, user: ${userRecord.userId}, error:`,
error
);
errorCount++;
}
}
console.log(
`distributing credits to all users end, processed: ${processedCount}, errors: ${errorCount}`
);
return { processedCount, errorCount };
}

19
src/credits/server.ts Normal file
View File

@ -0,0 +1,19 @@
import { websiteConfig } from '@/config/website';
import type { CreditPackage } from './types';
/**
* Get all credit packages, can be used in server or client components
* @returns Credit packages
*/
export function getAllCreditPackages(): CreditPackage[] {
return Object.values(websiteConfig.credits.packages);
}
/**
* Get credit package by id, can be used in server or client components
* @param id - Credit package id
* @returns Credit package
*/
export function getCreditPackageById(id: string): CreditPackage | undefined {
return getAllCreditPackages().find((pkg) => pkg.id === id);
}

36
src/credits/types.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* Credit transaction type enum
*/
export enum CREDIT_TRANSACTION_TYPE {
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh (free users)
REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift
PURCHASE_PACKAGE = 'PURCHASE_PACKAGE', // Credits earned by purchase package
SUBSCRIPTION_RENEWAL = 'SUBSCRIPTION_RENEWAL', // Credits earned by subscription renewal
LIFETIME_MONTHLY = 'LIFETIME_MONTHLY', // Credits earned by lifetime plan monthly distribution
USAGE = 'USAGE', // Credits spent by usage
EXPIRE = 'EXPIRE', // Credits expired
}
/**
* Credit package price
*/
export interface CreditPackagePrice {
priceId: string; // Stripe price ID (not product id)
amount: number; // Price amount in currency units (dollars, euros, etc.)
currency: string; // Currency code (e.g., USD)
allowPromotionCode?: boolean; // Whether to allow promotion code for this price
}
/**
* Credit package
*/
export interface CreditPackage {
id: string; // Unique identifier for the package
credits: number; // Number of credits in the package
price: CreditPackagePrice; // Price of the package
popular: boolean; // Whether the package is popular
name?: string; // Display name of the package
description?: string; // Description of the package
expireDays?: number; // Number of days to expire the credits, undefined means no expire
disabled?: boolean; // Whether the package is disabled in the UI
}

View File

@ -0,0 +1,25 @@
CREATE TABLE "credit_transaction" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"description" text,
"amount" integer NOT NULL,
"remaining_amount" integer,
"payment_id" text,
"expiration_date" timestamp,
"expiration_date_processed_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_credit" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"current_credits" integer DEFAULT 0 NOT NULL,
"last_refresh_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "credit_transaction" ADD CONSTRAINT "credit_transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_credit" ADD CONSTRAINT "user_credit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -0,0 +1,635 @@
{
"id": "6ed4f085-66bb-42c4-a708-2e5d86438ca2",
"prevId": "7ecbd97a-94eb-4a46-996e-dbff727fc0c7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.credit_transaction": {
"name": "credit_transaction",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amount": {
"name": "amount",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"remaining_amount": {
"name": "remaining_amount",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"payment_id": {
"name": "payment_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expiration_date": {
"name": "expiration_date",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expiration_date_processed_at": {
"name": "expiration_date_processed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"credit_transaction_user_id_user_id_fk": {
"name": "credit_transaction_user_id_user_id_fk",
"tableFrom": "credit_transaction",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payment": {
"name": "payment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"price_id": {
"name": "price_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"interval": {
"name": "interval",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_start": {
"name": "period_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"period_end": {
"name": "period_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"cancel_at_period_end": {
"name": "cancel_at_period_end",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"trial_start": {
"name": "trial_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trial_end": {
"name": "trial_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"payment_user_id_user_id_fk": {
"name": "payment_user_id_user_id_fk",
"tableFrom": "payment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_credit": {
"name": "user_credit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"current_credits": {
"name": "current_credits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_refresh_at": {
"name": "last_refresh_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_credit_user_id_user_id_fk": {
"name": "user_credit_user_id_user_id_fk",
"tableFrom": "user_credit",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1744304844165,
"tag": "0000_fine_sir_ram",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1751214200582,
"tag": "0001_woozy_jigsaw",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,4 @@
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
@ -69,3 +69,26 @@ export const payment = pgTable("payment", {
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
export const userCredit = pgTable("user_credit", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }),
currentCredits: integer("current_credits").notNull().default(0),
lastRefreshAt: timestamp("last_refresh_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const creditTransaction = pgTable("credit_transaction", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }),
type: text("type").notNull(),
description: text("description"),
amount: integer("amount").notNull(),
remainingAmount: integer("remaining_amount"),
paymentId: text("payment_id"),
expirationDate: timestamp("expiration_date"),
expirationDateProcessedAt: timestamp("expiration_date_processed_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

66
src/hooks/use-credits.ts Normal file
View File

@ -0,0 +1,66 @@
import { authClient } from '@/lib/auth-client';
import { useCreditsStore } from '@/stores/credits-store';
import { useCallback, useEffect } from 'react';
/**
* Hook for accessing and managing credits state
*
* This hook provides access to the credits state and methods to manage it.
* It also automatically fetches credits information when the user changes.
*/
export function useCredits() {
const {
balance,
isLoading,
error,
fetchCredits,
consumeCredits,
refreshCredits,
} = useCreditsStore();
const { data: session } = authClient.useSession();
// Stable refetch function using useCallback
const refetch = useCallback(() => {
const currentUser = session?.user;
if (currentUser) {
console.log('refetching credits info for user', currentUser.id);
fetchCredits(currentUser);
}
}, [session?.user, fetchCredits]);
// Stable refresh function using useCallback
const refresh = useCallback(() => {
const currentUser = session?.user;
if (currentUser) {
console.log('refreshing credits info for user', currentUser.id);
refreshCredits(currentUser);
}
}, [session?.user, refreshCredits]);
useEffect(() => {
const currentUser = session?.user;
// Fetch credits data whenever the user session changes
if (currentUser) {
console.log('fetching credits info for user', currentUser.id);
fetchCredits(currentUser);
}
}, [session?.user, fetchCredits]);
return {
// State
balance,
isLoading,
error,
// Methods
consumeCredits,
// Utility methods
refetch,
refresh,
// Helper methods
hasEnoughCredits: (amount: number) => balance >= amount,
};
}

8
src/inngest/client.ts Normal file
View File

@ -0,0 +1,8 @@
import { Inngest } from 'inngest';
/**
* Create a client to send and receive events
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*/
export const inngest = new Inngest({ id: 'mksaas-template' });

46
src/inngest/functions.ts Normal file
View File

@ -0,0 +1,46 @@
import { distributeCreditsToAllUsers } from '@/credits/credits';
import { inngest } from './client';
/**
* Distribute credits to all users daily
*
* https://www.inngest.com/docs/guides/scheduled-functions
*/
export const distributeCreditsDaily = inngest.createFunction(
{ id: 'distribute-credits-daily' },
{ cron: 'TZ=Asia/Shanghai 0 1 * * *' },
async ({ step }) => {
// You should use step.run for any async or long-running logic.
// This allows Inngest to track, retry, and visualize each step in your workflow.
await step.run('distribute-credits-to-all-users', async () => {
console.log('distributing credits to all users start');
const { processedCount, errorCount } =
await distributeCreditsToAllUsers();
console.log(
`distributing credits to all users end, processed: ${processedCount}, errors: ${errorCount}`
);
return {
message: `credits distributed, processed: ${processedCount}, errors: ${errorCount}`,
processedCount,
errorCount,
};
});
// you can add new steps here, for example, send email to admin
}
);
/**
* Hello World function, for testing inngest
*
* https://www.inngest.com/docs/guides/scheduled-functions
*/
export const helloWorld = inngest.createFunction(
{ id: 'hello-world' },
{ event: 'test/hello.world' },
async ({ event, step }) => {
console.log('Hello World function start');
await step.sleep('wait-a-moment', '1s');
console.log('Hello World function end');
return { message: `Hello ${event.data.email}!` };
}
);

View File

@ -1,14 +1,19 @@
import { websiteConfig } from '@/config/website';
import {
addMonthlyFreeCredits,
addRegisterGiftCredits,
} from '@/credits/credits';
import { getDb } from '@/db/index';
import { defaultMessages } from '@/i18n/messages';
import { LOCALE_COOKIE_NAME, routing } from '@/i18n/routing';
import { sendEmail } from '@/mail';
import { subscribe } from '@/newsletter';
import { betterAuth } from 'better-auth';
import { type User, betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins';
import { parse as parseCookies } from 'cookie';
import type { Locale } from 'next-intl';
import { getAllPricePlans } from './price-plan';
import { getBaseUrl, getUrlWithLocaleInCallbackUrl } from './urls/urls';
/**
@ -114,21 +119,7 @@ export const auth = betterAuth({
user: {
create: {
after: async (user) => {
// Auto subscribe user to newsletter after sign up if enabled in website config
if (user.email && websiteConfig.newsletter.autoSubscribeAfterSignUp) {
try {
const subscribed = await subscribe(user.email);
if (!subscribed) {
console.error(
`Failed to subscribe user ${user.email} to newsletter`
);
} else {
console.log(`User ${user.email} subscribed to newsletter`);
}
} catch (error) {
console.error('Newsletter subscription error:', error);
}
}
await onCreateUser(user);
},
},
},
@ -164,3 +155,59 @@ export function getLocaleFromRequest(request?: Request): Locale {
const cookies = parseCookies(request?.headers.get('cookie') ?? '');
return (cookies[LOCALE_COOKIE_NAME] as Locale) ?? routing.defaultLocale;
}
/**
* On create user hook
*
* @param user - The user to create
*/
async function onCreateUser(user: User) {
// Auto subscribe user to newsletter after sign up if enabled in website config
if (user.email && websiteConfig.newsletter.autoSubscribeAfterSignUp) {
try {
const subscribed = await subscribe(user.email);
if (!subscribed) {
console.error(`Failed to subscribe user ${user.email} to newsletter`);
} else {
console.log(`User ${user.email} subscribed to newsletter`);
}
} catch (error) {
console.error('Newsletter subscription error:', error);
}
}
// Add register gift credits to the user if enabled in website config
if (
websiteConfig.credits.registerGiftCredits.enable &&
websiteConfig.credits.registerGiftCredits.credits > 0
) {
try {
await addRegisterGiftCredits(user.id);
const credits = websiteConfig.credits.registerGiftCredits.credits;
console.log(
`added register gift credits for user ${user.id}, credits: ${credits}`
);
} catch (error) {
console.error('Register gift credits error:', error);
}
}
// Add free monthly credits to the user if enabled in website config
const pricePlans = await getAllPricePlans();
const freePlan = pricePlans.find((plan) => plan.isFree);
if (
freePlan?.credits?.enable &&
freePlan?.credits?.amount &&
freePlan?.credits?.amount > 0
) {
try {
await addMonthlyFreeCredits(user.id);
const credits = freePlan.credits.amount;
console.log(
`added free monthly credits for user ${user.id}, credits: ${credits}`
);
} catch (error) {
console.error('Free monthly credits error:', error);
}
}
}

View File

@ -3,6 +3,7 @@ import { StripeProvider } from './provider/stripe';
import type {
CheckoutResult,
CreateCheckoutParams,
CreateCreditCheckoutParams,
CreatePortalParams,
PaymentProvider,
PortalResult,
@ -56,6 +57,18 @@ export const createCheckout = async (
return provider.createCheckout(params);
};
/**
* Create a checkout session for a credit package
* @param params Parameters for creating the checkout session
* @returns Checkout result
*/
export const createCreditCheckout = async (
params: CreateCreditCheckoutParams
): Promise<CheckoutResult> => {
const provider = getPaymentProvider();
return provider.createCreditCheckout(params);
};
/**
* Create a customer portal session
* @param params Parameters for creating the portal

View File

@ -1,6 +1,14 @@
import { randomUUID } from 'crypto';
import { websiteConfig } from '@/config/website';
import {
addCredits,
addLifetimeMonthlyCredits,
addSubscriptionRenewalCredits,
} from '@/credits/credits';
import { getCreditPackageById } from '@/credits/server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { payment, session, user } from '@/db/schema';
import { payment, user } from '@/db/schema';
import {
findPlanByPlanId,
findPlanByPriceId,
@ -12,6 +20,7 @@ import { Stripe } from 'stripe';
import {
type CheckoutResult,
type CreateCheckoutParams,
type CreateCreditCheckoutParams,
type CreatePortalParams,
type PaymentProvider,
type PaymentStatus,
@ -279,6 +288,104 @@ export class StripeProvider implements PaymentProvider {
}
}
/**
* Create a checkout session for a plan
* @param params Parameters for creating the checkout session
* @returns Checkout result
*/
public async createCreditCheckout(
params: CreateCreditCheckoutParams
): Promise<CheckoutResult> {
const {
packageId,
priceId,
customerEmail,
successUrl,
cancelUrl,
metadata,
locale,
} = params;
try {
// Get credit package
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
throw new Error(`Credit package with ID ${packageId} not found`);
}
// Get priceId from credit package
const priceId = creditPackage.price.priceId;
if (!priceId) {
throw new Error(`Price ID not found for credit package ${packageId}`);
}
// Get userName from metadata if available
const userName = metadata?.userName;
// Create or get customer
const customerId = await this.createOrGetCustomer(
customerEmail,
userName
);
// Add planId and priceId to metadata, so we can get it in the webhook event
const customMetadata = {
...metadata,
packageId,
priceId,
};
// Set up the line items
const lineItems = [
{
price: priceId,
quantity: 1,
},
];
// Create checkout session parameters
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
line_items: lineItems,
mode: 'payment',
success_url: successUrl ?? '',
cancel_url: cancelUrl ?? '',
metadata: customMetadata,
allow_promotion_codes: creditPackage.price.allowPromotionCode ?? false,
};
// Add customer to checkout session
checkoutParams.customer = customerId;
// Add locale if provided
if (locale) {
checkoutParams.locale = this.mapLocaleToStripeLocale(
locale
) as Stripe.Checkout.SessionCreateParams.Locale;
}
// Add payment intent data for one-time payments
checkoutParams.payment_intent_data = {
metadata: customMetadata,
};
// Automatically create an invoice for the one-time payment
checkoutParams.invoice_creation = {
enabled: true,
};
// Create the checkout session
const session =
await this.stripe.checkout.sessions.create(checkoutParams);
return {
url: session.url!,
id: session.id,
};
} catch (error) {
console.error('Create credit checkout session error:', error);
throw new Error('Failed to create credit checkout session');
}
}
/**
* Create a customer portal session
* @param params Parameters for creating the portal
@ -394,7 +501,11 @@ export class StripeProvider implements PaymentProvider {
// Only process one-time payments (likely for lifetime plan)
if (session.mode === 'payment') {
await this.onOnetimePayment(session);
if (session.metadata?.type === 'credit_purchase') {
await this.onCreditPurchase(session);
} else {
await this.onOnetimePayment(session);
}
}
}
}
@ -478,6 +589,15 @@ export class StripeProvider implements PaymentProvider {
`<< No payment record created for Stripe subscription ${stripeSubscription.id}`
);
}
// Conditionally handle credits after subscription creation
if (websiteConfig.credits?.enableCredits) {
// Add subscription renewal credits if plan config enables credits
const pricePlan = findPlanByPriceId(priceId);
if (pricePlan?.credits?.enable) {
await addSubscriptionRenewalCredits(userId, priceId);
}
}
}
/**
@ -500,6 +620,34 @@ export class StripeProvider implements PaymentProvider {
return;
}
// Get current payment record to check for period changes (indicating renewal)
const db = await getDb();
const currentPayment = await db
.select({
userId: payment.userId,
periodStart: payment.periodStart,
periodEnd: payment.periodEnd,
})
.from(payment)
.where(eq(payment.subscriptionId, stripeSubscription.id))
.limit(1);
// get new period start and end
const newPeriodStart = stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: undefined;
const newPeriodEnd = stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined;
// Check if this is a renewal (period has changed and subscription is active)
const isRenewal =
currentPayment.length > 0 &&
stripeSubscription.status === 'active' &&
currentPayment[0].periodStart &&
newPeriodStart &&
currentPayment[0].periodStart.getTime() !== newPeriodStart.getTime();
// update fields
const updateFields: any = {
priceId: priceId,
@ -507,12 +655,8 @@ export class StripeProvider implements PaymentProvider {
status: this.mapSubscriptionStatusToPaymentStatus(
stripeSubscription.status
),
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: undefined,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined,
periodStart: newPeriodStart,
periodEnd: newPeriodEnd,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
@ -523,7 +667,6 @@ export class StripeProvider implements PaymentProvider {
updatedAt: new Date(),
};
const db = await getDb();
const result = await db
.update(payment)
.set(updateFields)
@ -534,6 +677,24 @@ export class StripeProvider implements PaymentProvider {
console.log(
`<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
);
// Add credits for subscription renewal
if (isRenewal && currentPayment[0].userId) {
try {
await addSubscriptionRenewalCredits(
currentPayment[0].userId,
priceId
);
console.log(
`<< Added renewal credits for user ${currentPayment[0].userId}, priceId: ${priceId}`
);
} catch (error) {
console.error(
`<< Failed to add renewal credits for user ${currentPayment[0].userId}:`,
error
);
}
}
} else {
console.warn(
`<< No payment record found for Stripe subscription ${stripeSubscription.id}`
@ -599,37 +760,120 @@ export class StripeProvider implements PaymentProvider {
return;
}
// Create a one-time payment record
const now = new Date();
const db = await getDb();
const result = await db
.insert(payment)
.values({
id: randomUUID(),
priceId: priceId,
type: PaymentTypes.ONE_TIME,
userId: userId,
customerId: customerId,
status: 'completed', // One-time payments are always completed
periodStart: now,
createdAt: now,
updatedAt: now,
})
.returning({ id: payment.id });
try {
// Create a one-time payment record
const now = new Date();
const db = await getDb();
const result = await db
.insert(payment)
.values({
id: randomUUID(),
priceId: priceId,
type: PaymentTypes.ONE_TIME,
userId: userId,
customerId: customerId,
status: 'completed', // One-time payments are always completed
periodStart: now,
createdAt: now,
updatedAt: now,
})
.returning({ id: payment.id });
if (result.length === 0) {
console.warn(
`<< Failed to create one-time payment record for user ${userId}`
if (result.length === 0) {
console.warn(
`<< Failed to create one-time payment record for user ${userId}`
);
return;
}
console.log(
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
);
// Conditionally handle credits after one-time payment
if (websiteConfig.credits?.enableCredits) {
// If the plan is lifetime and credits are enabled, add lifetime monthly credits if needed
const lifetimePlan = Object.values(
websiteConfig.price?.plans || {}
).find(
(plan) => plan.isLifetime && !plan.disabled && plan.credits?.enable
);
if (lifetimePlan?.prices?.some((p) => p.priceId === priceId)) {
await addLifetimeMonthlyCredits(userId);
}
}
// Send notification
const amount = session.amount_total ? session.amount_total / 100 : 0;
await sendNotification(session.id, customerId, userId, amount);
} catch (error) {
console.error(
`<< onOnetimePayment error for session ${session.id}:`,
error
);
throw error;
}
}
/**
* Handle credit purchase
* @param session Stripe checkout session
*/
private async onCreditPurchase(
session: Stripe.Checkout.Session
): Promise<void> {
const customerId = session.customer as string;
console.log(`>> Handle credit purchase for customer ${customerId}`);
// get userId from session metadata, we add it in the createCheckout session
const userId = session.metadata?.userId;
if (!userId) {
console.warn(`<< No userId found for checkout session ${session.id}`);
return;
}
console.log(
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
);
// Send notification
const amount = session.amount_total ? session.amount_total / 100 : 0;
await sendNotification(session.id, customerId, userId, amount);
// get packageId from session metadata
const packageId = session.metadata?.packageId;
if (!packageId) {
console.warn(`<< No packageId found for checkout session ${session.id}`);
return;
}
// get credits from session metadata
const credits = session.metadata?.credits;
if (!credits) {
console.warn(`<< No credits found for checkout session ${session.id}`);
return;
}
// get credit package
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
console.warn(`<< Credit package ${packageId} not found`);
return;
}
try {
// add credits to user account
const amount = session.amount_total ? session.amount_total / 100 : 0;
await addCredits({
userId,
amount: Number.parseInt(credits),
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`,
paymentId: session.id,
expireDays: creditPackage.expireDays,
});
console.log(
`<< Added ${credits} credits to user ${userId} for $${amount}${creditPackage.expireDays ? ` (expires in ${creditPackage.expireDays} days)` : ' (no expiration)'}`
);
} catch (error) {
console.error(
`<< onCreditPurchase error for session ${session.id}:`,
error
);
throw error;
}
}
/**

View File

@ -50,6 +50,15 @@ export interface Price {
disabled?: boolean; // Whether to disable this price in UI
}
/**
* Credits configuration for a plan
*/
export interface Credits {
enable: boolean; // Whether to enable credits for this plan
amount: number; // Number of credits provided per month
expireDays?: number; // Number of days until credits expire, undefined means no expiration
}
/**
* Price plan definition
*
@ -70,8 +79,9 @@ export interface PricePlan {
prices: Price[]; // Available prices for this plan
isFree: boolean; // Whether this is a free plan
isLifetime: boolean; // Whether this is a lifetime plan
recommended?: boolean; // Whether to mark this plan as recommended in UI
popular?: boolean; // Whether to mark this plan as popular in UI
disabled?: boolean; // Whether to disable this plan in UI
credits?: Credits; // Credits configuration for this plan
}
/**
@ -128,6 +138,19 @@ export interface CreateCheckoutParams {
locale?: Locale;
}
/**
* Parameters for creating a credit checkout session
*/
export interface CreateCreditCheckoutParams {
packageId: string;
priceId: string;
customerEmail: string;
successUrl?: string;
cancelUrl?: string;
metadata?: Record<string, string>;
locale?: Locale;
}
/**
* Result of creating a checkout session
*/
@ -168,6 +191,11 @@ export interface PaymentProvider {
*/
createCheckout(params: CreateCheckoutParams): Promise<CheckoutResult>;
/**
* Create a credit checkout session
*/
createCreditCheckout(params: CreateCreditCheckoutParams): Promise<CheckoutResult>;
/**
* Create a customer portal session
*/

View File

@ -0,0 +1,28 @@
'use client';
import { useCurrentUser } from '@/hooks/use-current-user';
import { useCreditsStore } from '@/stores/credits-store';
import { useEffect } from 'react';
/**
* Credits Provider Component
*
* This component initializes the credits store when the user is authenticated
* and handles cleanup when the user logs out.
*/
export function CreditsProvider({ children }: { children: React.ReactNode }) {
const user = useCurrentUser();
const { fetchCredits, resetCreditsState } = useCreditsStore();
useEffect(() => {
if (user) {
// User is logged in, fetch their credits
fetchCredits(user);
} else {
// User is logged out, reset the credits state
resetCreditsState();
}
}, [user, fetchCredits, resetCreditsState]);
return <>{children}</>;
}

View File

@ -33,6 +33,7 @@ export enum Routes {
AdminUsers = '/admin/users',
SettingsProfile = '/settings/profile',
SettingsBilling = '/settings/billing',
SettingsCredits = '/settings/credits',
SettingsSecurity = '/settings/security',
SettingsNotifications = '/settings/notifications',
@ -76,6 +77,7 @@ export const protectedRoutes = [
Routes.AdminUsers,
Routes.SettingsProfile,
Routes.SettingsBilling,
Routes.SettingsCredits,
Routes.SettingsSecurity,
Routes.SettingsNotifications,
];

204
src/stores/credits-store.ts Normal file
View File

@ -0,0 +1,204 @@
import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import type { Session } from '@/lib/auth-types';
import { create } from 'zustand';
/**
* Credits state interface
*/
export interface CreditsState {
// Current credit balance
balance: number;
// Loading state
isLoading: boolean;
// Error state
error: string | null;
// Last fetch timestamp to avoid frequent requests
lastFetchTime: number | null;
// Actions
fetchCredits: (user: Session['user'] | null | undefined) => Promise<void>;
consumeCredits: (amount: number, description: string) => Promise<boolean>;
refreshCredits: (user: Session['user'] | null | undefined) => Promise<void>;
resetCreditsState: () => void;
}
// Cache duration: 30 seconds
const CACHE_DURATION = 30 * 1000;
/**
* Credits store using Zustand
* Manages the user's credit balance globally with caching and optimistic updates
*/
export const useCreditsStore = create<CreditsState>((set, get) => ({
// Initial state
balance: 0,
isLoading: false,
error: null,
lastFetchTime: null,
/**
* Fetch credit balance for the current user with caching
* @param user Current user from auth session
*/
fetchCredits: async (user) => {
// Skip if already loading
if (get().isLoading) return;
// Skip if no user is provided
if (!user) {
set({
balance: 0,
error: null,
lastFetchTime: null,
});
return;
}
// Check if we have recent data (within cache duration)
const { lastFetchTime } = get();
const now = Date.now();
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
return; // Use cached data
}
set({ isLoading: true, error: null });
try {
const result = await getCreditBalanceAction();
if (result?.data?.success) {
set({
balance: result.data.credits || 0,
isLoading: false,
error: null,
lastFetchTime: now,
});
} else {
set({
error: result?.data?.error || 'Failed to fetch credit balance',
isLoading: false,
});
}
} catch (error) {
console.error('fetch credits error:', error);
set({
error: 'Failed to fetch credit balance',
isLoading: false,
});
}
},
/**
* Consume credits with optimistic updates
* @param amount Amount of credits to consume
* @param description Description for the transaction
* @returns Promise<boolean> Success status
*/
consumeCredits: async (amount: number, description: string) => {
const { balance } = get();
// Check if we have enough credits
if (balance < amount) {
set({
error: `Insufficient credits. You need ${amount} credits but only have ${balance}.`,
});
return false;
}
// Optimistically update the balance
set({
balance: balance - amount,
error: null,
isLoading: true,
});
try {
const result = await consumeCreditsAction({
amount,
description,
});
if (result?.data?.success) {
set({
isLoading: false,
error: null,
});
return true;
}
// Revert optimistic update on failure
set({
balance: balance, // Revert to original balance
error: result?.data?.error || 'Failed to consume credits',
isLoading: false,
});
return false;
} catch (error) {
console.error('consume credits error:', error);
// Revert optimistic update on error
set({
balance: balance, // Revert to original balance
error: 'Failed to consume credits',
isLoading: false,
});
return false;
}
},
/**
* Force refresh credit balance (ignores cache)
* @param user Current user from auth session
*/
refreshCredits: async (user) => {
if (!user) {
set({
error: 'No user found',
isLoading: false,
});
return;
}
set({
isLoading: true,
error: null,
lastFetchTime: null, // Clear cache to force refresh
});
try {
const result = await getCreditBalanceAction();
if (result?.data?.success) {
set({
balance: result.data.credits || 0,
isLoading: false,
error: null,
lastFetchTime: Date.now(),
});
} else {
set({
error: result?.data?.error || 'Failed to fetch credit balance',
isLoading: false,
});
}
} catch (error) {
console.error('refresh credits error:', error);
set({
error: 'Failed to fetch credit balance',
isLoading: false,
});
}
},
/**
* Reset credits state
*/
resetCreditsState: () => {
set({
balance: 0,
isLoading: false,
error: null,
lastFetchTime: null,
});
},
}));

16
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,20 @@ export interface PriceConfig {
plans: Record<string, PricePlan>; // Plans indexed by ID
}
/**
* Credits configuration
*/
export interface CreditsConfig {
enableCredits: boolean; // Whether to enable credits
enableForFreePlan: boolean; // Whether to enable purchase credits for free plan users
registerGiftCredits: {
enable: boolean; // Whether to enable register gift credits
credits: number; // The number of credits to give to the user
expireDays?: number; // The number of days to expire the credits, undefined means no expire
};
packages: Record<string, CreditPackage>; // Packages indexed by ID
}
/**
* menu item, used for navbar links, sidebar links, footer links
*/