Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
813d8ea0bb
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
|
@ -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"
|
||||
]
|
||||
|
15
env.example
15
env.example
@ -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
|
||||
|
118
messages/en.json
118
messages/en.json
@ -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,75 @@
|
||||
"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",
|
||||
"retry": "Retry",
|
||||
"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",
|
||||
|
125
messages/zh.json
125
messages/zh.json
@ -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,75 @@
|
||||
"manageBilling": "管理账单",
|
||||
"upgradePlan": "升级方案",
|
||||
"retry": "重试",
|
||||
"errorMessage": "获取数据失败"
|
||||
"errorMessage": "获取数据失败",
|
||||
"paymentSuccess": "支付成功"
|
||||
},
|
||||
"credits": {
|
||||
"title": "积分",
|
||||
"description": "管理您的积分交易",
|
||||
"balance": {
|
||||
"title": "积分余额",
|
||||
"description": "您的积分余额",
|
||||
"credits": "积分",
|
||||
"creditsDescription": "您有 {credits} 积分",
|
||||
"creditsExpired": "积分已过期",
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
"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": "通知",
|
||||
|
@ -97,6 +97,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",
|
||||
|
1589
pnpm-lock.yaml
generated
1589
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
43
src/actions/consume-credits.ts
Normal file
43
src/actions/consume-credits.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
@ -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);
|
||||
|
124
src/actions/create-credit-checkout-session.ts
Normal file
124
src/actions/create-credit-checkout-session.ts
Normal 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}?credits_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',
|
||||
};
|
||||
}
|
||||
});
|
21
src/actions/get-credit-balance.ts
Normal file
21
src/actions/get-credit-balance.ts
Normal 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 };
|
||||
});
|
110
src/actions/get-credit-stats.ts
Normal file
110
src/actions/get-credit-stats.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
124
src/actions/get-credit-transactions.ts
Normal file
124
src/actions/get-credit-transactions.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
}}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
51
src/ai/text/components/consume-credit-card.tsx
Normal file
51
src/ai/text/components/consume-credit-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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')}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal file
46
src/app/[locale]/(protected)/settings/credits/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
|
||||
}
|
16
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal file
16
src/app/[locale]/(protected)/settings/credits/page.tsx
Normal 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 />;
|
||||
}
|
@ -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')}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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')}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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')}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
20
src/app/api/hello/route.ts
Normal file
20
src/app/api/hello/route.ts
Normal 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!' });
|
||||
}
|
19
src/app/api/inngest/route.ts
Normal file
19
src/app/api/inngest/route.ts
Normal 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],
|
||||
});
|
@ -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 />
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
42
src/components/layout/credits-balance-button.tsx
Normal file
42
src/components/layout/credits-balance-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
46
src/components/layout/credits-balance-menu.tsx
Normal file
46
src/components/layout/credits-balance-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -15,10 +15,10 @@ export function PaymentProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize payment state
|
||||
const currentUser = session?.user;
|
||||
fetchPayment(currentUser);
|
||||
}, [session, fetchPayment]);
|
||||
if (session?.user) {
|
||||
fetchPayment(session.user);
|
||||
}
|
||||
}, [session?.user, fetchPayment]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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 })}
|
||||
|
@ -13,25 +13,32 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
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 { formatDate } 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 { useCallback, 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 mounted = useMounted();
|
||||
|
||||
const {
|
||||
isLoading: isLoadingPayment,
|
||||
error: loadPaymentError,
|
||||
subscription,
|
||||
currentPlan: currentPlanFromStore,
|
||||
refetch,
|
||||
fetchPayment,
|
||||
} = usePayment();
|
||||
|
||||
// Get user session for customer ID
|
||||
@ -57,91 +64,112 @@ 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)
|
||||
: null;
|
||||
|
||||
// Determine if we are in a loading state
|
||||
const isPageLoading = isLoadingPayment || isLoadingSession;
|
||||
// console.log('billing card, isLoadingPayment', isLoadingPayment, 'isLoadingSession', isLoadingSession);
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
fetchPayment(true);
|
||||
}, [fetchPayment]);
|
||||
|
||||
// Render loading skeleton
|
||||
if (isPageLoading) {
|
||||
// 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 not mounted or in a loading state
|
||||
const isPageLoading = isLoadingPayment || isLoadingSession;
|
||||
if (!mounted || 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="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 (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={handleRetry}
|
||||
>
|
||||
<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) {
|
||||
// currentPlan maybe null, so we need to check if it is null
|
||||
if (!currentPlan) {
|
||||
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 +178,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>
|
||||
);
|
||||
}
|
||||
|
236
src/components/settings/billing/credits-balance-card.tsx
Normal file
236
src/components/settings/billing/credits-balance-card.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
'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 { useMounted } from '@/hooks/use-mounted';
|
||||
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 { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useCallback, 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);
|
||||
const mounted = useMounted();
|
||||
|
||||
// Use the credits hook to get balance
|
||||
const {
|
||||
balance,
|
||||
isLoading: isLoadingBalance,
|
||||
error,
|
||||
fetchCredits,
|
||||
} = 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;
|
||||
}
|
||||
|
||||
// Fetch credit statistics
|
||||
const fetchCreditStats = useCallback(async () => {
|
||||
console.log('fetchCreditStats, fetch start');
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const result = await getCreditStatsAction();
|
||||
if (result?.data?.success && result.data.data) {
|
||||
setCreditStats(result.data.data);
|
||||
} else {
|
||||
console.error('fetchCreditStats, failed to fetch credit stats', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchCreditStats, error:', error);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch stats on component mount
|
||||
useEffect(() => {
|
||||
fetchCreditStats();
|
||||
}, []);
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
const sessionId = searchParams.get('credits_session_id');
|
||||
if (sessionId && !hasHandledSession.current) {
|
||||
hasHandledSession.current = true;
|
||||
|
||||
setTimeout(() => {
|
||||
// Show success toast and refresh data after payment
|
||||
toast.success(t('creditsAdded'));
|
||||
|
||||
// Force refresh credits data to show updated balance
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, 0);
|
||||
|
||||
// Clean up URL parameters
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('credits_session_id');
|
||||
localeRouter.replace(Routes.SettingsBilling + url.search);
|
||||
}
|
||||
}, [searchParams, localeRouter, fetchCredits, fetchCreditStats, t]);
|
||||
|
||||
// Retry all data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch credits data');
|
||||
// Force refresh credits balance (ignore cache)
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, [fetchCredits, fetchCreditStats]);
|
||||
|
||||
// Render loading skeleton
|
||||
const isPageLoading = isLoadingBalance || isLoadingStats;
|
||||
if (!mounted || 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"
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<RefreshCwIcon className="size-4 mr-1" />
|
||||
{t('retry')}
|
||||
</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>
|
||||
);
|
||||
}
|
135
src/components/settings/credits/credit-checkout-button.tsx
Normal file
135
src/components/settings/credits/credit-checkout-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
234
src/components/settings/credits/credit-detail-viewer.tsx
Normal file
234
src/components/settings/credits/credit-detail-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
119
src/components/settings/credits/credit-packages.tsx
Normal file
119
src/components/settings/credits/credit-packages.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/components/settings/credits/credit-transactions-page.tsx
Normal file
73
src/components/settings/credits/credit-transactions-page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
649
src/components/settings/credits/credit-transactions-table.tsx
Normal file
649
src/components/settings/credits/credit-transactions-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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')}
|
||||
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
58
src/config/credits-config.tsx
Normal file
58
src/config/credits-config.tsx
Normal 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;
|
||||
}
|
@ -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'),
|
||||
|
@ -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" />,
|
||||
|
@ -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
21
src/credits/client.ts
Normal 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);
|
||||
}
|
623
src/credits/credits.ts
Normal file
623
src/credits/credits.ts
Normal file
@ -0,0 +1,623 @@
|
||||
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 addSubscriptionCredits(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
19
src/credits/server.ts
Normal 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
36
src/credits/types.ts
Normal 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
|
||||
}
|
25
src/db/migrations/0001_woozy_jigsaw.sql
Normal file
25
src/db/migrations/0001_woozy_jigsaw.sql
Normal 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;
|
635
src/db/migrations/meta/0001_snapshot.json
Normal file
635
src/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
@ -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(),
|
||||
});
|
||||
|
52
src/hooks/use-credits.ts
Normal file
52
src/hooks/use-credits.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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: fetchCreditsFromStore,
|
||||
consumeCredits,
|
||||
} = useCreditsStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchCreditsFromStore(currentUser, force);
|
||||
}
|
||||
},
|
||||
[session?.user, fetchCreditsFromStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchCreditsFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchCreditsFromStore]);
|
||||
|
||||
return {
|
||||
// State
|
||||
balance,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
fetchCredits,
|
||||
consumeCredits,
|
||||
|
||||
// Helper methods
|
||||
hasEnoughCredits: (amount: number) => balance >= amount,
|
||||
};
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for accessing and managing payment state
|
||||
@ -9,31 +9,41 @@ import { useEffect } from 'react';
|
||||
* It also automatically fetches payment information when the user changes.
|
||||
*/
|
||||
export function usePayment() {
|
||||
const { currentPlan, subscription, isLoading, error, fetchPayment } =
|
||||
usePaymentStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
// Fetch payment data whenever the user session changes
|
||||
if (currentUser) {
|
||||
console.log('fetching payment info for user', currentUser.id);
|
||||
fetchPayment(currentUser);
|
||||
}
|
||||
}, [session, fetchPayment]);
|
||||
|
||||
return {
|
||||
const {
|
||||
currentPlan,
|
||||
subscription,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: () => {
|
||||
fetchPayment: fetchPaymentFromStore,
|
||||
} = usePaymentStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const fetchPayment = useCallback(
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
console.log('refetching payment info for user', currentUser.id);
|
||||
fetchPayment(currentUser);
|
||||
fetchPaymentFromStore(currentUser, force);
|
||||
}
|
||||
},
|
||||
[session?.user, fetchPaymentFromStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchPaymentFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchPaymentFromStore]);
|
||||
|
||||
return {
|
||||
// State
|
||||
currentPlan,
|
||||
subscription,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
fetchPayment,
|
||||
};
|
||||
}
|
||||
|
8
src/inngest/client.ts
Normal file
8
src/inngest/client.ts
Normal 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
46
src/inngest/functions.ts
Normal 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}!` };
|
||||
}
|
||||
);
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,14 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import {
|
||||
addCredits,
|
||||
addLifetimeMonthlyCredits,
|
||||
addSubscriptionCredits,
|
||||
} 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,
|
||||
@ -84,9 +93,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
// user does not exist, update user with customer id
|
||||
// in case you deleted user in database, but forgot to delete customer in Stripe
|
||||
if (!userId) {
|
||||
console.log(
|
||||
`User ${email} does not exist, update with customer id ${customerId}`
|
||||
);
|
||||
console.log('User does not exist, update with customer id (hidden)');
|
||||
await this.updateUserWithCustomerId(customerId, email);
|
||||
}
|
||||
return customerId;
|
||||
@ -131,9 +138,9 @@ export class StripeProvider implements PaymentProvider {
|
||||
.returning({ id: user.id });
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log(`Updated user ${email} with customer ID ${customerId}`);
|
||||
console.log('Updated user with customer ID (hidden)');
|
||||
} else {
|
||||
console.log(`No user found with email ${email}`);
|
||||
console.log('No user found with given email');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update user with customer ID error:', error);
|
||||
@ -161,7 +168,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
if (result.length > 0) {
|
||||
return result[0].id;
|
||||
}
|
||||
console.warn(`No user found with customerId ${customerId}`);
|
||||
console.warn('No user found with given customerId');
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
@ -285,6 +292,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
|
||||
@ -400,7 +505,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -417,26 +526,20 @@ export class StripeProvider implements PaymentProvider {
|
||||
private async onCreateSubscription(
|
||||
stripeSubscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`>> Create payment record for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.log('>> Create payment record for Stripe subscription');
|
||||
const customerId = stripeSubscription.customer as string;
|
||||
|
||||
// get priceId from subscription items (this is always available)
|
||||
const priceId = stripeSubscription.items.data[0]?.price.id;
|
||||
if (!priceId) {
|
||||
console.warn(
|
||||
`<< No priceId found for subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.warn('No priceId found for subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
// get userId from metadata, we add it in the createCheckout session
|
||||
const userId = stripeSubscription.metadata.userId;
|
||||
if (!userId) {
|
||||
console.warn(
|
||||
`<< No userId found for subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.warn('No userId found for subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -476,13 +579,18 @@ export class StripeProvider implements PaymentProvider {
|
||||
.returning({ id: payment.id });
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log(
|
||||
`<< Created new payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.log('<< Created new payment record for Stripe subscription');
|
||||
} else {
|
||||
console.warn(
|
||||
`<< No payment record created for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.warn('<< No payment record created for Stripe subscription');
|
||||
}
|
||||
|
||||
// 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 addSubscriptionCredits(userId, priceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -493,19 +601,43 @@ export class StripeProvider implements PaymentProvider {
|
||||
private async onUpdateSubscription(
|
||||
stripeSubscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`>> Update payment record for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.log('>> Update payment record for Stripe subscription');
|
||||
|
||||
// get priceId from subscription items (this is always available)
|
||||
const priceId = stripeSubscription.items.data[0]?.price.id;
|
||||
if (!priceId) {
|
||||
console.warn(
|
||||
`<< No priceId found for subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.warn('No priceId found for subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current payment record to check for period changes (indicating renewal)
|
||||
const db = await getDb();
|
||||
const payments = 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 =
|
||||
payments.length > 0 &&
|
||||
stripeSubscription.status === 'active' &&
|
||||
payments[0].periodStart &&
|
||||
newPeriodStart &&
|
||||
payments[0].periodStart.getTime() !== newPeriodStart.getTime();
|
||||
|
||||
// update fields
|
||||
const updateFields: any = {
|
||||
priceId: priceId,
|
||||
@ -513,12 +645,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)
|
||||
@ -529,7 +657,6 @@ export class StripeProvider implements PaymentProvider {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.update(payment)
|
||||
.set(updateFields)
|
||||
@ -537,13 +664,32 @@ export class StripeProvider implements PaymentProvider {
|
||||
.returning({ id: payment.id });
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log(
|
||||
`<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.log('<< Updated payment record for Stripe subscription');
|
||||
|
||||
// Add credits for subscription renewal
|
||||
const currentPayment = payments[0];
|
||||
if (
|
||||
isRenewal &&
|
||||
currentPayment.userId &&
|
||||
websiteConfig.credits?.enableCredits
|
||||
) {
|
||||
// Add subscription renewal credits if plan config enables credits
|
||||
const pricePlan = findPlanByPriceId(priceId);
|
||||
if (pricePlan?.credits?.enable) {
|
||||
try {
|
||||
await addSubscriptionCredits(currentPayment.userId, priceId);
|
||||
console.log('<< Added renewal credits for user');
|
||||
} catch (error) {
|
||||
console.error('<< Failed to add renewal credits for user:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'<< No renewal credits added for user, isRenewal: ' + isRenewal
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`<< No payment record found for Stripe subscription ${stripeSubscription.id}`
|
||||
);
|
||||
console.warn('<< No payment record found for Stripe subscription');
|
||||
}
|
||||
}
|
||||
|
||||
@ -554,9 +700,7 @@ export class StripeProvider implements PaymentProvider {
|
||||
private async onDeleteSubscription(
|
||||
stripeSubscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`>> Mark payment record for Stripe subscription ${stripeSubscription.id} as canceled`
|
||||
);
|
||||
console.log('>> Mark payment record for Stripe subscription as canceled');
|
||||
const db = await getDb();
|
||||
const result = await db
|
||||
.update(payment)
|
||||
@ -570,12 +714,10 @@ export class StripeProvider implements PaymentProvider {
|
||||
.returning({ id: payment.id });
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log(
|
||||
`<< Marked payment record for subscription ${stripeSubscription.id} as canceled`
|
||||
);
|
||||
console.log('<< Marked payment record for subscription as canceled');
|
||||
} else {
|
||||
console.warn(
|
||||
`<< No payment record found to cancel for Stripe subscription ${stripeSubscription.id}`
|
||||
'<< No payment record found to cancel for Stripe subscription'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -588,12 +730,12 @@ export class StripeProvider implements PaymentProvider {
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId = session.customer as string;
|
||||
console.log(`>> Handle onetime payment for customer ${customerId}`);
|
||||
console.log('>> Handle onetime payment for customer');
|
||||
|
||||
// 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}`);
|
||||
console.warn('No userId found for checkout session');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -601,41 +743,112 @@ export class StripeProvider implements PaymentProvider {
|
||||
// const priceId = session.line_items?.data[0]?.price?.id;
|
||||
const priceId = session.metadata?.priceId;
|
||||
if (!priceId) {
|
||||
console.warn(`<< No priceId found for checkout session ${session.id}`);
|
||||
console.warn('No priceId found for checkout session');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
console.log('Created one-time payment record for user');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
// get credits from session metadata
|
||||
const credits = session.metadata?.credits;
|
||||
if (!credits) {
|
||||
console.warn('No credits found for checkout session');
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error('onCreditPurchase error for session: ' + session.id, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
*/
|
||||
|
24
src/providers/credits-provider.tsx
Normal file
24
src/providers/credits-provider.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
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 { fetchCredits } = useCreditsStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
fetchCredits(session.user);
|
||||
}
|
||||
}, [session?.user, fetchCredits]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
@ -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,
|
||||
];
|
||||
|
162
src/stores/credits-store.ts
Normal file
162
src/stores/credits-store.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { consumeCreditsAction } from '@/actions/consume-credits';
|
||||
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import type { Session } from '@/lib/auth-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Cache duration: 2 minutes (optimized for better UX)
|
||||
const CACHE_DURATION = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
consumeCredits: (amount: number, description: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 optional cache bypass
|
||||
* @param user Current user from auth session
|
||||
* @param force Whether to force refresh and ignore cache
|
||||
*/
|
||||
fetchCredits: async (user, force = false) => {
|
||||
// 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) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`fetchCredits, ${force ? 'force fetch' : 'fetch'} credits`);
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
// Clear cache if force refresh
|
||||
lastFetchTime: force ? null : get().lastFetchTime,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getCreditBalanceAction();
|
||||
if (result?.data?.success) {
|
||||
const newBalance = result.data.credits || 0;
|
||||
console.log('fetchCredits, set new balance', newBalance);
|
||||
set({
|
||||
balance: newBalance,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
console.warn('fetchCredits, failed to fetch credit balance', result);
|
||||
set({
|
||||
error: result?.data?.error || 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchCredits, 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) {
|
||||
console.log('consumeCredits, insufficient credits', balance, amount);
|
||||
set({
|
||||
error: 'Insufficient credits',
|
||||
});
|
||||
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
|
||||
console.warn('consumeCredits, reverting optimistic update');
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
error: result?.data?.error || 'Failed to consume credits',
|
||||
isLoading: false,
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('consumeCredits, error:', error);
|
||||
// Revert optimistic update on error
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
error: 'Failed to consume credits',
|
||||
isLoading: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
@ -5,6 +5,9 @@ import { getAllPricePlans } from '@/lib/price-plan';
|
||||
import type { PricePlan, Subscription } from '@/payment/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Cache duration: 2 minutes (optimized for better UX)
|
||||
const CACHE_DURATION = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Payment state interface
|
||||
*/
|
||||
@ -17,9 +20,14 @@ export interface PaymentState {
|
||||
isLoading: boolean;
|
||||
// Error state
|
||||
error: string | null;
|
||||
// Last fetch timestamp to avoid frequent requests
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
fetchPayment: (user: Session['user'] | null | undefined) => Promise<void>;
|
||||
fetchPayment: (
|
||||
user: Session['user'] | null | undefined,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
@ -33,12 +41,13 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
|
||||
/**
|
||||
* Fetch payment and subscription data for the current user
|
||||
* @param user Current user from auth session
|
||||
*/
|
||||
fetchPayment: async (user) => {
|
||||
fetchPayment: async (user, force = false) => {
|
||||
// Skip if already loading
|
||||
if (get().isLoading) return;
|
||||
|
||||
@ -48,10 +57,21 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
currentPlan: null,
|
||||
subscription: null,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recent data (within cache duration) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
console.log('fetchPayment, use cached data');
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch subscription data
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
@ -66,30 +86,26 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
const result = await getLifetimeStatusAction({ userId: user.id });
|
||||
if (result?.data?.success) {
|
||||
isLifetimeMember = result.data.isLifetimeMember || false;
|
||||
console.log('get lifetime status result', result);
|
||||
console.log('fetchPayment, lifetime status', isLifetimeMember);
|
||||
} else {
|
||||
console.warn('get lifetime status failed', result?.data?.error);
|
||||
// set({
|
||||
// error: result?.data?.error || 'Failed to fetch payment data',
|
||||
// isLoading: false
|
||||
// });
|
||||
console.warn(
|
||||
'fetchPayment, lifetime status error',
|
||||
result?.data?.error
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get lifetime status error:', error);
|
||||
// set({
|
||||
// error: 'Failed to fetch payment data',
|
||||
// isLoading: false
|
||||
// });
|
||||
console.error('fetchPayment, lifetime status error:', error);
|
||||
}
|
||||
|
||||
// If lifetime member, set the lifetime plan
|
||||
if (isLifetimeMember) {
|
||||
console.log('set lifetime plan for user', user.id);
|
||||
console.log('fetchPayment, set lifetime plan');
|
||||
set({
|
||||
currentPlan: lifetimePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -108,34 +124,29 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
(price) => price.priceId === activeSubscription.priceId
|
||||
)
|
||||
) || null;
|
||||
console.log(
|
||||
'subscription found, setting plan for user',
|
||||
user.id,
|
||||
plan?.id
|
||||
);
|
||||
console.log('fetchPayment, subscription found, set pro plan');
|
||||
set({
|
||||
currentPlan: plan,
|
||||
subscription: activeSubscription,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// No subscription found - set to free plan
|
||||
console.log(
|
||||
'no subscription found, setting free plan for user',
|
||||
user.id
|
||||
);
|
||||
console.log('fetchPayment, no subscription found, set free plan');
|
||||
set({
|
||||
currentPlan: freePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Failed to fetch subscription
|
||||
console.error(
|
||||
'fetch subscription for user failed',
|
||||
'fetchPayment, subscription for user failed',
|
||||
result?.data?.error
|
||||
);
|
||||
set({
|
||||
@ -144,7 +155,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetch payment data error:', error);
|
||||
console.error('fetchPayment, error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch payment data',
|
||||
isLoading: false,
|
||||
@ -163,6 +174,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
16
src/types/index.d.ts
vendored
16
src/types/index.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { PricePlan } from '@/payment/types';
|
||||
import type { CreditPackage } from '@/credits/types';
|
||||
|
||||
/**
|
||||
* website config, without translations
|
||||
@ -17,6 +18,7 @@ export type WebsiteConfig = {
|
||||
storage: StorageConfig;
|
||||
payment: PaymentConfig;
|
||||
price: PriceConfig;
|
||||
credits: CreditsConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -148,6 +150,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
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user