From 9e0bd57ecc984a12901a63dae15836ab31a8fe24 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 22 May 2025 00:30:10 +0800 Subject: [PATCH 01/87] feat: support credits --- src/actions/credits.action.ts | 63 +++++ .../[locale]/(protected)/dashboard/page.tsx | 62 ++++- src/db/schema.ts | 25 ++ src/lib/constants.ts | 22 ++ src/lib/credits.ts | 251 ++++++++++++++++++ 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/actions/credits.action.ts create mode 100644 src/lib/credits.ts diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts new file mode 100644 index 0000000..4511b00 --- /dev/null +++ b/src/actions/credits.action.ts @@ -0,0 +1,63 @@ +import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + addCredits, + addMonthlyFreeCredits, + consumeCredits, + getUserCredits, +} from '@/lib/credits'; +import { getSession } from '@/lib/server'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +const actionClient = createSafeActionClient(); + +// get current user's credits +export const getCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + const credits = await getUserCredits(session.user.id); + return { success: true, credits }; +}); + +// consume credits (simulate button) +const consumeSchema = z.object({ + amount: z.number().min(1), + reason: z.string().optional(), +}); +export const consumeCreditsAction = actionClient + .schema(consumeSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + try { + await consumeCredits({ + userId: session.user.id, + amount: parsedInput.amount, + reason: parsedInput.reason || 'SIMULATE_USE', + }); + return { success: true }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } + }); + +// add register credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addCredits({ + userId: session.user.id, + amount: 100, + type: CREDIT_TRANSACTION_TYPE.REGISTER, + reason: 'REGISTER', + }); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 61d51d0..5cf3ed9 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,8 +1,15 @@ +import { + consumeCreditsAction, + getCreditsAction, +} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; +import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; +import React from 'react'; +import { useState } from 'react'; import data from './data.json'; @@ -14,6 +21,44 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // get credits + async function fetchCredits() { + setLoading(true); + setError(null); + const res = await getCreditsAction(); + if ( + typeof res === 'object' && + res && + 'success' in res && + res.success && + 'credits' in res + ) + setCredits(res.credits as number); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'get credits failed'); + setLoading(false); + } + + // consume credits + async function consumeCredits() { + setLoading(true); + setError(null); + const res = await consumeCreditsAction({ amount: 10 }); + if (typeof res === 'object' && res && 'success' in res && res.success) + await fetchCredits(); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'consume credits failed'); + setLoading(false); + } + + // first load credits + React.useEffect(() => { + fetchCredits(); + }, []); const breadcrumbs = [ { @@ -24,8 +69,21 @@ export default function DashboardPage() { return ( <> - - + + 当前积分: {credits === null ? '加载中...' : credits} + + + } + /> + {error &&
{error}
}
diff --git a/src/db/schema.ts b/src/db/schema.ts index e8afeb3..513881a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,3 +69,28 @@ export const payment = pgTable("payment", { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); + +// Credits table: stores user's current credit balance and last refresh date +export const userCredit = pgTable("user_credit", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred + lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// Credit transaction table: records all credit changes (earn/spend/expire) +export const creditTransaction = pgTable("credit_transaction", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + amount: text("amount").notNull(), // positive for earn, negative for spend + remainingAmount: text("remaining_amount"), // for FIFO consumption + paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits + expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 39459fd..e29cdb1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,2 +1,24 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; + +// credit package definition (example) +export const CREDIT_PACKAGES = [ + { id: 'package-1', credits: 500, price: 5 }, + { id: 'package-2', credits: 1200, price: 10 }, + { id: 'package-3', credits: 3000, price: 20 }, +]; + +// free monthly credits (10% of the smallest package) +export const FREE_MONTHLY_CREDITS = 50; + +// default credit expiration days +export const CREDIT_EXPIRE_DAYS = 30; + +// credit transaction type +export const CREDIT_TRANSACTION_TYPE = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', + REGISTER: 'REGISTER', + PURCHASE: 'PURCHASE', + USAGE: 'USAGE', + EXPIRE: 'EXPIRE', +}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 0000000..903a6fe --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,251 @@ +import db from '@/db'; +import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { addDays, isAfter } from 'date-fns'; +import { and, asc, eq } from 'drizzle-orm'; +import { + CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_TYPE, + FREE_MONTHLY_CREDITS, +} from './constants'; + +// Get user's current credit balance +export async function getUserCredits(userId: string): Promise { + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; +} + +// Write a credit transaction record +async function logCreditTransaction(params: { + userId: string; + type: string; + amount: number; + reason: string; + paymentId?: string; + expirationDate?: Date; +}) { + await db.insert(creditTransaction).values({ + id: crypto.randomUUID(), + userId: params.userId, + type: params.type, + amount: params.amount.toString(), + remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + reason: params.reason, + paymentId: params.paymentId, + expirationDate: params.expirationDate, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +// Add credits (registration, monthly, purchase, etc.) +export async function addCredits({ + userId, + amount, + type, + reason, + paymentId, + expireDays = CREDIT_EXPIRE_DAYS, +}: { + userId: string; + amount: number; + type: string; + reason: string; + paymentId?: string; + expireDays?: number; +}) { + // Process expired credits first + await processExpiredCredits(userId); + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) + amount + ).toString(); + if (current.length > 0) { + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + } else { + await db.insert(userCredit).values({ + id: crypto.randomUUID(), + userId, + balance: newBalance, + lastRefresh: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + } + // Write transaction record + await logCreditTransaction({ + userId, + type, + amount, + reason, + paymentId, + expirationDate: addDays(new Date(), expireDays), + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Consume credits (FIFO, by expiration) +export async function consumeCredits({ + userId, + amount, + reason, +}: { + userId: string; + amount: number; + reason: string; +}) { + await processExpiredCredits(userId); + const balance = await getUserCredits(userId); + if (balance < amount) throw new Error('Insufficient credits'); + // FIFO consumption: consume from the earliest unexpired credits first + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ) + .orderBy( + asc(creditTransaction.expirationDate), + asc(creditTransaction.createdAt) + ); + let left = amount; + for (const tx of txs) { + if (left <= 0) break; + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain <= 0) continue; + const consume = Math.min(remain, left); + await db + .update(creditTransaction) + .set({ + remainingAmount: (remain - consume).toString(), + updatedAt: new Date(), + }) + .where(eq(creditTransaction.id, tx.id)); + left -= consume; + } + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - amount + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + // Write usage record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.USAGE, + amount: -amount, + reason, + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Process expired credits +export async function processExpiredCredits(userId: string) { + const now = new Date(); + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ); + let expiredTotal = 0; + for (const tx of txs) { + if ( + tx.expirationDate && + isAfter(now, tx.expirationDate) && + !tx.expirationDateProcessedAt + ) { + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain > 0) { + expiredTotal += remain; + await db + .update(creditTransaction) + .set({ + remainingAmount: '0', + expirationDateProcessedAt: now, + updatedAt: now, + }) + .where(eq(creditTransaction.id, tx.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 = ( + Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + // Write expire record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.EXPIRE, + amount: -expiredTotal, + reason: 'EXPIRE', + }); + } +} + +// Add free monthly credits (can be called by scheduler) +export async function addMonthlyFreeCredits(userId: string) { + // Check last refresh time + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const now = new Date(); + let canAdd = false; + if (!record[0]?.lastRefresh) canAdd = true; + else { + const last = new Date(record[0].lastRefresh); + canAdd = + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear(); + } + if (canAdd) { + await addCredits({ + userId, + amount: FREE_MONTHLY_CREDITS, + type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, + reason: 'MONTHLY_FREE', + }); + await db + .update(userCredit) + .set({ lastRefresh: now, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + } +} From ac320b21f4ae10ffdb077daabd708dcdf9bf016b Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 23 May 2025 00:38:30 +0800 Subject: [PATCH 02/87] chore: update credits related functions --- src/actions/credits.action.ts | 15 ++++++++---- src/db/schema.ts | 6 ++--- src/lib/constants.ts | 19 +++++++++++---- src/lib/credits.ts | 46 +++++++++++++++++++++++------------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 4511b00..b1d1d39 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,4 +1,7 @@ -import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + CREDIT_TRANSACTION_DESCRIPTION, + CREDIT_TRANSACTION_TYPE, +} from '@/lib/constants'; import { addCredits, addMonthlyFreeCredits, @@ -22,8 +25,9 @@ export const getCreditsAction = actionClient.action(async () => { // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), - reason: z.string().optional(), + description: z.string().optional(), }); + export const consumeCreditsAction = actionClient .schema(consumeSchema) .action(async ({ parsedInput }) => { @@ -33,7 +37,8 @@ export const consumeCreditsAction = actionClient await consumeCredits({ userId: session.user.id, amount: parsedInput.amount, - reason: parsedInput.reason || 'SIMULATE_USE', + description: + parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, }); return { success: true }; } catch (e) { @@ -48,8 +53,8 @@ export const addRegisterCreditsAction = actionClient.action(async () => { await addCredits({ userId: session.user.id, amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER, - reason: 'REGISTER', + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, }); return { success: true }; }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 513881a..cc76130 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -84,12 +84,12 @@ export const userCredit = pgTable("user_credit", { export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e29cdb1..d86f2bf 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,9 +16,18 @@ export const CREDIT_EXPIRE_DAYS = 30; // credit transaction type export const CREDIT_TRANSACTION_TYPE = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', - REGISTER: 'REGISTER', - PURCHASE: 'PURCHASE', - USAGE: 'USAGE', - EXPIRE: 'EXPIRE', + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired +}; + +// credit transaction description +export const CREDIT_TRANSACTION_DESCRIPTION = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired }; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 903a6fe..b9ab813 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,9 +1,10 @@ import db from '@/db'; -import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, } from './constants'; @@ -23,7 +24,7 @@ async function logCreditTransaction(params: { userId: string; type: string; amount: number; - reason: string; + description: string; paymentId?: string; expirationDate?: Date; }) { @@ -33,7 +34,7 @@ async function logCreditTransaction(params: { type: params.type, amount: params.amount.toString(), remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, - reason: params.reason, + description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, createdAt: new Date(), @@ -46,14 +47,14 @@ export async function addCredits({ userId, amount, type, - reason, + description, paymentId, expireDays = CREDIT_EXPIRE_DAYS, }: { userId: string; amount: number; type: string; - reason: string; + description: string; paymentId?: string; expireDays?: number; }) { @@ -88,7 +89,7 @@ export async function addCredits({ userId, type, amount, - reason, + description, paymentId, expirationDate: addDays(new Date(), expireDays), }); @@ -100,13 +101,15 @@ export async function addCredits({ export async function consumeCredits({ userId, amount, - reason, + description, }: { userId: string; amount: number; - reason: string; + description: string; }) { + // Process expired credits first await processExpiredCredits(userId); + // Check balance const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first @@ -116,18 +119,24 @@ export async function consumeCredits({ .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ) .orderBy( asc(creditTransaction.expirationDate), asc(creditTransaction.createdAt) ); + // Consume credits let left = amount; for (const tx of txs) { if (left <= 0) break; const remain = Number.parseInt(tx.remainingAmount || '0', 10); if (remain <= 0) continue; + // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) @@ -156,7 +165,7 @@ export async function consumeCredits({ userId, type: CREDIT_TRANSACTION_TYPE.USAGE, amount: -amount, - reason, + description, }); // Refresh session if needed // await refreshUserSession(userId); @@ -165,16 +174,22 @@ export async function consumeCredits({ // Process expired credits export async function processExpiredCredits(userId: string) { const now = new Date(); + // Get all credit transactions without type EXPIRE const txs = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ); let expiredTotal = 0; + // Process expired credit transactions for (const tx of txs) { if ( tx.expirationDate && @@ -202,7 +217,8 @@ export async function processExpiredCredits(userId: string) { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( + const newBalance = Math.max( + 0, Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal ).toString(); await db @@ -214,7 +230,7 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - reason: 'EXPIRE', + description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, }); } } @@ -241,7 +257,7 @@ export async function addMonthlyFreeCredits(userId: string) { userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - reason: 'MONTHLY_FREE', + description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, }); await db .update(userCredit) From 4374f118b4f8be2a3d313649eba12d8ae53a6183 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 28 May 2025 00:54:27 +0800 Subject: [PATCH 03/87] chore: update credits related functions (2) --- src/actions/credits.action.ts | 45 +++++++++++---------------- src/db/schema.ts | 2 +- src/lib/constants.ts | 18 ++++------- src/lib/credits.ts | 58 +++++++++++++++++++++++++++-------- 4 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index b1d1d39..406eefa 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,10 +1,6 @@ import { - CREDIT_TRANSACTION_DESCRIPTION, - CREDIT_TRANSACTION_TYPE, -} from '@/lib/constants'; -import { - addCredits, addMonthlyFreeCredits, + addRegisterGiftCredits, consumeCredits, getUserCredits, } from '@/lib/credits'; @@ -22,6 +18,22 @@ export const getCreditsAction = actionClient.action(async () => { return { success: true, credits }; }); +// add register gift credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addRegisterGiftCredits(session.user.id); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); + // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), @@ -38,31 +50,10 @@ export const consumeCreditsAction = actionClient userId: session.user.id, amount: parsedInput.amount, description: - parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, + parsedInput.description || `Consume credits: ${parsedInput.amount}`, }); return { success: true }; } catch (e) { return { success: false, error: (e as Error).message }; } }); - -// add register credits (for testing) -export const addRegisterCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addCredits({ - userId: session.user.id, - amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, - description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, - }); - return { success: true }; -}); - -// add monthly free credits (for testing) -export const addMonthlyCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addMonthlyFreeCredits(session.user.id); - return { success: true }; -}); diff --git a/src/db/schema.ts b/src/db/schema.ts index cc76130..0ccd085 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -85,7 +85,7 @@ export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE + description: text("description"), // description, e.g. "Register gift credits: 100" amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d86f2bf..07cc3f5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,14 +3,17 @@ export const PLACEHOLDER_IMAGE = // credit package definition (example) export const CREDIT_PACKAGES = [ - { id: 'package-1', credits: 500, price: 5 }, - { id: 'package-2', credits: 1200, price: 10 }, - { id: 'package-3', credits: 3000, price: 20 }, + { id: 'package-1', credits: 1000, price: 10 }, + { id: 'package-2', credits: 2500, price: 20 }, + { id: 'package-3', credits: 5000, price: 30 }, ]; // free monthly credits (10% of the smallest package) export const FREE_MONTHLY_CREDITS = 50; +// register gift credits (for new user registration) +export const REGISTER_GIFT_CREDITS = 100; + // default credit expiration days export const CREDIT_EXPIRE_DAYS = 30; @@ -22,12 +25,3 @@ export const CREDIT_TRANSACTION_TYPE = { USAGE: 'USAGE', // credits spent by usage EXPIRE: 'EXPIRE', // credits expired }; - -// credit transaction description -export const CREDIT_TRANSACTION_DESCRIPTION = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh - REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift - PURCHASE: 'PURCHASE', // credits earned by purchase - USAGE: 'USAGE', // credits spent by usage - EXPIRE: 'EXPIRE', // credits expired -}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b9ab813..ec72310 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -4,9 +4,9 @@ import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, - CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, + REGISTER_GIFT_CREDITS, } from './constants'; // Get user's current credit balance @@ -60,7 +60,7 @@ export async function addCredits({ }) { // Process expired credits first await processExpiredCredits(userId); - // Update balance + // Update user credit balance const current = await db .select() .from(userCredit) @@ -72,7 +72,11 @@ export async function addCredits({ if (current.length > 0) { await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ + balance: newBalance, + lastRefresh: new Date(), + updatedAt: new Date(), + }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ @@ -84,7 +88,7 @@ export async function addCredits({ updatedAt: new Date(), }); } - // Write transaction record + // Write credit transaction record await logCreditTransaction({ userId, type, @@ -230,12 +234,36 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, + description: `Expire credits: ${expiredTotal}`, }); } } -// Add free monthly credits (can be called by scheduler) +// Add register gift credits +export async function addRegisterGiftCredits(userId: string) { + // Check if user has already received register gift credits + 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) { + await addCredits({ + userId, + amount: REGISTER_GIFT_CREDITS, + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: `Register gift credits: ${REGISTER_GIFT_CREDITS}`, + }); + } +} + +// Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const record = await db @@ -245,23 +273,27 @@ export async function addMonthlyFreeCredits(userId: string) { .limit(1); const now = new Date(); let canAdd = false; - if (!record[0]?.lastRefresh) canAdd = true; - else { + // never added credits before + if (!record[0]?.lastRefresh) { + canAdd = true; + } else { const last = new Date(record[0].lastRefresh); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); } + // add credits if it's a new month if (canAdd) { await addCredits({ userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, + description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - await db - .update(userCredit) - .set({ lastRefresh: now, updatedAt: now }) - .where(eq(userCredit.userId, userId)); + // update last refresh time ? addCredits has already updated it + // await db + // .update(userCredit) + // .set({ lastRefresh: now, updatedAt: now }) + // .where(eq(userCredit.userId, userId)); } } From 3a8acc5ef401fcc06087e91ad2c22c167e01f574 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 29 May 2025 01:21:04 +0800 Subject: [PATCH 04/87] chore: update credit related functions (2) --- src/lib/credits.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index ec72310..a6a075a 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -28,6 +28,12 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { + if (!params.userId || !params.type || !params.description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(params.amount) || params.amount === 0) { + throw new Error('Amount must be positive'); + } await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -58,6 +64,15 @@ export async function addCredits({ paymentId?: string; expireDays?: number; }) { + if (!userId || !type || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } + if (!Number.isFinite(expireDays) || expireDays <= 0) { + throw new Error('expireDays must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Update user credit balance @@ -111,6 +126,12 @@ export async function consumeCredits({ amount: number; description: string; }) { + if (!userId || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Check balance From 181e478bc346e4e31f765dc6a353ea7d79f3d019 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:05:25 +0800 Subject: [PATCH 05/87] chore: fix db instance --- .../[locale]/(protected)/dashboard/page.tsx | 62 +------------------ src/lib/credits.ts | 9 ++- 2 files changed, 10 insertions(+), 61 deletions(-) diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 5cf3ed9..61d51d0 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,15 +1,8 @@ -import { - consumeCreditsAction, - getCreditsAction, -} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; -import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; -import React from 'react'; -import { useState } from 'react'; import data from './data.json'; @@ -21,44 +14,6 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); - const [credits, setCredits] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // get credits - async function fetchCredits() { - setLoading(true); - setError(null); - const res = await getCreditsAction(); - if ( - typeof res === 'object' && - res && - 'success' in res && - res.success && - 'credits' in res - ) - setCredits(res.credits as number); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'get credits failed'); - setLoading(false); - } - - // consume credits - async function consumeCredits() { - setLoading(true); - setError(null); - const res = await consumeCreditsAction({ amount: 10 }); - if (typeof res === 'object' && res && 'success' in res && res.success) - await fetchCredits(); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'consume credits failed'); - setLoading(false); - } - - // first load credits - React.useEffect(() => { - fetchCredits(); - }, []); const breadcrumbs = [ { @@ -69,21 +24,8 @@ export default function DashboardPage() { return ( <> - - 当前积分: {credits === null ? '加载中...' : credits} - -
- } - /> - {error &&
{error}
} + +
diff --git a/src/lib/credits.ts b/src/lib/credits.ts index a6a075a..b6d0728 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,4 +1,4 @@ -import db from '@/db'; +import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; @@ -11,6 +11,7 @@ import { // Get user's current credit balance export async function getUserCredits(userId: string): Promise { + const db = await getDb(); const record = await db .select() .from(userCredit) @@ -34,6 +35,7 @@ async function logCreditTransaction(params: { if (!Number.isFinite(params.amount) || params.amount === 0) { throw new Error('Amount must be positive'); } + const db = await getDb(); await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -76,6 +78,7 @@ export async function addCredits({ // Process expired credits first await processExpiredCredits(userId); // Update user credit balance + const db = await getDb(); const current = await db .select() .from(userCredit) @@ -138,6 +141,7 @@ export async function consumeCredits({ const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -200,6 +204,7 @@ export async function consumeCredits({ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -263,6 +268,7 @@ export async function processExpiredCredits(userId: string) { // Add register gift credits export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits + const db = await getDb(); const record = await db .select() .from(creditTransaction) @@ -287,6 +293,7 @@ export async function addRegisterGiftCredits(userId: string) { // Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time + const db = await getDb(); const record = await db .select() .from(userCredit) From 684bbdff8281aae568a6983d4dede6c538ae0ed2 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:25:26 +0800 Subject: [PATCH 06/87] chore: add credit related tables --- src/db/migrations/0001_woozy_jigsaw.sql | 25 + src/db/migrations/meta/0001_snapshot.json | 635 ++++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 22 +- src/lib/credits.ts | 42 +- 5 files changed, 696 insertions(+), 35 deletions(-) create mode 100644 src/db/migrations/0001_woozy_jigsaw.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/db/migrations/0001_woozy_jigsaw.sql b/src/db/migrations/0001_woozy_jigsaw.sql new file mode 100644 index 0000000..dd997f8 --- /dev/null +++ b/src/db/migrations/0001_woozy_jigsaw.sql @@ -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; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..83c624e --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 578b9e1..8f5787e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ccd085..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), @@ -70,27 +70,25 @@ export const payment = pgTable("payment", { updatedAt: timestamp('updated_at').notNull().defaultNow(), }); -// Credits table: stores user's current credit balance and last refresh date export const userCredit = pgTable("user_credit", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred - lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + currentCredits: integer("current_credits").notNull().default(0), + lastRefreshAt: timestamp("last_refresh_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -// Credit transaction table: records all credit changes (earn/spend/expire) export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. "Register gift credits: 100" - amount: text("amount").notNull(), // positive for earn, negative for spend - remainingAmount: text("remaining_amount"), // for FIFO consumption - paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration - expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + 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(), }); diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b6d0728..9edff94 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -17,7 +17,7 @@ export async function getUserCredits(userId: string): Promise { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; + return record[0]?.currentCredits || 0; } // Write a credit transaction record @@ -40,8 +40,8 @@ async function logCreditTransaction(params: { id: crypto.randomUUID(), userId: params.userId, type: params.type, - amount: params.amount.toString(), - remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + amount: params.amount, + remainingAmount: params.amount > 0 ? params.amount : null, description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, @@ -84,15 +84,13 @@ export async function addCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) + amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { await db .update(userCredit) .set({ - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); @@ -100,8 +98,8 @@ export async function addCredits({ await db.insert(userCredit).values({ id: crypto.randomUUID(), userId, - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }); @@ -163,14 +161,14 @@ export async function consumeCredits({ let left = amount; for (const tx of txs) { if (left <= 0) break; - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) .set({ - remainingAmount: (remain - consume).toString(), + remainingAmount: remain - consume, updatedAt: new Date(), }) .where(eq(creditTransaction.id, tx.id)); @@ -182,12 +180,10 @@ export async function consumeCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) - amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) - amount; await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ currentCredits: newBalance, updatedAt: new Date() }) .where(eq(userCredit.userId, userId)); // Write usage record await logCreditTransaction({ @@ -226,13 +222,13 @@ export async function processExpiredCredits(userId: string) { isAfter(now, tx.expirationDate) && !tx.expirationDateProcessedAt ) { - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db .update(creditTransaction) .set({ - remainingAmount: '0', + remainingAmount: 0, expirationDateProcessedAt: now, updatedAt: now, }) @@ -249,11 +245,11 @@ export async function processExpiredCredits(userId: string) { .limit(1); const newBalance = Math.max( 0, - Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal - ).toString(); + (current[0]?.currentCredits || 0) - expiredTotal + ); await db .update(userCredit) - .set({ balance: newBalance, updatedAt: now }) + .set({ currentCredits: newBalance, updatedAt: now }) .where(eq(userCredit.userId, userId)); // Write expire record await logCreditTransaction({ @@ -302,10 +298,10 @@ export async function addMonthlyFreeCredits(userId: string) { const now = new Date(); let canAdd = false; // never added credits before - if (!record[0]?.lastRefresh) { + if (!record[0]?.lastRefreshAt) { canAdd = true; } else { - const last = new Date(record[0].lastRefresh); + const last = new Date(record[0].lastRefreshAt); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); From 45e6a59fe61e94cb8c2d797abf3192b94255a48d Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 01:10:14 +0800 Subject: [PATCH 07/87] chore: optimize the credit functions --- src/lib/credits.ts | 125 ++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 9edff94..495a0ac 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; @@ -9,7 +10,11 @@ import { REGISTER_GIFT_CREDITS, } from './constants'; -// Get user's current credit balance +/** + * Get user's current credit balance + * @param userId - User ID + * @returns User's current credit balance + */ export async function getUserCredits(userId: string): Promise { const db = await getDb(); const record = await db @@ -20,8 +25,18 @@ export async function getUserCredits(userId: string): Promise { return record[0]?.currentCredits || 0; } -// Write a credit transaction record -async function logCreditTransaction(params: { +/** + * Write a credit transaction record + * @param params - Credit transaction parameters + */ +async function logCreditTransaction({ + userId, + type, + amount, + description, + paymentId, + expirationDate, +}: { userId: string; type: string; amount: number; @@ -29,28 +44,33 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { - if (!params.userId || !params.type || !params.description) { + if (!userId || !type || !description) { throw new Error('Invalid params'); } - if (!Number.isFinite(params.amount) || params.amount === 0) { - throw new Error('Amount must be positive'); + if (!Number.isFinite(amount) || amount === 0) { + throw new Error('Invalid amount'); } const db = await getDb(); await db.insert(creditTransaction).values({ - id: crypto.randomUUID(), - userId: params.userId, - type: params.type, - amount: params.amount, - remainingAmount: params.amount > 0 ? params.amount : null, - description: params.description, - paymentId: params.paymentId, - expirationDate: params.expirationDate, + 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.) +/** + * Add credits (registration, monthly, purchase, etc.) + * @param params - Credit creation parameters + */ export async function addCredits({ userId, amount, @@ -70,10 +90,10 @@ export async function addCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } if (!Number.isFinite(expireDays) || expireDays <= 0) { - throw new Error('expireDays must be positive'); + throw new Error('Invalid expire days'); } // Process expired credits first await processExpiredCredits(userId); @@ -90,16 +110,16 @@ export async function addCredits({ .update(userCredit) .set({ currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ - id: crypto.randomUUID(), + id: randomUUID(), userId, currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here createdAt: new Date(), updatedAt: new Date(), }); @@ -111,13 +131,15 @@ export async function addCredits({ amount, description, paymentId, + // TODO: maybe there is no expiration date for PURCHASE type? expirationDate: addDays(new Date(), expireDays), }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Consume credits (FIFO, by expiration) +/** + * Consume credits (FIFO, by expiration) + * @param params - Credit consumption parameters + */ export async function consumeCredits({ userId, amount, @@ -131,16 +153,21 @@ export async function consumeCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } // Process expired credits first await processExpiredCredits(userId); // Check balance const balance = await getUserCredits(userId); - if (balance < amount) throw new Error('Insufficient credits'); + if (balance < amount) { + console.error( + `Insufficient credits for user ${userId}, balance: ${balance}, amount: ${amount}, description: ${description}` + ); + throw new Error('Insufficient credits'); + } // FIFO consumption: consume from the earliest unexpired credits first const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( @@ -159,9 +186,9 @@ export async function consumeCredits({ ); // Consume credits let left = amount; - for (const tx of txs) { + for (const transaction of transactions) { if (left <= 0) break; - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); @@ -171,7 +198,7 @@ export async function consumeCredits({ remainingAmount: remain - consume, updatedAt: new Date(), }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); left -= consume; } // Update balance @@ -181,6 +208,7 @@ export async function consumeCredits({ .where(eq(userCredit.userId, userId)) .limit(1); const newBalance = (current[0]?.currentCredits || 0) - amount; + // TODO: there must have one record for this user in userCredit? await db .update(userCredit) .set({ currentCredits: newBalance, updatedAt: new Date() }) @@ -192,23 +220,25 @@ export async function consumeCredits({ amount: -amount, description, }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Process expired credits +/** + * Process expired credits + * @param userId - User ID + */ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), or( - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + // TODO: credits with PURCHASE type can not be expired? + // eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) ) @@ -216,13 +246,13 @@ export async function processExpiredCredits(userId: string) { ); let expiredTotal = 0; // Process expired credit transactions - for (const tx of txs) { + for (const transaction of transactions) { if ( - tx.expirationDate && - isAfter(now, tx.expirationDate) && - !tx.expirationDateProcessedAt + transaction.expirationDate && + isAfter(now, transaction.expirationDate) && + !transaction.expirationDateProcessedAt ) { - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db @@ -232,7 +262,7 @@ export async function processExpiredCredits(userId: string) { expirationDateProcessedAt: now, updatedAt: now, }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); } } } @@ -261,7 +291,10 @@ export async function processExpiredCredits(userId: string) { } } -// Add register gift credits +/** + * Add register gift credits + * @param userId - User ID + */ export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits const db = await getDb(); @@ -286,7 +319,10 @@ export async function addRegisterGiftCredits(userId: string) { } } -// Add free monthly credits +/** + * Add free monthly credits + * @param userId - User ID + */ export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const db = await getDb(); @@ -314,10 +350,5 @@ export async function addMonthlyFreeCredits(userId: string) { type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - // update last refresh time ? addCredits has already updated it - // await db - // .update(userCredit) - // .set({ lastRefresh: now, updatedAt: now }) - // .where(eq(userCredit.userId, userId)); } } From e1b0e2f44cdb133347a30f810b78aa8a186b74f9 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 22 May 2025 00:30:10 +0800 Subject: [PATCH 08/87] feat: support credits --- src/actions/credits.action.ts | 63 +++++ .../[locale]/(protected)/dashboard/page.tsx | 62 ++++- src/db/schema.ts | 25 ++ src/lib/constants.ts | 22 ++ src/lib/credits.ts | 251 ++++++++++++++++++ 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/actions/credits.action.ts create mode 100644 src/lib/credits.ts diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts new file mode 100644 index 0000000..4511b00 --- /dev/null +++ b/src/actions/credits.action.ts @@ -0,0 +1,63 @@ +import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + addCredits, + addMonthlyFreeCredits, + consumeCredits, + getUserCredits, +} from '@/lib/credits'; +import { getSession } from '@/lib/server'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +const actionClient = createSafeActionClient(); + +// get current user's credits +export const getCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + const credits = await getUserCredits(session.user.id); + return { success: true, credits }; +}); + +// consume credits (simulate button) +const consumeSchema = z.object({ + amount: z.number().min(1), + reason: z.string().optional(), +}); +export const consumeCreditsAction = actionClient + .schema(consumeSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + try { + await consumeCredits({ + userId: session.user.id, + amount: parsedInput.amount, + reason: parsedInput.reason || 'SIMULATE_USE', + }); + return { success: true }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } + }); + +// add register credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addCredits({ + userId: session.user.id, + amount: 100, + type: CREDIT_TRANSACTION_TYPE.REGISTER, + reason: 'REGISTER', + }); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 61d51d0..5cf3ed9 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,8 +1,15 @@ +import { + consumeCreditsAction, + getCreditsAction, +} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; +import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; +import React from 'react'; +import { useState } from 'react'; import data from './data.json'; @@ -14,6 +21,44 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // get credits + async function fetchCredits() { + setLoading(true); + setError(null); + const res = await getCreditsAction(); + if ( + typeof res === 'object' && + res && + 'success' in res && + res.success && + 'credits' in res + ) + setCredits(res.credits as number); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'get credits failed'); + setLoading(false); + } + + // consume credits + async function consumeCredits() { + setLoading(true); + setError(null); + const res = await consumeCreditsAction({ amount: 10 }); + if (typeof res === 'object' && res && 'success' in res && res.success) + await fetchCredits(); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'consume credits failed'); + setLoading(false); + } + + // first load credits + React.useEffect(() => { + fetchCredits(); + }, []); const breadcrumbs = [ { @@ -24,8 +69,21 @@ export default function DashboardPage() { return ( <> - - + + 当前积分: {credits === null ? '加载中...' : credits} + +
+ } + /> + {error &&
{error}
}
diff --git a/src/db/schema.ts b/src/db/schema.ts index e8afeb3..513881a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,3 +69,28 @@ export const payment = pgTable("payment", { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); + +// Credits table: stores user's current credit balance and last refresh date +export const userCredit = pgTable("user_credit", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred + lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// Credit transaction table: records all credit changes (earn/spend/expire) +export const creditTransaction = pgTable("credit_transaction", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + amount: text("amount").notNull(), // positive for earn, negative for spend + remainingAmount: text("remaining_amount"), // for FIFO consumption + paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits + expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 39459fd..e29cdb1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,2 +1,24 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; + +// credit package definition (example) +export const CREDIT_PACKAGES = [ + { id: 'package-1', credits: 500, price: 5 }, + { id: 'package-2', credits: 1200, price: 10 }, + { id: 'package-3', credits: 3000, price: 20 }, +]; + +// free monthly credits (10% of the smallest package) +export const FREE_MONTHLY_CREDITS = 50; + +// default credit expiration days +export const CREDIT_EXPIRE_DAYS = 30; + +// credit transaction type +export const CREDIT_TRANSACTION_TYPE = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', + REGISTER: 'REGISTER', + PURCHASE: 'PURCHASE', + USAGE: 'USAGE', + EXPIRE: 'EXPIRE', +}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 0000000..903a6fe --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,251 @@ +import db from '@/db'; +import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { addDays, isAfter } from 'date-fns'; +import { and, asc, eq } from 'drizzle-orm'; +import { + CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_TYPE, + FREE_MONTHLY_CREDITS, +} from './constants'; + +// Get user's current credit balance +export async function getUserCredits(userId: string): Promise { + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; +} + +// Write a credit transaction record +async function logCreditTransaction(params: { + userId: string; + type: string; + amount: number; + reason: string; + paymentId?: string; + expirationDate?: Date; +}) { + await db.insert(creditTransaction).values({ + id: crypto.randomUUID(), + userId: params.userId, + type: params.type, + amount: params.amount.toString(), + remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + reason: params.reason, + paymentId: params.paymentId, + expirationDate: params.expirationDate, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +// Add credits (registration, monthly, purchase, etc.) +export async function addCredits({ + userId, + amount, + type, + reason, + paymentId, + expireDays = CREDIT_EXPIRE_DAYS, +}: { + userId: string; + amount: number; + type: string; + reason: string; + paymentId?: string; + expireDays?: number; +}) { + // Process expired credits first + await processExpiredCredits(userId); + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) + amount + ).toString(); + if (current.length > 0) { + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + } else { + await db.insert(userCredit).values({ + id: crypto.randomUUID(), + userId, + balance: newBalance, + lastRefresh: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + } + // Write transaction record + await logCreditTransaction({ + userId, + type, + amount, + reason, + paymentId, + expirationDate: addDays(new Date(), expireDays), + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Consume credits (FIFO, by expiration) +export async function consumeCredits({ + userId, + amount, + reason, +}: { + userId: string; + amount: number; + reason: string; +}) { + await processExpiredCredits(userId); + const balance = await getUserCredits(userId); + if (balance < amount) throw new Error('Insufficient credits'); + // FIFO consumption: consume from the earliest unexpired credits first + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ) + .orderBy( + asc(creditTransaction.expirationDate), + asc(creditTransaction.createdAt) + ); + let left = amount; + for (const tx of txs) { + if (left <= 0) break; + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain <= 0) continue; + const consume = Math.min(remain, left); + await db + .update(creditTransaction) + .set({ + remainingAmount: (remain - consume).toString(), + updatedAt: new Date(), + }) + .where(eq(creditTransaction.id, tx.id)); + left -= consume; + } + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - amount + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + // Write usage record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.USAGE, + amount: -amount, + reason, + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Process expired credits +export async function processExpiredCredits(userId: string) { + const now = new Date(); + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ); + let expiredTotal = 0; + for (const tx of txs) { + if ( + tx.expirationDate && + isAfter(now, tx.expirationDate) && + !tx.expirationDateProcessedAt + ) { + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain > 0) { + expiredTotal += remain; + await db + .update(creditTransaction) + .set({ + remainingAmount: '0', + expirationDateProcessedAt: now, + updatedAt: now, + }) + .where(eq(creditTransaction.id, tx.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 = ( + Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + // Write expire record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.EXPIRE, + amount: -expiredTotal, + reason: 'EXPIRE', + }); + } +} + +// Add free monthly credits (can be called by scheduler) +export async function addMonthlyFreeCredits(userId: string) { + // Check last refresh time + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const now = new Date(); + let canAdd = false; + if (!record[0]?.lastRefresh) canAdd = true; + else { + const last = new Date(record[0].lastRefresh); + canAdd = + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear(); + } + if (canAdd) { + await addCredits({ + userId, + amount: FREE_MONTHLY_CREDITS, + type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, + reason: 'MONTHLY_FREE', + }); + await db + .update(userCredit) + .set({ lastRefresh: now, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + } +} From e0c0ff9518c0c80ac62ff720e4a2b876cee153c6 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 23 May 2025 00:38:30 +0800 Subject: [PATCH 09/87] chore: update credits related functions --- src/actions/credits.action.ts | 15 ++++++++---- src/db/schema.ts | 6 ++--- src/lib/constants.ts | 19 +++++++++++---- src/lib/credits.ts | 46 +++++++++++++++++++++++------------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 4511b00..b1d1d39 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,4 +1,7 @@ -import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + CREDIT_TRANSACTION_DESCRIPTION, + CREDIT_TRANSACTION_TYPE, +} from '@/lib/constants'; import { addCredits, addMonthlyFreeCredits, @@ -22,8 +25,9 @@ export const getCreditsAction = actionClient.action(async () => { // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), - reason: z.string().optional(), + description: z.string().optional(), }); + export const consumeCreditsAction = actionClient .schema(consumeSchema) .action(async ({ parsedInput }) => { @@ -33,7 +37,8 @@ export const consumeCreditsAction = actionClient await consumeCredits({ userId: session.user.id, amount: parsedInput.amount, - reason: parsedInput.reason || 'SIMULATE_USE', + description: + parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, }); return { success: true }; } catch (e) { @@ -48,8 +53,8 @@ export const addRegisterCreditsAction = actionClient.action(async () => { await addCredits({ userId: session.user.id, amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER, - reason: 'REGISTER', + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, }); return { success: true }; }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 513881a..cc76130 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -84,12 +84,12 @@ export const userCredit = pgTable("user_credit", { export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e29cdb1..d86f2bf 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,9 +16,18 @@ export const CREDIT_EXPIRE_DAYS = 30; // credit transaction type export const CREDIT_TRANSACTION_TYPE = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', - REGISTER: 'REGISTER', - PURCHASE: 'PURCHASE', - USAGE: 'USAGE', - EXPIRE: 'EXPIRE', + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired +}; + +// credit transaction description +export const CREDIT_TRANSACTION_DESCRIPTION = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired }; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 903a6fe..b9ab813 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,9 +1,10 @@ import db from '@/db'; -import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, } from './constants'; @@ -23,7 +24,7 @@ async function logCreditTransaction(params: { userId: string; type: string; amount: number; - reason: string; + description: string; paymentId?: string; expirationDate?: Date; }) { @@ -33,7 +34,7 @@ async function logCreditTransaction(params: { type: params.type, amount: params.amount.toString(), remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, - reason: params.reason, + description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, createdAt: new Date(), @@ -46,14 +47,14 @@ export async function addCredits({ userId, amount, type, - reason, + description, paymentId, expireDays = CREDIT_EXPIRE_DAYS, }: { userId: string; amount: number; type: string; - reason: string; + description: string; paymentId?: string; expireDays?: number; }) { @@ -88,7 +89,7 @@ export async function addCredits({ userId, type, amount, - reason, + description, paymentId, expirationDate: addDays(new Date(), expireDays), }); @@ -100,13 +101,15 @@ export async function addCredits({ export async function consumeCredits({ userId, amount, - reason, + description, }: { userId: string; amount: number; - reason: string; + description: string; }) { + // Process expired credits first await processExpiredCredits(userId); + // Check balance const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first @@ -116,18 +119,24 @@ export async function consumeCredits({ .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ) .orderBy( asc(creditTransaction.expirationDate), asc(creditTransaction.createdAt) ); + // Consume credits let left = amount; for (const tx of txs) { if (left <= 0) break; const remain = Number.parseInt(tx.remainingAmount || '0', 10); if (remain <= 0) continue; + // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) @@ -156,7 +165,7 @@ export async function consumeCredits({ userId, type: CREDIT_TRANSACTION_TYPE.USAGE, amount: -amount, - reason, + description, }); // Refresh session if needed // await refreshUserSession(userId); @@ -165,16 +174,22 @@ export async function consumeCredits({ // Process expired credits export async function processExpiredCredits(userId: string) { const now = new Date(); + // Get all credit transactions without type EXPIRE const txs = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ); let expiredTotal = 0; + // Process expired credit transactions for (const tx of txs) { if ( tx.expirationDate && @@ -202,7 +217,8 @@ export async function processExpiredCredits(userId: string) { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( + const newBalance = Math.max( + 0, Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal ).toString(); await db @@ -214,7 +230,7 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - reason: 'EXPIRE', + description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, }); } } @@ -241,7 +257,7 @@ export async function addMonthlyFreeCredits(userId: string) { userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - reason: 'MONTHLY_FREE', + description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, }); await db .update(userCredit) From dae7a3b0e875c002828a6681bba480bf001f7905 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 28 May 2025 00:54:27 +0800 Subject: [PATCH 10/87] chore: update credits related functions (2) --- src/actions/credits.action.ts | 45 +++++++++++---------------- src/db/schema.ts | 2 +- src/lib/constants.ts | 18 ++++------- src/lib/credits.ts | 58 +++++++++++++++++++++++++++-------- 4 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index b1d1d39..406eefa 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,10 +1,6 @@ import { - CREDIT_TRANSACTION_DESCRIPTION, - CREDIT_TRANSACTION_TYPE, -} from '@/lib/constants'; -import { - addCredits, addMonthlyFreeCredits, + addRegisterGiftCredits, consumeCredits, getUserCredits, } from '@/lib/credits'; @@ -22,6 +18,22 @@ export const getCreditsAction = actionClient.action(async () => { return { success: true, credits }; }); +// add register gift credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addRegisterGiftCredits(session.user.id); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); + // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), @@ -38,31 +50,10 @@ export const consumeCreditsAction = actionClient userId: session.user.id, amount: parsedInput.amount, description: - parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, + parsedInput.description || `Consume credits: ${parsedInput.amount}`, }); return { success: true }; } catch (e) { return { success: false, error: (e as Error).message }; } }); - -// add register credits (for testing) -export const addRegisterCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addCredits({ - userId: session.user.id, - amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, - description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, - }); - return { success: true }; -}); - -// add monthly free credits (for testing) -export const addMonthlyCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addMonthlyFreeCredits(session.user.id); - return { success: true }; -}); diff --git a/src/db/schema.ts b/src/db/schema.ts index cc76130..0ccd085 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -85,7 +85,7 @@ export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE + description: text("description"), // description, e.g. "Register gift credits: 100" amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d86f2bf..07cc3f5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,14 +3,17 @@ export const PLACEHOLDER_IMAGE = // credit package definition (example) export const CREDIT_PACKAGES = [ - { id: 'package-1', credits: 500, price: 5 }, - { id: 'package-2', credits: 1200, price: 10 }, - { id: 'package-3', credits: 3000, price: 20 }, + { id: 'package-1', credits: 1000, price: 10 }, + { id: 'package-2', credits: 2500, price: 20 }, + { id: 'package-3', credits: 5000, price: 30 }, ]; // free monthly credits (10% of the smallest package) export const FREE_MONTHLY_CREDITS = 50; +// register gift credits (for new user registration) +export const REGISTER_GIFT_CREDITS = 100; + // default credit expiration days export const CREDIT_EXPIRE_DAYS = 30; @@ -22,12 +25,3 @@ export const CREDIT_TRANSACTION_TYPE = { USAGE: 'USAGE', // credits spent by usage EXPIRE: 'EXPIRE', // credits expired }; - -// credit transaction description -export const CREDIT_TRANSACTION_DESCRIPTION = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh - REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift - PURCHASE: 'PURCHASE', // credits earned by purchase - USAGE: 'USAGE', // credits spent by usage - EXPIRE: 'EXPIRE', // credits expired -}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b9ab813..ec72310 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -4,9 +4,9 @@ import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, - CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, + REGISTER_GIFT_CREDITS, } from './constants'; // Get user's current credit balance @@ -60,7 +60,7 @@ export async function addCredits({ }) { // Process expired credits first await processExpiredCredits(userId); - // Update balance + // Update user credit balance const current = await db .select() .from(userCredit) @@ -72,7 +72,11 @@ export async function addCredits({ if (current.length > 0) { await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ + balance: newBalance, + lastRefresh: new Date(), + updatedAt: new Date(), + }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ @@ -84,7 +88,7 @@ export async function addCredits({ updatedAt: new Date(), }); } - // Write transaction record + // Write credit transaction record await logCreditTransaction({ userId, type, @@ -230,12 +234,36 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, + description: `Expire credits: ${expiredTotal}`, }); } } -// Add free monthly credits (can be called by scheduler) +// Add register gift credits +export async function addRegisterGiftCredits(userId: string) { + // Check if user has already received register gift credits + 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) { + await addCredits({ + userId, + amount: REGISTER_GIFT_CREDITS, + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: `Register gift credits: ${REGISTER_GIFT_CREDITS}`, + }); + } +} + +// Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const record = await db @@ -245,23 +273,27 @@ export async function addMonthlyFreeCredits(userId: string) { .limit(1); const now = new Date(); let canAdd = false; - if (!record[0]?.lastRefresh) canAdd = true; - else { + // never added credits before + if (!record[0]?.lastRefresh) { + canAdd = true; + } else { const last = new Date(record[0].lastRefresh); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); } + // add credits if it's a new month if (canAdd) { await addCredits({ userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, + description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - await db - .update(userCredit) - .set({ lastRefresh: now, updatedAt: now }) - .where(eq(userCredit.userId, userId)); + // update last refresh time ? addCredits has already updated it + // await db + // .update(userCredit) + // .set({ lastRefresh: now, updatedAt: now }) + // .where(eq(userCredit.userId, userId)); } } From b30355dfe538a266a843a2dfa2645ebef3474072 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 29 May 2025 01:21:04 +0800 Subject: [PATCH 11/87] chore: update credit related functions (2) --- src/lib/credits.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index ec72310..a6a075a 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -28,6 +28,12 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { + if (!params.userId || !params.type || !params.description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(params.amount) || params.amount === 0) { + throw new Error('Amount must be positive'); + } await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -58,6 +64,15 @@ export async function addCredits({ paymentId?: string; expireDays?: number; }) { + if (!userId || !type || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } + if (!Number.isFinite(expireDays) || expireDays <= 0) { + throw new Error('expireDays must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Update user credit balance @@ -111,6 +126,12 @@ export async function consumeCredits({ amount: number; description: string; }) { + if (!userId || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Check balance From d8904750d902116e3fd302efbfc60a9bceb08e48 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:05:25 +0800 Subject: [PATCH 12/87] chore: fix db instance --- .../[locale]/(protected)/dashboard/page.tsx | 62 +------------------ src/lib/credits.ts | 9 ++- 2 files changed, 10 insertions(+), 61 deletions(-) diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 5cf3ed9..61d51d0 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,15 +1,8 @@ -import { - consumeCreditsAction, - getCreditsAction, -} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; -import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; -import React from 'react'; -import { useState } from 'react'; import data from './data.json'; @@ -21,44 +14,6 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); - const [credits, setCredits] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // get credits - async function fetchCredits() { - setLoading(true); - setError(null); - const res = await getCreditsAction(); - if ( - typeof res === 'object' && - res && - 'success' in res && - res.success && - 'credits' in res - ) - setCredits(res.credits as number); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'get credits failed'); - setLoading(false); - } - - // consume credits - async function consumeCredits() { - setLoading(true); - setError(null); - const res = await consumeCreditsAction({ amount: 10 }); - if (typeof res === 'object' && res && 'success' in res && res.success) - await fetchCredits(); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'consume credits failed'); - setLoading(false); - } - - // first load credits - React.useEffect(() => { - fetchCredits(); - }, []); const breadcrumbs = [ { @@ -69,21 +24,8 @@ export default function DashboardPage() { return ( <> - - 当前积分: {credits === null ? '加载中...' : credits} - -
- } - /> - {error &&
{error}
} + +
diff --git a/src/lib/credits.ts b/src/lib/credits.ts index a6a075a..b6d0728 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,4 +1,4 @@ -import db from '@/db'; +import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; @@ -11,6 +11,7 @@ import { // Get user's current credit balance export async function getUserCredits(userId: string): Promise { + const db = await getDb(); const record = await db .select() .from(userCredit) @@ -34,6 +35,7 @@ async function logCreditTransaction(params: { if (!Number.isFinite(params.amount) || params.amount === 0) { throw new Error('Amount must be positive'); } + const db = await getDb(); await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -76,6 +78,7 @@ export async function addCredits({ // Process expired credits first await processExpiredCredits(userId); // Update user credit balance + const db = await getDb(); const current = await db .select() .from(userCredit) @@ -138,6 +141,7 @@ export async function consumeCredits({ const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -200,6 +204,7 @@ export async function consumeCredits({ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -263,6 +268,7 @@ export async function processExpiredCredits(userId: string) { // Add register gift credits export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits + const db = await getDb(); const record = await db .select() .from(creditTransaction) @@ -287,6 +293,7 @@ export async function addRegisterGiftCredits(userId: string) { // Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time + const db = await getDb(); const record = await db .select() .from(userCredit) From 12fb19e97b1999381e05e09399d736febeb9935f Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:25:26 +0800 Subject: [PATCH 13/87] chore: add credit related tables --- src/db/migrations/0001_woozy_jigsaw.sql | 25 + src/db/migrations/meta/0001_snapshot.json | 635 ++++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 22 +- src/lib/credits.ts | 42 +- 5 files changed, 696 insertions(+), 35 deletions(-) create mode 100644 src/db/migrations/0001_woozy_jigsaw.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/db/migrations/0001_woozy_jigsaw.sql b/src/db/migrations/0001_woozy_jigsaw.sql new file mode 100644 index 0000000..dd997f8 --- /dev/null +++ b/src/db/migrations/0001_woozy_jigsaw.sql @@ -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; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..83c624e --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 578b9e1..8f5787e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ccd085..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), @@ -70,27 +70,25 @@ export const payment = pgTable("payment", { updatedAt: timestamp('updated_at').notNull().defaultNow(), }); -// Credits table: stores user's current credit balance and last refresh date export const userCredit = pgTable("user_credit", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred - lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + currentCredits: integer("current_credits").notNull().default(0), + lastRefreshAt: timestamp("last_refresh_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -// Credit transaction table: records all credit changes (earn/spend/expire) export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. "Register gift credits: 100" - amount: text("amount").notNull(), // positive for earn, negative for spend - remainingAmount: text("remaining_amount"), // for FIFO consumption - paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration - expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + 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(), }); diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b6d0728..9edff94 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -17,7 +17,7 @@ export async function getUserCredits(userId: string): Promise { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; + return record[0]?.currentCredits || 0; } // Write a credit transaction record @@ -40,8 +40,8 @@ async function logCreditTransaction(params: { id: crypto.randomUUID(), userId: params.userId, type: params.type, - amount: params.amount.toString(), - remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + amount: params.amount, + remainingAmount: params.amount > 0 ? params.amount : null, description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, @@ -84,15 +84,13 @@ export async function addCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) + amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { await db .update(userCredit) .set({ - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); @@ -100,8 +98,8 @@ export async function addCredits({ await db.insert(userCredit).values({ id: crypto.randomUUID(), userId, - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }); @@ -163,14 +161,14 @@ export async function consumeCredits({ let left = amount; for (const tx of txs) { if (left <= 0) break; - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) .set({ - remainingAmount: (remain - consume).toString(), + remainingAmount: remain - consume, updatedAt: new Date(), }) .where(eq(creditTransaction.id, tx.id)); @@ -182,12 +180,10 @@ export async function consumeCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) - amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) - amount; await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ currentCredits: newBalance, updatedAt: new Date() }) .where(eq(userCredit.userId, userId)); // Write usage record await logCreditTransaction({ @@ -226,13 +222,13 @@ export async function processExpiredCredits(userId: string) { isAfter(now, tx.expirationDate) && !tx.expirationDateProcessedAt ) { - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db .update(creditTransaction) .set({ - remainingAmount: '0', + remainingAmount: 0, expirationDateProcessedAt: now, updatedAt: now, }) @@ -249,11 +245,11 @@ export async function processExpiredCredits(userId: string) { .limit(1); const newBalance = Math.max( 0, - Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal - ).toString(); + (current[0]?.currentCredits || 0) - expiredTotal + ); await db .update(userCredit) - .set({ balance: newBalance, updatedAt: now }) + .set({ currentCredits: newBalance, updatedAt: now }) .where(eq(userCredit.userId, userId)); // Write expire record await logCreditTransaction({ @@ -302,10 +298,10 @@ export async function addMonthlyFreeCredits(userId: string) { const now = new Date(); let canAdd = false; // never added credits before - if (!record[0]?.lastRefresh) { + if (!record[0]?.lastRefreshAt) { canAdd = true; } else { - const last = new Date(record[0].lastRefresh); + const last = new Date(record[0].lastRefreshAt); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); From abf8b31ec730eef9b5c4e4201371c7ada2b706ae Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 01:10:14 +0800 Subject: [PATCH 14/87] chore: optimize the credit functions --- src/lib/credits.ts | 125 ++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 9edff94..495a0ac 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; @@ -9,7 +10,11 @@ import { REGISTER_GIFT_CREDITS, } from './constants'; -// Get user's current credit balance +/** + * Get user's current credit balance + * @param userId - User ID + * @returns User's current credit balance + */ export async function getUserCredits(userId: string): Promise { const db = await getDb(); const record = await db @@ -20,8 +25,18 @@ export async function getUserCredits(userId: string): Promise { return record[0]?.currentCredits || 0; } -// Write a credit transaction record -async function logCreditTransaction(params: { +/** + * Write a credit transaction record + * @param params - Credit transaction parameters + */ +async function logCreditTransaction({ + userId, + type, + amount, + description, + paymentId, + expirationDate, +}: { userId: string; type: string; amount: number; @@ -29,28 +44,33 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { - if (!params.userId || !params.type || !params.description) { + if (!userId || !type || !description) { throw new Error('Invalid params'); } - if (!Number.isFinite(params.amount) || params.amount === 0) { - throw new Error('Amount must be positive'); + if (!Number.isFinite(amount) || amount === 0) { + throw new Error('Invalid amount'); } const db = await getDb(); await db.insert(creditTransaction).values({ - id: crypto.randomUUID(), - userId: params.userId, - type: params.type, - amount: params.amount, - remainingAmount: params.amount > 0 ? params.amount : null, - description: params.description, - paymentId: params.paymentId, - expirationDate: params.expirationDate, + 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.) +/** + * Add credits (registration, monthly, purchase, etc.) + * @param params - Credit creation parameters + */ export async function addCredits({ userId, amount, @@ -70,10 +90,10 @@ export async function addCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } if (!Number.isFinite(expireDays) || expireDays <= 0) { - throw new Error('expireDays must be positive'); + throw new Error('Invalid expire days'); } // Process expired credits first await processExpiredCredits(userId); @@ -90,16 +110,16 @@ export async function addCredits({ .update(userCredit) .set({ currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ - id: crypto.randomUUID(), + id: randomUUID(), userId, currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here createdAt: new Date(), updatedAt: new Date(), }); @@ -111,13 +131,15 @@ export async function addCredits({ amount, description, paymentId, + // TODO: maybe there is no expiration date for PURCHASE type? expirationDate: addDays(new Date(), expireDays), }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Consume credits (FIFO, by expiration) +/** + * Consume credits (FIFO, by expiration) + * @param params - Credit consumption parameters + */ export async function consumeCredits({ userId, amount, @@ -131,16 +153,21 @@ export async function consumeCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } // Process expired credits first await processExpiredCredits(userId); // Check balance const balance = await getUserCredits(userId); - if (balance < amount) throw new Error('Insufficient credits'); + if (balance < amount) { + console.error( + `Insufficient credits for user ${userId}, balance: ${balance}, amount: ${amount}, description: ${description}` + ); + throw new Error('Insufficient credits'); + } // FIFO consumption: consume from the earliest unexpired credits first const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( @@ -159,9 +186,9 @@ export async function consumeCredits({ ); // Consume credits let left = amount; - for (const tx of txs) { + for (const transaction of transactions) { if (left <= 0) break; - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); @@ -171,7 +198,7 @@ export async function consumeCredits({ remainingAmount: remain - consume, updatedAt: new Date(), }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); left -= consume; } // Update balance @@ -181,6 +208,7 @@ export async function consumeCredits({ .where(eq(userCredit.userId, userId)) .limit(1); const newBalance = (current[0]?.currentCredits || 0) - amount; + // TODO: there must have one record for this user in userCredit? await db .update(userCredit) .set({ currentCredits: newBalance, updatedAt: new Date() }) @@ -192,23 +220,25 @@ export async function consumeCredits({ amount: -amount, description, }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Process expired credits +/** + * Process expired credits + * @param userId - User ID + */ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), or( - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + // TODO: credits with PURCHASE type can not be expired? + // eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) ) @@ -216,13 +246,13 @@ export async function processExpiredCredits(userId: string) { ); let expiredTotal = 0; // Process expired credit transactions - for (const tx of txs) { + for (const transaction of transactions) { if ( - tx.expirationDate && - isAfter(now, tx.expirationDate) && - !tx.expirationDateProcessedAt + transaction.expirationDate && + isAfter(now, transaction.expirationDate) && + !transaction.expirationDateProcessedAt ) { - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db @@ -232,7 +262,7 @@ export async function processExpiredCredits(userId: string) { expirationDateProcessedAt: now, updatedAt: now, }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); } } } @@ -261,7 +291,10 @@ export async function processExpiredCredits(userId: string) { } } -// Add register gift credits +/** + * Add register gift credits + * @param userId - User ID + */ export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits const db = await getDb(); @@ -286,7 +319,10 @@ export async function addRegisterGiftCredits(userId: string) { } } -// Add free monthly credits +/** + * Add free monthly credits + * @param userId - User ID + */ export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const db = await getDb(); @@ -314,10 +350,5 @@ export async function addMonthlyFreeCredits(userId: string) { type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - // update last refresh time ? addCredits has already updated it - // await db - // .update(userCredit) - // .set({ lastRefresh: now, updatedAt: now }) - // .where(eq(userCredit.userId, userId)); } } From c7e3de816c51edf8fcd36e60335d049e2d804e62 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 4 Jul 2025 01:02:56 +0800 Subject: [PATCH 15/87] chore: update credit related functions --- src/db/schema.ts | 2 +- src/lib/credits.ts | 77 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index 0f05063..fd56eeb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -86,7 +86,7 @@ export const creditTransaction = pgTable("credit_transaction", { description: text("description"), amount: integer("amount").notNull(), remainingAmount: integer("remaining_amount"), - paymentId: text("payment_id"), + paymentId: text("payment_id"), // payment_intent_id expirationDate: timestamp("expiration_date"), expirationDateProcessedAt: timestamp("expiration_date_processed_at"), createdAt: timestamp("created_at").notNull().defaultNow(), diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 495a0ac..530e844 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -25,11 +25,27 @@ export async function getUserCredits(userId: string): Promise { return record[0]?.currentCredits || 0; } +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)); +} + +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 */ -async function logCreditTransaction({ +export async function logCreditTransaction({ userId, type, amount, @@ -45,9 +61,11 @@ async function logCreditTransaction({ expirationDate?: Date; }) { if (!userId || !type || !description) { + console.error('Invalid params', userId, type, description); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount === 0) { + console.error('Invalid amount', userId, amount); throw new Error('Invalid amount'); } const db = await getDb(); @@ -87,12 +105,15 @@ export async function addCredits({ expireDays?: number; }) { if (!userId || !type || !description) { + console.error('Invalid params', userId, type, description); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { + console.error('Invalid amount', userId, amount); throw new Error('Invalid amount'); } if (!Number.isFinite(expireDays) || expireDays <= 0) { + console.error('Invalid expire days', userId, expireDays); throw new Error('Invalid expire days'); } // Process expired credits first @@ -104,22 +125,26 @@ export async function addCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = (current[0]?.currentCredits || 0) + amount; + // const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { + const newBalance = (current[0]?.currentCredits || 0) + amount; + console.log('update user credit', userId, newBalance); await db .update(userCredit) .set({ currentCredits: newBalance, - lastRefreshAt: new Date(), // TODO: maybe we can not update this field here + // 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('insert user credit', userId, newBalance); await db.insert(userCredit).values({ id: randomUUID(), userId, currentCredits: newBalance, - lastRefreshAt: new Date(), // TODO: maybe we can not update this field here + // lastRefreshAt: new Date(), // NOTE: we can not update this field here createdAt: new Date(), updatedAt: new Date(), }); @@ -131,11 +156,25 @@ export async function addCredits({ amount, description, paymentId, - // TODO: maybe there is no expiration date for PURCHASE type? - expirationDate: addDays(new Date(), expireDays), + // NOTE: there is no expiration date for PURCHASE type + expirationDate: + type === CREDIT_TRANSACTION_TYPE.PURCHASE + ? undefined + : addDays(new Date(), expireDays), }); } +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 @@ -150,19 +189,18 @@ export async function consumeCredits({ description: string; }) { if (!userId || !description) { + console.error('Invalid params', userId, description); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { + console.error('Invalid amount', userId, amount); throw new Error('Invalid amount'); } // Process expired credits first await processExpiredCredits(userId); // Check balance - const balance = await getUserCredits(userId); - if (balance < amount) { - console.error( - `Insufficient credits for user ${userId}, balance: ${balance}, amount: ${amount}, description: ${description}` - ); + if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) { + console.error( `Insufficient credits for user ${userId}, required: ${amount}` ); throw new Error('Insufficient credits'); } // FIFO consumption: consume from the earliest unexpired credits first @@ -185,21 +223,21 @@ export async function consumeCredits({ asc(creditTransaction.createdAt) ); // Consume credits - let left = amount; + let remainingToDeduct = amount; for (const transaction of transactions) { - if (left <= 0) break; - const remain = transaction.remainingAmount || 0; - if (remain <= 0) continue; + if (remainingToDeduct <= 0) break; + const remainingAmount = transaction.remainingAmount || 0; + if (remainingAmount <= 0) continue; // credits to consume at most in this transaction - const consume = Math.min(remain, left); + const deductFromThis = Math.min(remainingAmount, remainingToDeduct); await db .update(creditTransaction) .set({ - remainingAmount: remain - consume, + remainingAmount: remainingAmount - deductFromThis, updatedAt: new Date(), }) .where(eq(creditTransaction.id, transaction.id)); - left -= consume; + remainingToDeduct -= deductFromThis; } // Update balance const current = await db @@ -208,7 +246,6 @@ export async function consumeCredits({ .where(eq(userCredit.userId, userId)) .limit(1); const newBalance = (current[0]?.currentCredits || 0) - amount; - // TODO: there must have one record for this user in userCredit? await db .update(userCredit) .set({ currentCredits: newBalance, updatedAt: new Date() }) @@ -237,7 +274,7 @@ export async function processExpiredCredits(userId: string) { and( eq(creditTransaction.userId, userId), or( - // TODO: credits with PURCHASE type can not be expired? + // NOTE: credits with PURCHASE type can not be expired // eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) From bab58e64202b4451695db44c6e744e423996efeb Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 5 Jul 2025 00:25:11 +0800 Subject: [PATCH 16/87] chore: add @stripe/react-stripe-js --- package.json | 1 + pnpm-lock.yaml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/package.json b/package.json index d472a69..0f79bd4 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@react-email/components": "0.0.33", "@react-email/render": "1.0.5", + "@stripe/react-stripe-js": "^3.7.0", "@stripe/stripe-js": "^5.6.0", "@tabler/icons-react": "^3.31.0", "@tanstack/react-table": "^8.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5837676..fb6cf86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@react-email/render': specifier: 1.0.5 version: 1.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@stripe/react-stripe-js': + specifier: ^3.7.0 + version: 3.7.0(@stripe/stripe-js@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@stripe/stripe-js': specifier: ^5.6.0 version: 5.6.0 @@ -3349,6 +3352,13 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stripe/react-stripe-js@3.7.0': + resolution: {integrity: sha512-PYls/2S9l0FF+2n0wHaEJsEU8x7CmBagiH7zYOsxbBlLIHEsqUIQ4MlIAbV9Zg6xwT8jlYdlRIyBTHmO3yM7kQ==} + peerDependencies: + '@stripe/stripe-js': '>=1.44.1 <8.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + '@stripe/stripe-js@5.6.0': resolution: {integrity: sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==} engines: {node: '>=12.16'} @@ -8599,6 +8609,13 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@stripe/react-stripe-js@3.7.0(@stripe/stripe-js@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@stripe/stripe-js': 5.6.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@stripe/stripe-js@5.6.0': {} '@swc/counter@0.1.3': {} From fe2b1bbe39b1212cf007e14611cdf9ec1fc85887 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 5 Jul 2025 22:30:22 +0800 Subject: [PATCH 17/87] feat: add credit purchase functionality with Stripe integration - Introduced credit purchase payment intent actions in credits.action.ts. - Created new components for credit packages and Stripe payment form. - Added routes and layout for credits settings page. - Updated sidebar configuration to include credits settings. - Enhanced constants for credit packages with detailed pricing and descriptions. - Implemented loading and layout components for credits page. - Integrated payment confirmation handling in Stripe provider. --- env.example | 1 + messages/en.json | 7 + src/actions/credits.action.ts | 87 +++++++ .../(protected)/settings/credits/layout.tsx | 46 ++++ .../(protected)/settings/credits/loading.tsx | 5 + .../(protected)/settings/credits/page.tsx | 9 + .../settings/credits/credit-packages.tsx | 216 ++++++++++++++++++ .../settings/credits/stripe-payment-form.tsx | 191 ++++++++++++++++ src/config/sidebar-config.tsx | 7 + src/lib/constants.ts | 35 ++- src/payment/index.ts | 27 +++ src/payment/provider/stripe.ts | 102 +++++++++ src/payment/types.ts | 34 +++ src/routes.ts | 2 + 14 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 src/app/[locale]/(protected)/settings/credits/layout.tsx create mode 100644 src/app/[locale]/(protected)/settings/credits/loading.tsx create mode 100644 src/app/[locale]/(protected)/settings/credits/page.tsx create mode 100644 src/components/settings/credits/credit-packages.tsx create mode 100644 src/components/settings/credits/stripe-payment-form.tsx diff --git a/env.example b/env.example index e158843..7ecd61a 100644 --- a/env.example +++ b/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 diff --git a/messages/en.json b/messages/en.json index 7429307..b4156ec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -556,6 +556,13 @@ "retry": "Retry", "errorMessage": "Failed to get data" }, + "credits": { + "title": "Credits", + "description": "Manage your credits", + "credits": "Credits", + "creditsDescription": "You have {credits} credits", + "creditsExpired": "Credits expired" + }, "notification": { "title": "Notification", "description": "Manage your notification preferences", diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 406eefa..0b96e67 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,12 +1,18 @@ +'use server'; + import { addMonthlyFreeCredits, addRegisterGiftCredits, consumeCredits, getUserCredits, + addCredits, } from '@/lib/credits'; import { getSession } from '@/lib/server'; import { createSafeActionClient } from 'next-safe-action'; import { z } from 'zod'; +import { createPaymentIntent, confirmPaymentIntent } from '@/payment'; +import { CREDIT_PACKAGES } from '@/lib/constants'; +import { revalidatePath } from 'next/cache'; const actionClient = createSafeActionClient(); @@ -57,3 +63,84 @@ export const consumeCreditsAction = actionClient return { success: false, error: (e as Error).message }; } }); + +// Credit purchase payment intent action +const createPaymentIntentSchema = z.object({ + packageId: z.string().min(1), +}); + +export const createCreditPaymentIntent = actionClient + .schema(createPaymentIntentSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'User not authenticated' }; + + const { packageId } = parsedInput; + + // Find the credit package + const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); + if (!creditPackage) { + return { success: false, error: 'Invalid credit package' }; + } + + try { + // Create payment intent + const paymentIntent = await createPaymentIntent({ + amount: creditPackage.price * 100, // Convert to cents + currency: 'usd', + metadata: { + packageId, + userId: session.user.id, + credits: creditPackage.credits.toString(), + }, + }); + + return { + success: true, + clientSecret: paymentIntent.clientSecret, + }; + } catch (error) { + console.error('Create credit payment intent error:', error); + return { success: false, error: 'Failed to create payment intent' }; + } + }); + +// Confirm credit payment action +const confirmPaymentSchema = z.object({ + packageId: z.string().min(1), + paymentIntentId: z.string().min(1), +}); + +export const confirmCreditPayment = actionClient + .schema(confirmPaymentSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'User not authenticated' }; + + const { packageId, paymentIntentId } = parsedInput; + + // Find the credit package + const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); + if (!creditPackage) { + return { success: false, error: 'Invalid credit package' }; + } + + try { + // Confirm payment intent + const isSuccessful = await confirmPaymentIntent({ + paymentIntentId, + }); + + if (!isSuccessful) { + return { success: false, error: 'Payment confirmation failed' }; + } + + // Revalidate the credits page to show updated balance + revalidatePath('/settings/credits'); + + return { success: true }; + } catch (error) { + console.error('Confirm credit payment error:', error); + return { success: false, error: 'Failed to confirm payment' }; + } + }); diff --git a/src/app/[locale]/(protected)/settings/credits/layout.tsx b/src/app/[locale]/(protected)/settings/credits/layout.tsx new file mode 100644 index 0000000..9fc5771 --- /dev/null +++ b/src/app/[locale]/(protected)/settings/credits/layout.tsx @@ -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 ( + <> + + +
+
+
+
+
+

+ {t('credits.title')} +

+

+ {t('credits.description')} +

+
+ + {children} +
+
+
+
+ + ); +} diff --git a/src/app/[locale]/(protected)/settings/credits/loading.tsx b/src/app/[locale]/(protected)/settings/credits/loading.tsx new file mode 100644 index 0000000..ebfad58 --- /dev/null +++ b/src/app/[locale]/(protected)/settings/credits/loading.tsx @@ -0,0 +1,5 @@ +import { Loader2Icon } from 'lucide-react'; + +export default function Loading() { + return ; +} diff --git a/src/app/[locale]/(protected)/settings/credits/page.tsx b/src/app/[locale]/(protected)/settings/credits/page.tsx new file mode 100644 index 0000000..6d0b763 --- /dev/null +++ b/src/app/[locale]/(protected)/settings/credits/page.tsx @@ -0,0 +1,9 @@ +import { CreditPackages } from '@/components/settings/credits/credit-packages'; + +export default function CreditsPage() { + return ( +
+ +
+ ); +} diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx new file mode 100644 index 0000000..783c311 --- /dev/null +++ b/src/components/settings/credits/credit-packages.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { CREDIT_PACKAGES } from '@/lib/constants'; +import { formatPrice } from '@/lib/formatter'; +import { cn } from '@/lib/utils'; +import { CheckIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Separator } from '../../ui/separator'; +import { StripePaymentForm } from './stripe-payment-form'; +import { toast } from 'sonner'; + +export function CreditPackages() { + const [loadingPackage, setLoadingPackage] = useState(null); + const [paymentDialog, setPaymentDialog] = useState<{ + isOpen: boolean; + clientSecret: string | null; + packageId: string | null; + }>({ + isOpen: false, + clientSecret: null, + packageId: null, + }); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchCredits = async () => { + try { + setLoading(true); + const result = await getCreditsAction(); + if (result?.data?.success) { + console.log('CreditPackages, fetched credits:', result.data.credits); + setCredits(result.data.credits || 0); + } else { + const errorMessage = result?.data?.error || 'Failed to fetch credits'; + console.error('CreditPackages, failed to fetch credits:', errorMessage); + toast.error(errorMessage); + } + } catch (error) { + console.error('CreditPackages, failed to fetch credits:', error); + toast.error('Failed to fetch credits'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCredits(); + }, []); + + const handlePurchase = async (packageId: string) => { + try { + setLoadingPackage(packageId); + + const result = await createCreditPaymentIntent({ packageId }); + if (result?.data?.success && result?.data?.clientSecret) { + setPaymentDialog({ + isOpen: true, + clientSecret: result.data.clientSecret, + packageId, + }); + } else { + const errorMessage = result?.data?.error || 'Failed to create payment intent'; + console.error('CreditPackages, failed to create payment intent:', errorMessage); + toast.error(errorMessage); + } + } catch (error) { + console.error('CreditPackages, failed to initiate payment:', error); + toast.error('Failed to initiate payment'); + } finally { + setLoadingPackage(null); + } + }; + + const handlePaymentSuccess = () => { + console.log('CreditPackages, payment successful'); + setPaymentDialog({ + isOpen: false, + clientSecret: null, + packageId: null, + }); + + // Refresh credit balance without page reload + fetchCredits(); + + // Show success toast + toast.success('Your credits have been added to your account'); + }; + + const handlePaymentCancel = () => { + console.log('CreditPackages, payment cancelled'); + setPaymentDialog({ + isOpen: false, + clientSecret: null, + packageId: null, + }); + }; + + const getPackageInfo = (packageId: string) => { + return CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); + }; + + return ( +
+ + + Credit Balance + + +
+
+ +
+ {loading ? ( + ... + ) : ( + credits?.toLocaleString() || 0 + )} +
+
+ + + +
+
+

Credit Packages

+

+ Purchase additional credits to use our services +

+
+
+ {CREDIT_PACKAGES.map((pkg) => ( + + {pkg.popular && ( +
+ + Most Popular + +
+ )} + + {/* + {pkg.id} + */} + + + {/* Price and Credits - Left/Right Layout */} +
+
+
+ {pkg.credits.toLocaleString()} +
+
+
+
+ {formatPrice(pkg.price, 'USD')} +
+
+
+ +
+ + {pkg.description} +
+ + {/* purchase button */} + +
+
+ ))} +
+
+
+
+
+ + {/* Payment Dialog */} + + + + Complete Your Purchase + + + {paymentDialog.clientSecret && paymentDialog.packageId && ( + + )} + + +
+ ); +} diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx new file mode 100644 index 0000000..f86dc00 --- /dev/null +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { confirmCreditPayment } from '@/actions/credits.action'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatPrice } from '@/lib/formatter'; +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { CoinsIcon, Loader2Icon } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +interface StripePaymentFormProps { + clientSecret: string; + packageId: string; + packageInfo: { + credits: number; + price: number; + description: string; + }; + onPaymentSuccess: () => void; + onPaymentCancel: () => void; +} + +export function StripePaymentForm(props: StripePaymentFormProps) { + const { resolvedTheme: theme } = useTheme(); + const stripePromise = useMemo(() => { + if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set'); + } + return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); + }, []); + + const options = useMemo(() => ({ + clientSecret: props.clientSecret, + appearance: { + theme: (theme === "dark" ? "night" : "stripe") as "night" | "stripe", + }, + loader: 'auto' as const, + }), [props.clientSecret, theme]); + + return ( + + + + ); +} + +interface PaymentFormProps { + clientSecret: string; + packageId: string; + packageInfo: { + credits: number; + price: number; + description: string; + }; + onPaymentSuccess: () => void; + onPaymentCancel: () => void; +} + +function PaymentForm({ + clientSecret, + packageId, + packageInfo, + onPaymentSuccess, + onPaymentCancel, +}: PaymentFormProps) { + const stripe = useStripe(); + const elements = useElements(); + const [processing, setProcessing] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + console.error('Stripe or elements not found'); + return; + } + + setProcessing(true); + + try { + // Confirm the payment using PaymentElement + const { error } = await stripe.confirmPayment({ + elements, + redirect: "if_required", + }); + + if (error) { + console.error('PaymentForm, payment error:', error); + throw new Error(error.message || "Payment failed"); + } else { + // The payment was successful + const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); + if (paymentIntent.paymentIntent) { + const result = await confirmCreditPayment({ + packageId, + paymentIntentId: paymentIntent.paymentIntent.id, + }); + + if (result?.data?.success) { + console.log('PaymentForm, payment success'); + toast.success(`${packageInfo.credits} credits have been added to your account.`); + onPaymentSuccess(); + } else { + console.error('PaymentForm, payment error:', result?.data?.error); + throw new Error( + result?.data?.error || + result?.serverError || + 'Failed to confirm payment' + ); + } + } else { + console.error('PaymentForm, no payment intent found'); + throw new Error("No payment intent found"); + } + } + } catch (error) { + console.error('PaymentForm, payment error:', error); + toast.error('Purchase credits failed'); + } finally { + setProcessing(false); + } + }; + + return ( +
+ + + +
+
+ +
+ {packageInfo.credits.toLocaleString()} +
+
+
+ {formatPrice(packageInfo.price, 'USD')} +
+
+
+
+ +
+

+ We use Stripe, a trusted global payment provider, to process your payment. + For your security, your payment details are handled directly by Stripe and never touch our servers. +

+
+
+
+ +
+ +
+ + +
+ +
+ ); +} diff --git a/src/config/sidebar-config.tsx b/src/config/sidebar-config.tsx index 2aa6936..2dd17cf 100644 --- a/src/config/sidebar-config.tsx +++ b/src/config/sidebar-config.tsx @@ -5,6 +5,7 @@ import type { NestedMenuItem } from '@/types'; import { BellIcon, CircleUserRoundIcon, + CoinsIcon, CreditCardIcon, LayoutDashboardIcon, LockKeyholeIcon, @@ -66,6 +67,12 @@ export function getSidebarLinks(): NestedMenuItem[] { href: Routes.SettingsBilling, external: false, }, + { + title: t('settings.credits.title'), + icon: , + href: Routes.SettingsCredits, + external: false, + }, { title: t('settings.security.title'), icon: , diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 07cc3f5..ba50ebb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,12 +1,37 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; -// credit package definition (example) +// credit package definition (price in cents) export const CREDIT_PACKAGES = [ - { id: 'package-1', credits: 1000, price: 10 }, - { id: 'package-2', credits: 2500, price: 20 }, - { id: 'package-3', credits: 5000, price: 30 }, -]; + { + id: 'basic', + credits: 100, + price: 990, // 9.90 USD in cents + popular: false, + description: 'Perfect for getting started', + }, + { + id: 'standard', + credits: 200, + price: 1490, // 14.90 USD in cents + popular: true, + description: 'Most popular package', + }, + { + id: 'premium', + credits: 500, + price: 3990, // 39.90 USD in cents + popular: false, + description: 'Best value for heavy users', + }, + { + id: 'enterprise', + credits: 1000, + price: 6990, // 69.90 USD in cents + popular: false, + description: 'Tailored for enterprises', + }, +] as const; // free monthly credits (10% of the smallest package) export const FREE_MONTHLY_CREDITS = 50; diff --git a/src/payment/index.ts b/src/payment/index.ts index 0a3d09f..0d52125 100644 --- a/src/payment/index.ts +++ b/src/payment/index.ts @@ -2,8 +2,11 @@ import { websiteConfig } from '@/config/website'; import { StripeProvider } from './provider/stripe'; import type { CheckoutResult, + ConfirmPaymentIntentParams, CreateCheckoutParams, + CreatePaymentIntentParams, CreatePortalParams, + PaymentIntentResult, PaymentProvider, PortalResult, Subscription, @@ -92,3 +95,27 @@ export const getSubscriptions = async ( const provider = getPaymentProvider(); return provider.getSubscriptions(params); }; + +/** + * Create a payment intent + * @param params Parameters for creating the payment intent + * @returns Payment intent result + */ +export const createPaymentIntent = async ( + params: CreatePaymentIntentParams +): Promise => { + const provider = getPaymentProvider(); + return provider.createPaymentIntent(params); +}; + +/** + * Confirm a payment intent + * @param params Parameters for confirming the payment intent + * @returns True if successful + */ +export const confirmPaymentIntent = async ( + params: ConfirmPaymentIntentParams +): Promise => { + const provider = getPaymentProvider(); + return provider.confirmPaymentIntent(params); +}; diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 44e2e66..1981476 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -7,12 +7,17 @@ import { findPriceInPlan, } from '@/lib/price-plan'; import { sendNotification } from '@/notification/notification'; +import { addCredits } from '@/lib/credits'; +import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; import { desc, eq } from 'drizzle-orm'; import { Stripe } from 'stripe'; import { type CheckoutResult, + type ConfirmPaymentIntentParams, type CreateCheckoutParams, + type CreatePaymentIntentParams, type CreatePortalParams, + type PaymentIntentResult, type PaymentProvider, type PaymentStatus, PaymentTypes, @@ -397,6 +402,12 @@ export class StripeProvider implements PaymentProvider { await this.onOnetimePayment(session); } } + } else if (eventType.startsWith('payment_intent.')) { + // Handle payment intent events + if (eventType === 'payment_intent.succeeded') { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + await this.onPaymentIntentSucceeded(paymentIntent); + } } } catch (error) { console.error('handle webhook event error:', error); @@ -632,6 +643,97 @@ export class StripeProvider implements PaymentProvider { await sendNotification(session.id, customerId, userId, amount); } + /** + * Handle payment intent succeeded event + * @param paymentIntent Stripe payment intent + */ + private async onPaymentIntentSucceeded( + paymentIntent: Stripe.PaymentIntent + ): Promise { + console.log(`>> Handle payment intent succeeded: ${paymentIntent.id}`); + + // Get metadata from payment intent + const { packageId, userId, credits } = paymentIntent.metadata; + + if (!packageId || !userId || !credits) { + console.warn( + `<< Missing metadata for payment intent ${paymentIntent.id}: packageId=${packageId}, userId=${userId}, credits=${credits}` + ); + return; + } + + try { + // Add credits to user account using existing addCredits method + await addCredits({ + userId, + amount: parseInt(credits), + type: CREDIT_TRANSACTION_TYPE.PURCHASE, + description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`, + paymentId: paymentIntent.id, + }); + + console.log( + `<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}` + ); + } catch (error) { + console.error( + `<< Error processing payment intent ${paymentIntent.id}:`, + error + ); + throw error; + } + } + + /** + * Create a payment intent + * @param params Parameters for creating the payment intent + * @returns Payment intent result + */ + public async createPaymentIntent( + params: CreatePaymentIntentParams + ): Promise { + const { amount, currency, metadata } = params; + + try { + const paymentIntent = await this.stripe.paymentIntents.create({ + amount, + currency, + metadata, + automatic_payment_methods: { + enabled: true, + }, + }); + + return { + id: paymentIntent.id, + clientSecret: paymentIntent.client_secret!, + }; + } catch (error) { + console.error('Create payment intent error:', error); + throw new Error('Failed to create payment intent'); + } + } + + /** + * Confirm a payment intent + * @param params Parameters for confirming the payment intent + * @returns True if successful + */ + public async confirmPaymentIntent( + params: ConfirmPaymentIntentParams + ): Promise { + const { paymentIntentId } = params; + + try { + const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + + return paymentIntent.status === 'succeeded'; + } catch (error) { + console.error('Confirm payment intent error:', error); + throw new Error('Failed to confirm payment intent'); + } + } + /** * Map Stripe subscription interval to our own interval types * @param subscription Stripe subscription diff --git a/src/payment/types.ts b/src/payment/types.ts index 7b5bd00..7b80936 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -159,6 +159,30 @@ export interface getSubscriptionsParams { userId: string; } +/** + * Parameters for creating a payment intent + */ +export interface CreatePaymentIntentParams { + amount: number; + currency: string; + metadata?: Record; +} + +/** + * Result of creating a payment intent + */ +export interface PaymentIntentResult { + id: string; + clientSecret: string; +} + +/** + * Parameters for confirming a payment intent + */ +export interface ConfirmPaymentIntentParams { + paymentIntentId: string; +} + /** * Payment provider interface */ @@ -178,6 +202,16 @@ export interface PaymentProvider { */ getSubscriptions(params: getSubscriptionsParams): Promise; + /** + * Create a payment intent + */ + createPaymentIntent(params: CreatePaymentIntentParams): Promise; + + /** + * Confirm a payment intent + */ + confirmPaymentIntent(params: ConfirmPaymentIntentParams): Promise; + /** * Handle webhook events */ diff --git a/src/routes.ts b/src/routes.ts index 1f1adf2..cab64b1 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -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, ]; From 13bee49f903f81b3cb5b0248ffa77e50c1c4d925 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 5 Jul 2025 23:25:37 +0800 Subject: [PATCH 18/87] feat: add credits section to avatar configuration and update translations - Added "Credits" entry to the avatar configuration for navigation. - Updated English and Chinese translation files to include "Credits" label. - Refactored error messages in credit payment actions for clarity. - Enhanced loading state management in CreditPackages component. - Replaced icons in CreditPackages component for improved UI consistency. --- messages/en.json | 1 + messages/zh.json | 1 + src/actions/credits.action.ts | 11 +++--- .../settings/credits/credit-packages.tsx | 36 +++++++++---------- .../settings/credits/stripe-payment-form.tsx | 26 ++++++++------ src/config/avatar-config.tsx | 6 ++++ 6 files changed, 46 insertions(+), 35 deletions(-) diff --git a/messages/en.json b/messages/en.json index b4156ec..02b51c4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -432,6 +432,7 @@ "avatar": { "dashboard": "Dashboard", "billing": "Billing", + "credits": "Credits", "settings": "Settings" } }, diff --git a/messages/zh.json b/messages/zh.json index cf11293..c614203 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -433,6 +433,7 @@ "avatar": { "dashboard": "工作台", "billing": "账单", + "credits": "积分", "settings": "设置" } }, diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 0b96e67..2c89842 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,18 +1,17 @@ 'use server'; +import { CREDIT_PACKAGES } from '@/lib/constants'; import { addMonthlyFreeCredits, addRegisterGiftCredits, consumeCredits, getUserCredits, - addCredits, } from '@/lib/credits'; import { getSession } from '@/lib/server'; +import { confirmPaymentIntent, createPaymentIntent } from '@/payment'; import { createSafeActionClient } from 'next-safe-action'; -import { z } from 'zod'; -import { createPaymentIntent, confirmPaymentIntent } from '@/payment'; -import { CREDIT_PACKAGES } from '@/lib/constants'; import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; const actionClient = createSafeActionClient(); @@ -73,7 +72,7 @@ export const createCreditPaymentIntent = actionClient .schema(createPaymentIntentSchema) .action(async ({ parsedInput }) => { const session = await getSession(); - if (!session) return { success: false, error: 'User not authenticated' }; + if (!session) return { success: false, error: 'Unauthorized' }; const { packageId } = parsedInput; @@ -115,7 +114,7 @@ export const confirmCreditPayment = actionClient .schema(confirmPaymentSchema) .action(async ({ parsedInput }) => { const session = await getSession(); - if (!session) return { success: false, error: 'User not authenticated' }; + if (!session) return { success: false, error: 'Unauthorized' }; const { packageId, paymentIntentId } = parsedInput; diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 783c311..2bbeb3b 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -8,29 +8,29 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { CREDIT_PACKAGES } from '@/lib/constants'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; -import { CheckIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { Separator } from '../../ui/separator'; import { StripePaymentForm } from './stripe-payment-form'; -import { toast } from 'sonner'; export function CreditPackages() { + const [loadingCredits, setLoadingCredits] = useState(true); const [loadingPackage, setLoadingPackage] = useState(null); + const [credits, setCredits] = useState(null); const [paymentDialog, setPaymentDialog] = useState<{ isOpen: boolean; - clientSecret: string | null; packageId: string | null; + clientSecret: string | null; }>({ isOpen: false, - clientSecret: null, packageId: null, + clientSecret: null, }); - const [credits, setCredits] = useState(null); - const [loading, setLoading] = useState(true); const fetchCredits = async () => { try { - setLoading(true); + setLoadingCredits(true); const result = await getCreditsAction(); if (result?.data?.success) { console.log('CreditPackages, fetched credits:', result.data.credits); @@ -44,7 +44,7 @@ export function CreditPackages() { console.error('CreditPackages, failed to fetch credits:', error); toast.error('Failed to fetch credits'); } finally { - setLoading(false); + setLoadingCredits(false); } }; @@ -55,13 +55,12 @@ export function CreditPackages() { const handlePurchase = async (packageId: string) => { try { setLoadingPackage(packageId); - const result = await createCreditPaymentIntent({ packageId }); if (result?.data?.success && result?.data?.clientSecret) { setPaymentDialog({ isOpen: true, - clientSecret: result.data.clientSecret, packageId, + clientSecret: result.data.clientSecret, }); } else { const errorMessage = result?.data?.error || 'Failed to create payment intent'; @@ -77,11 +76,11 @@ export function CreditPackages() { }; const handlePaymentSuccess = () => { - console.log('CreditPackages, payment successful'); + console.log('CreditPackages, payment success'); setPaymentDialog({ isOpen: false, - clientSecret: null, packageId: null, + clientSecret: null, }); // Refresh credit balance without page reload @@ -95,8 +94,8 @@ export function CreditPackages() { console.log('CreditPackages, payment cancelled'); setPaymentDialog({ isOpen: false, - clientSecret: null, packageId: null, + clientSecret: null, }); }; @@ -108,14 +107,14 @@ export function CreditPackages() {
- Credit Balance + Credit Balance
- {loading ? ( + {loadingCredits ? ( ... ) : ( credits?.toLocaleString() || 0 @@ -127,7 +126,7 @@ export function CreditPackages() {
-

Credit Packages

+

Credit Packages

Purchase additional credits to use our services

@@ -153,7 +152,8 @@ export function CreditPackages() {
- {pkg.credits.toLocaleString()} + + {pkg.credits.toLocaleString()}
@@ -164,7 +164,7 @@ export function CreditPackages() {
- + {pkg.description}
diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx index f86dc00..ff7d6c3 100644 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -28,15 +28,23 @@ interface StripePaymentFormProps { onPaymentCancel: () => void; } +/** + * StripePaymentForm is a component that displays a payment form for a credit package. + * It uses the Stripe Elements API to display a payment form. + * + * @param props - The props for the StripePaymentForm component. + * @returns The StripePaymentForm component. + */ export function StripePaymentForm(props: StripePaymentFormProps) { - const { resolvedTheme: theme } = useTheme(); + if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set'); + } + const stripePromise = useMemo(() => { - if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { - throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set'); - } - return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); + return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); }, []); + const { resolvedTheme: theme } = useTheme(); const options = useMemo(() => ({ clientSecret: props.clientSecret, appearance: { @@ -110,11 +118,7 @@ function PaymentForm({ onPaymentSuccess(); } else { console.error('PaymentForm, payment error:', result?.data?.error); - throw new Error( - result?.data?.error || - result?.serverError || - 'Failed to confirm payment' - ); + throw new Error( result?.data?.error || 'Failed to confirm payment' ); } } else { console.error('PaymentForm, no payment intent found'); @@ -180,7 +184,7 @@ function PaymentForm({ ) : ( <> - Pay {formatPrice(packageInfo.price, 'USD')} + Pay {/* {formatPrice(packageInfo.price, 'USD')} */} )} diff --git a/src/config/avatar-config.tsx b/src/config/avatar-config.tsx index 78d5083..749e125 100644 --- a/src/config/avatar-config.tsx +++ b/src/config/avatar-config.tsx @@ -3,6 +3,7 @@ import { Routes } from '@/routes'; import type { MenuItem } from '@/types'; import { + CoinsIcon, CreditCardIcon, LayoutDashboardIcon, Settings2Icon, @@ -33,6 +34,11 @@ export function getAvatarLinks(): MenuItem[] { href: Routes.SettingsBilling, icon: , }, + { + title: t('credits'), + href: Routes.SettingsCredits, + icon: , + }, { title: t('settings'), href: Routes.SettingsProfile, From e9338444792d3e7dd93af1c149a052d44d020c59 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 5 Jul 2025 23:59:30 +0800 Subject: [PATCH 19/87] feat: enhance credit management with transaction store and UI updates - Added a new transaction store to manage refresh triggers for credit-related components. - Updated CreditPackages and StripePaymentForm components to utilize the transaction store for refreshing UI after credit purchases. - Modified .gitignore to include certificates. - Introduced a new script in package.json for running the development server with HTTPS support. --- .gitignore | 4 +++- package.json | 1 + src/components/settings/credits/credit-packages.tsx | 11 +++++------ .../settings/credits/stripe-payment-form.tsx | 6 ++++++ src/stores/transaction-store.ts | 11 +++++++++++ 5 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 src/stores/transaction-store.ts diff --git a/.gitignore b/.gitignore index 32c5e88..98722b3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +certificates + # env files (can opt-in for committing if needed) .env* @@ -53,4 +55,4 @@ next-env.d.ts .wrangler .dev.vars .dev.vars* -!.dev.vars.example \ No newline at end of file +!.dev.vars.example diff --git a/package.json b/package.json index 0f79bd4..d0fe6b3 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "dev-https": "next dev --experimental-https", "build": "next build", "start": "next start", "postinstall": "fumadocs-mdx", diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 2bbeb3b..a71fdfc 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { CREDIT_PACKAGES } from '@/lib/constants'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; +import { useTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -28,6 +29,8 @@ export function CreditPackages() { clientSecret: null, }); + const { refreshTrigger } = useTransactionStore(); + const fetchCredits = async () => { try { setLoadingCredits(true); @@ -48,9 +51,10 @@ export function CreditPackages() { } }; + // Initial fetch and listen for transaction updates useEffect(() => { fetchCredits(); - }, []); + }, [refreshTrigger]); const handlePurchase = async (packageId: string) => { try { @@ -82,11 +86,6 @@ export function CreditPackages() { packageId: null, clientSecret: null, }); - - // Refresh credit balance without page reload - fetchCredits(); - - // Show success toast toast.success('Your credits have been added to your account'); }; diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx index ff7d6c3..f7bb42c 100644 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -4,6 +4,7 @@ import { confirmCreditPayment } from '@/actions/credits.action'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { formatPrice } from '@/lib/formatter'; +import { useTransactionStore } from '@/stores/transaction-store'; import { Elements, PaymentElement, @@ -82,6 +83,7 @@ function PaymentForm({ const stripe = useStripe(); const elements = useElements(); const [processing, setProcessing] = useState(false); + const { triggerRefresh } = useTransactionStore(); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -115,6 +117,10 @@ function PaymentForm({ if (result?.data?.success) { console.log('PaymentForm, payment success'); toast.success(`${packageInfo.credits} credits have been added to your account.`); + + // Trigger refresh for transaction-dependent UI components + triggerRefresh(); + onPaymentSuccess(); } else { console.error('PaymentForm, payment error:', result?.data?.error); diff --git a/src/stores/transaction-store.ts b/src/stores/transaction-store.ts new file mode 100644 index 0000000..47107da --- /dev/null +++ b/src/stores/transaction-store.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface TransactionStore { + refreshTrigger: number; + triggerRefresh: () => void; +} + +export const useTransactionStore = create((set) => ({ + refreshTrigger: 0, + triggerRefresh: () => set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })), +})); From 75083b32e4dd7f2e1f84b948966875c66ee0aa95 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 6 Jul 2025 00:15:07 +0800 Subject: [PATCH 20/87] refactor: reorganize credit-related imports and enhance user feedback - Updated import paths for credit-related actions and functions to improve module organization. - Removed redundant refresh trigger declaration in CreditPackages component. - Simplified success toast message in CreditPackages and PaymentForm components for clarity. - Introduced a new credits.ts file to centralize credit management logic and improve maintainability. --- src/actions/credits.action.ts | 2 +- .../settings/credits/credit-packages.tsx | 5 +- .../settings/credits/stripe-payment-form.tsx | 4 +- src/credits/README.md | 163 ++++++++++++++++++ src/{lib => credits}/credits.ts | 2 +- src/payment/provider/stripe.ts | 2 +- 6 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/credits/README.md rename src/{lib => credits}/credits.ts (99%) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 2c89842..2be7e67 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -6,7 +6,7 @@ import { addRegisterGiftCredits, consumeCredits, getUserCredits, -} from '@/lib/credits'; +} from '@/credits/credits'; import { getSession } from '@/lib/server'; import { confirmPaymentIntent, createPaymentIntent } from '@/payment'; import { createSafeActionClient } from 'next-safe-action'; diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index a71fdfc..09e6efd 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -19,6 +19,7 @@ export function CreditPackages() { const [loadingCredits, setLoadingCredits] = useState(true); const [loadingPackage, setLoadingPackage] = useState(null); const [credits, setCredits] = useState(null); + const { refreshTrigger } = useTransactionStore(); const [paymentDialog, setPaymentDialog] = useState<{ isOpen: boolean; packageId: string | null; @@ -29,8 +30,6 @@ export function CreditPackages() { clientSecret: null, }); - const { refreshTrigger } = useTransactionStore(); - const fetchCredits = async () => { try { setLoadingCredits(true); @@ -86,7 +85,7 @@ export function CreditPackages() { packageId: null, clientSecret: null, }); - toast.success('Your credits have been added to your account'); + toast.success('Credits have been added to your account'); }; const handlePaymentCancel = () => { diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx index f7bb42c..6fea790 100644 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -116,12 +116,12 @@ function PaymentForm({ if (result?.data?.success) { console.log('PaymentForm, payment success'); - toast.success(`${packageInfo.credits} credits have been added to your account.`); - // Trigger refresh for transaction-dependent UI components triggerRefresh(); + // Show success toast onPaymentSuccess(); + // toast.success(`${packageInfo.credits} credits have been added to your account.`); } else { console.error('PaymentForm, payment error:', result?.data?.error); throw new Error( result?.data?.error || 'Failed to confirm payment' ); diff --git a/src/credits/README.md b/src/credits/README.md new file mode 100644 index 0000000..aa96bb5 --- /dev/null +++ b/src/credits/README.md @@ -0,0 +1,163 @@ +# Credit Management System Implementation + +## Overview + +This document describes the credit management system implementation for the mksaas-template project, which allows users to purchase credits using Stripe payments. + +## Features Implemented + +### 1. Credit Packages +- Defined credit packages with different tiers (Basic, Standard, Premium, Enterprise) +- Each package includes credits amount, price, and description +- Popular package highlighting + +### 2. Payment Integration +- Stripe PaymentIntent integration for credit purchases +- Secure payment processing with webhook verification +- Automatic credit addition upon successful payment + +### 3. UI Components +- Credit balance display with refresh functionality +- Credit packages selection interface +- Stripe payment form integration +- Modern, responsive design + +### 4. Database Integration +- Credit transaction recording +- User credit balance management +- Proper error handling and validation + +## Files Created/Modified + +### Core Components +- `src/components/dashboard/credit-packages.tsx` - Main credit packages interface +- `src/components/dashboard/credit-balance.tsx` - Credit balance display +- `src/components/dashboard/stripe-payment-form.tsx` - Stripe payment integration + +### Actions & API +- `src/actions/credits.action.ts` - Credit-related server actions +- `src/app/api/webhooks/stripe/route.ts` - Stripe webhook handler +- `src/payment/index.ts` - Payment provider interface (updated) +- `src/payment/types.ts` - Payment types (updated) + +### Configuration +- `src/lib/constants.ts` - Credit packages configuration +- `env.example` - Environment variables template + +### Pages +- `src/app/[locale]/(protected)/settings/credits/page.tsx` - Credits management page + +## Environment Variables Required + +Add these to your `.env.local` file: + +```env +# Stripe Configuration +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_WEBHOOK_SECRET="whsec_..." +``` + +## Setup Instructions + +### 1. Stripe Configuration +1. Create a Stripe account at https://dashboard.stripe.com +2. Get your API keys from the Stripe dashboard +3. Set up a webhook endpoint pointing to `/api/webhooks/stripe` +4. Copy the webhook secret and add it to your environment variables + +### 2. Database Setup +Make sure your database schema includes the required credit tables as defined in `src/db/schema.ts`. + +### 3. Environment Variables +Copy the required environment variables from `env.example` to your `.env.local` file and fill in the values. + +## Usage + +### For Users +1. Navigate to `/settings/credits` +2. View current credit balance +3. Select a credit package +4. Complete payment using Stripe +5. Credits are automatically added to account + +### For Developers +```typescript +// Get user credits +const result = await getCreditsAction(); + +// Create payment intent +const paymentIntent = await createCreditPaymentIntent({ + packageId: 'standard' +}); + +// Add credits manually +await addCredits({ + userId: 'user-id', + amount: 100, + type: 'PURCHASE', + description: 'Credit purchase' +}); +``` + +## Credit Packages Configuration + +Edit `src/lib/constants.ts` to modify available credit packages: + +```typescript +export const CREDIT_PACKAGES = [ + { + id: 'basic', + credits: 100, + price: 9.99, + popular: false, + description: 'Perfect for getting started', + }, + // ... more packages +]; +``` + +## Webhook Events + +The system handles these Stripe webhook events: +- `payment_intent.succeeded` - Adds credits to user account upon successful payment + +## Security Features + +1. **Webhook Verification**: All webhook requests are verified using Stripe signatures +2. **Payment Validation**: Amount and package validation before processing +3. **User Authentication**: All credit operations require authenticated users +4. **Metadata Validation**: Payment metadata is validated before processing + +## Error Handling + +The system includes comprehensive error handling for: +- Invalid payment attempts +- Network failures +- Database errors +- Authentication issues +- Webhook verification failures + +## Testing + +To test the credit purchase flow: +1. Use Stripe test cards (e.g., `4242424242424242`) +2. Monitor webhook events in Stripe dashboard +3. Check credit balance updates in the application + +## Integration Notes + +This implementation: +- Uses Next.js server actions for secure server-side operations +- Integrates with existing Drizzle ORM schema +- Follows the existing payment provider pattern +- Maintains consistency with the existing codebase architecture + +## Future Enhancements + +Potential improvements: +- Credit transaction history display +- Credit expiration management +- Bulk credit operations +- Credit usage analytics +- Subscription-based credit allocation diff --git a/src/lib/credits.ts b/src/credits/credits.ts similarity index 99% rename from src/lib/credits.ts rename to src/credits/credits.ts index 530e844..9506e7f 100644 --- a/src/lib/credits.ts +++ b/src/credits/credits.ts @@ -8,7 +8,7 @@ import { CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, REGISTER_GIFT_CREDITS, -} from './constants'; +} from '../lib/constants'; /** * Get user's current credit balance diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 1981476..0e8b6bd 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -7,7 +7,7 @@ import { findPriceInPlan, } from '@/lib/price-plan'; import { sendNotification } from '@/notification/notification'; -import { addCredits } from '@/lib/credits'; +import { addCredits } from '@/credits/credits'; import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; import { desc, eq } from 'drizzle-orm'; import { Stripe } from 'stripe'; From 1740c826c756f3fb4a1d3ec4a0c962945d4663bb Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 6 Jul 2025 00:41:57 +0800 Subject: [PATCH 21/87] feat: add CreditsBalance component and integrate into dashboard and navbar - Introduced a new CreditsBalance component to display user credits. - Integrated CreditsBalance into DashboardHeader, Navbar, and NavbarMobile for improved visibility of user credits. - Enhanced user interaction by allowing navigation to the credits settings page. --- src/components/dashboard/dashboard-header.tsx | 2 + src/components/layout/credits-balance.tsx | 51 +++++++++++++++++++ src/components/layout/navbar-mobile.tsx | 6 ++- src/components/layout/navbar.tsx | 6 ++- .../settings/credits/stripe-payment-form.tsx | 17 +++---- 5 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 src/components/layout/credits-balance.tsx diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 22e514e..1cb04e6 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -11,6 +11,7 @@ import React, { type ReactNode } from 'react'; import LocaleSwitcher from '../layout/locale-switcher'; import { ModeSwitcher } from '../layout/mode-switcher'; import { ThemeSelector } from '../layout/theme-selector'; +import { CreditsBalance } from '../layout/credits-balance'; interface DashboardBreadcrumbItem { label: string; @@ -72,6 +73,7 @@ export function DashboardHeader({
{actions} + {isDemo && } diff --git a/src/components/layout/credits-balance.tsx b/src/components/layout/credits-balance.tsx new file mode 100644 index 0000000..5f69707 --- /dev/null +++ b/src/components/layout/credits-balance.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { getCreditsAction } from '@/actions/credits.action'; +import { Button } from '@/components/ui/button'; +import { useLocaleRouter } from '@/i18n/navigation'; +import { Routes } from '@/routes'; +import { useTransactionStore } from '@/stores/transaction-store'; +import { CoinsIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export function CreditsBalance() { + const router = useLocaleRouter(); + const { refreshTrigger } = useTransactionStore(); + const [credits, setCredits] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchCredits = async () => { + try { + const result = await getCreditsAction(); + if (result?.data?.success && result.data.credits !== undefined) { + setCredits(result.data.credits); + } + } catch (error) { + console.error('CreditsBalance, fetch credits error:', error); + } finally { + setLoading(false); + } + }; + + fetchCredits(); + }, [refreshTrigger]); + + const handleClick = () => { + router.push(Routes.SettingsCredits); + }; + + return ( + + ); +} diff --git a/src/components/layout/navbar-mobile.tsx b/src/components/layout/navbar-mobile.tsx index a2cb0a9..fb530ec 100644 --- a/src/components/layout/navbar-mobile.tsx +++ b/src/components/layout/navbar-mobile.tsx @@ -28,6 +28,7 @@ import { useEffect, useState } from 'react'; import { RemoveScroll } from 'react-remove-scroll'; import { Skeleton } from '../ui/skeleton'; import { UserButtonMobile } from './user-button-mobile'; +import { CreditsBalance } from './credits-balance'; export function NavbarMobile({ className, @@ -94,7 +95,10 @@ export function NavbarMobile({ {isPending ? ( ) : currentUser ? ( - + <> + + + ) : null} +
+ ); +} + +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 { type CreditTransaction }; + +export function CreditTransactionsTable({ + data, + total, + pageIndex, + pageSize, + search, + loading, + onSearch, + onPageChange, + onPageSizeChange, + onSortingChange, +}: CreditTransactionsTableProps) { + const t = useTranslations('Dashboard.admin.creditTransactions'); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + + // 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 and color + const getTransactionTypeIcon = (type: string) => { + switch (type) { + case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: + return ; + case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: + return ; + case CREDIT_TRANSACTION_TYPE.PURCHASE: + return ; + case CREDIT_TRANSACTION_TYPE.USAGE: + return ; + case CREDIT_TRANSACTION_TYPE.EXPIRE: + return ; + default: + return null; + } + }; + + // Get transaction type color + const getTransactionTypeColor = (type: string) => { + switch (type) { + case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: + return 'bg-blue-100 text-blue-800 border-blue-200'; + case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: + return 'bg-green-100 text-green-800 border-green-200'; + case CREDIT_TRANSACTION_TYPE.PURCHASE: + return 'bg-purple-100 text-purple-800 border-purple-200'; + case CREDIT_TRANSACTION_TYPE.USAGE: + return 'bg-red-100 text-red-800 border-red-200'; + case CREDIT_TRANSACTION_TYPE.EXPIRE: + return 'bg-gray-100 text-gray-800 border-gray-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + // Table columns definition + const columns: ColumnDef[] = [ + { + accessorKey: 'type', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ + {getTransactionTypeIcon(transaction.type)} + {transaction.type} + +
+ ); + }, + }, + { + accessorKey: 'amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {transaction.amount > 0 ? '+' : ''} + {transaction.amount.toLocaleString()} + +
+ ); + }, + }, + { + accessorKey: 'remainingAmount', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ {transaction.remainingAmount !== null ? ( + + {transaction.remainingAmount.toLocaleString()} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'description', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ + {transaction.description || '-'} + +
+ ); + }, + }, + { + accessorKey: 'paymentId', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ {transaction.paymentId ? ( + { + navigator.clipboard.writeText(transaction.paymentId!); + toast.success(t('paymentIdCopied')); + }} + > + {transaction.paymentId} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'expirationDate', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ {transaction.expirationDate ? ( + + {formatDate(transaction.expirationDate)} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'expirationDateProcessedAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ {transaction.expirationDateProcessedAt ? ( + + {formatDate(transaction.expirationDateProcessedAt)} + + ) : ( + - + )} +
+ ); + }, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ + {formatDate(transaction.createdAt)} + +
+ ); + }, + }, + { + accessorKey: 'updatedAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const transaction = row.original; + return ( +
+ + {formatDate(transaction.updatedAt)} + +
+ ); + }, + }, + ]; + + 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 ( +
+
+
+ { + onSearch(event.target.value); + onPageChange(0); + }} + className="max-w-sm" + /> +
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {t( + columnIdToTranslationKey[ + column.id as keyof typeof columnIdToTranslationKey + ] || 'columns.columns' + )} + + ); + })} + + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {loading ? t('loading') : t('noResults')} + + + )} + +
+
+ +
+
+ {total > 0 && ( + + {t('totalRecords', { count: total })} + + )} +
+
+
+ + +
+
+ {t('page')} {pageIndex + 1} {' / '} + {Math.max(1, Math.ceil(total / pageSize))} +
+
+ + + + +
+
+
+
+ ); +} From d8a12343c8e7f5c1ef7e7c34b7c2e78965cdfbd5 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 6 Jul 2025 11:30:10 +0800 Subject: [PATCH 23/87] refactor: adjust spacing in layout components for consistency - Updated spacing from `space-y-10` to `space-y-8` in layout components for Billing, Credits, Notifications, Profile, and Security to ensure uniformity across the settings pages. --- src/app/[locale]/(protected)/settings/billing/layout.tsx | 2 +- src/app/[locale]/(protected)/settings/credits/layout.tsx | 2 +- src/app/[locale]/(protected)/settings/notifications/layout.tsx | 2 +- src/app/[locale]/(protected)/settings/profile/layout.tsx | 2 +- src/app/[locale]/(protected)/settings/security/layout.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/[locale]/(protected)/settings/billing/layout.tsx b/src/app/[locale]/(protected)/settings/billing/layout.tsx index af239d4..5b85ac6 100644 --- a/src/app/[locale]/(protected)/settings/billing/layout.tsx +++ b/src/app/[locale]/(protected)/settings/billing/layout.tsx @@ -26,7 +26,7 @@ export default async function BillingLayout({ children }: BillingLayoutProps) {
-
+

{t('billing.title')} diff --git a/src/app/[locale]/(protected)/settings/credits/layout.tsx b/src/app/[locale]/(protected)/settings/credits/layout.tsx index 9fc5771..5c1b6bc 100644 --- a/src/app/[locale]/(protected)/settings/credits/layout.tsx +++ b/src/app/[locale]/(protected)/settings/credits/layout.tsx @@ -26,7 +26,7 @@ export default async function CreditsLayout({ children }: CreditsLayoutProps) {
-
+

{t('credits.title')} diff --git a/src/app/[locale]/(protected)/settings/notifications/layout.tsx b/src/app/[locale]/(protected)/settings/notifications/layout.tsx index e480a3a..740dfad 100644 --- a/src/app/[locale]/(protected)/settings/notifications/layout.tsx +++ b/src/app/[locale]/(protected)/settings/notifications/layout.tsx @@ -28,7 +28,7 @@ export default async function NotificationsLayout({
-
+

{t('notification.title')} diff --git a/src/app/[locale]/(protected)/settings/profile/layout.tsx b/src/app/[locale]/(protected)/settings/profile/layout.tsx index c807a00..bf2e737 100644 --- a/src/app/[locale]/(protected)/settings/profile/layout.tsx +++ b/src/app/[locale]/(protected)/settings/profile/layout.tsx @@ -26,7 +26,7 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
-
+

{t('profile.title')} diff --git a/src/app/[locale]/(protected)/settings/security/layout.tsx b/src/app/[locale]/(protected)/settings/security/layout.tsx index d42ac2e..c35eb87 100644 --- a/src/app/[locale]/(protected)/settings/security/layout.tsx +++ b/src/app/[locale]/(protected)/settings/security/layout.tsx @@ -28,7 +28,7 @@ export default async function SecurityLayout({
-
+

{t('security.title')} From d9cda3e1224a8290f08f6568191523e8d40edda3 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 6 Jul 2025 17:17:24 +0800 Subject: [PATCH 24/87] feat: enhance credits settings with tabbed interface and improved translations - Implemented a tabbed interface in the CreditsPage component to separate balance and transactions views. - Updated CreditPackages and CreditTransactionsPageClient components to utilize the new tab structure. - Enhanced translation support for credits-related messages in both English and Chinese. - Improved error handling and user feedback in credit-related components. - Refactored CreditTransactionsTable to utilize translations for table headers and pagination controls. --- messages/en.json | 109 ++++++++------ messages/zh.json | 114 +++++++++----- .../(protected)/settings/credits/page.tsx | 27 +++- .../settings/credits/credit-packages.tsx | 139 +++++++++--------- .../credits/credit-transactions-page.tsx | 32 ++-- .../credits/credit-transactions-table.tsx | 82 +++++++---- .../settings/credits/stripe-payment-form.tsx | 10 +- 7 files changed, 308 insertions(+), 205 deletions(-) diff --git a/messages/en.json b/messages/en.json index 64cada0..fd5b728 100644 --- a/messages/en.json +++ b/messages/en.json @@ -28,7 +28,19 @@ "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" + } }, "PricingPage": { "title": "Pricing", @@ -494,44 +506,8 @@ "error": "Failed to unban user" }, "close": "Close" + } }, - "creditTransactions": { - "title": "Credit Transactions", - "error": "Failed to get credit transactions", - "search": "Search credit transactions...", - "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" - }, - "noResults": "No results", - "firstPage": "First Page", - "lastPage": "Last Page", - "nextPage": "Next Page", - "previousPage": "Previous Page", - "rowsPerPage": "Rows per page", - "page": "Page", - "loading": "Loading...", - "paymentIdCopied": "Payment ID copied to clipboard", - "types": { - "MONTHLY_REFRESH": "Monthly Refresh", - "REGISTER_GIFT": "Register Gift", - "PURCHASE": "Purchase", - "USAGE": "Usage", - "EXPIRE": "Expire" - }, - "expired": "Expired", - "never": "Never" - } - }, "settings": { "title": "Settings", "profile": { @@ -596,9 +572,60 @@ "credits": { "title": "Credits", "description": "Manage your credits", - "credits": "Credits", - "creditsDescription": "You have {credits} credits", - "creditsExpired": "Credits expired" + "balance": { + "title": "Credit Balance", + "credits": "Credits", + "creditsDescription": "You have {credits} credits", + "creditsExpired": "Credits expired" + }, + "packages": { + "balance": "Credit Balance", + "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", + "creditsAdded": "Credits have been added to your account", + "cancel": "Cancel", + "purchaseFailed": "Purchase credits failed", + "pay": "Pay" + }, + "tabs": { + "balance": "Balance", + "transactions": "Transactions" + }, + "transactions": { + "title": "Credit Transactions", + "error": "Failed to get credit transactions", + "search": "Search credit transactions...", + "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" + }, + "paymentIdCopied": "Payment ID copied to clipboard", + "types": { + "MONTHLY_REFRESH": "Monthly Refresh", + "REGISTER_GIFT": "Register Gift", + "PURCHASE": "Purchase", + "USAGE": "Usage", + "EXPIRE": "Expire" + }, + "expired": "Expired", + "never": "Never" + } }, "notification": { "title": "Notification", diff --git a/messages/zh.json b/messages/zh.json index 01f78b7..2224948 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -11,9 +11,9 @@ "language": "切换语言", "mode": { "label": "切换模式", - "light": "浅色模式", - "dark": "深色模式", - "system": "跟随系统" + "light": "浅色", + "dark": "深色", + "system": "系统" }, "theme": { "label": "切换主题", @@ -28,7 +28,19 @@ "saving": "保存中...", "loading": "加载中...", "cancel": "取消", - "logoutFailed": "退出失败" + "logoutFailed": "退出失败", + "table": { + "totalRecords": "总共 {count} 条记录", + "noResults": "无结果", + "loading": "加载中...", + "columns": "列", + "rowsPerPage": "每页行数", + "page": "页", + "firstPage": "第一页", + "lastPage": "最后一页", + "nextPage": "下一页", + "previousPage": "上一页" + } }, "PricingPage": { "title": "价格", @@ -495,42 +507,6 @@ "error": "解除封禁失败" }, "close": "关闭" - }, - "creditTransactions": { - "title": "积分交易记录", - "error": "获取积分交易记录失败", - "search": "搜索积分交易记录...", - "columns": { - "columns": "列", - "id": "ID", - "type": "类型", - "description": "描述", - "amount": "金额", - "remainingAmount": "剩余金额", - "paymentId": "支付ID", - "expirationDate": "过期时间", - "expirationDateProcessedAt": "过期时间处理时间", - "createdAt": "创建时间", - "updatedAt": "更新时间" - }, - "noResults": "无结果", - "firstPage": "首页", - "lastPage": "末页", - "nextPage": "下一页", - "previousPage": "上一页", - "rowsPerPage": "每页行数", - "page": "页", - "loading": "加载中...", - "paymentIdCopied": "支付ID已复制到剪贴板", - "types": { - "MONTHLY_REFRESH": "月度刷新", - "REGISTER_GIFT": "注册礼品", - "PURCHASE": "购买", - "USAGE": "使用", - "EXPIRE": "过期" - }, - "expired": "已过期", - "never": "永不" } }, "settings": { @@ -594,6 +570,64 @@ "retry": "重试", "errorMessage": "获取数据失败" }, + "credits": { + "title": "积分", + "description": "管理您的积分", + "balance": { + "title": "积分余额", + "credits": "积分", + "creditsDescription": "您有 {credits} 积分", + "creditsExpired": "积分已过期" + }, + "packages": { + "balance": "积分余额", + "title": "积分套餐", + "description": "购买积分以使用我们的更多服务", + "purchase": "购买", + "processing": "处理中...", + "popular": "热门", + "completePurchase": "请支付订单", + "failedToFetchCredits": "获取积分失败", + "failedToCreatePaymentIntent": "创建付款意向失败", + "failedToInitiatePayment": "发起付款失败", + "creditsAdded": "积分已添加到您的账户", + "cancel": "取消", + "purchaseFailed": "购买积分失败", + "pay": "支付" + }, + "tabs": { + "balance": "积分余额", + "transactions": "积分记录" + }, + "transactions": { + "title": "积分记录", + "error": "获取积分交易记录失败", + "search": "搜索积分交易记录...", + "columns": { + "columns": "列", + "id": "ID", + "type": "类型", + "description": "描述", + "amount": "金额", + "remainingAmount": "剩余金额", + "paymentId": "支付编号", + "expirationDate": "过期日期", + "expirationDateProcessedAt": "过期日期处理时间", + "createdAt": "创建时间", + "updatedAt": "更新时间" + }, + "paymentIdCopied": "支付ID已复制到剪贴板", + "types": { + "MONTHLY_REFRESH": "每月刷新", + "REGISTER_GIFT": "注册礼品", + "PURCHASE": "购买", + "USAGE": "使用", + "EXPIRE": "过期" + }, + "expired": "已过期", + "never": "永不" + } + }, "notification": { "title": "通知", "description": "管理您的通知设置", diff --git a/src/app/[locale]/(protected)/settings/credits/page.tsx b/src/app/[locale]/(protected)/settings/credits/page.tsx index c565403..e85f741 100644 --- a/src/app/[locale]/(protected)/settings/credits/page.tsx +++ b/src/app/[locale]/(protected)/settings/credits/page.tsx @@ -1,12 +1,29 @@ import { CreditPackages } from '@/components/settings/credits/credit-packages'; import { CreditTransactionsPageClient } from '@/components/settings/credits/credit-transactions-page'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useTranslations } from 'next-intl'; export default function CreditsPage() { - return ( -
- + const t = useTranslations('Dashboard.settings.credits'); - -
+ return ( + + + + {t('tabs.balance')} + + + {t('tabs.transactions')} + + + + + + + + + + + ); } diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 09e6efd..55de6bf 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -3,19 +3,20 @@ import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { CREDIT_PACKAGES } from '@/lib/constants'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { useTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { Separator } from '../../ui/separator'; import { StripePaymentForm } from './stripe-payment-form'; export function CreditPackages() { + const t = useTranslations('Dashboard.settings.credits.packages'); const [loadingCredits, setLoadingCredits] = useState(true); const [loadingPackage, setLoadingPackage] = useState(null); const [credits, setCredits] = useState(null); @@ -38,13 +39,13 @@ export function CreditPackages() { console.log('CreditPackages, fetched credits:', result.data.credits); setCredits(result.data.credits || 0); } else { - const errorMessage = result?.data?.error || 'Failed to fetch credits'; + const errorMessage = result?.data?.error || t('failedToFetchCredits'); console.error('CreditPackages, failed to fetch credits:', errorMessage); toast.error(errorMessage); } } catch (error) { console.error('CreditPackages, failed to fetch credits:', error); - toast.error('Failed to fetch credits'); + toast.error(t('failedToFetchCredits')); } finally { setLoadingCredits(false); } @@ -66,13 +67,13 @@ export function CreditPackages() { clientSecret: result.data.clientSecret, }); } else { - const errorMessage = result?.data?.error || 'Failed to create payment intent'; + const errorMessage = result?.data?.error || t('failedToCreatePaymentIntent'); console.error('CreditPackages, failed to create payment intent:', errorMessage); toast.error(errorMessage); } } catch (error) { console.error('CreditPackages, failed to initiate payment:', error); - toast.error('Failed to initiate payment'); + toast.error(t('failedToInitiatePayment')); } finally { setLoadingPackage(null); } @@ -85,7 +86,7 @@ export function CreditPackages() { packageId: null, clientSecret: null, }); - toast.success('Credits have been added to your account'); + toast.success(t('creditsAdded')); }; const handlePaymentCancel = () => { @@ -105,7 +106,7 @@ export function CreditPackages() {
- Credit Balance + {t('balance')}
@@ -119,74 +120,74 @@ export function CreditPackages() { )}
+

+ + - + + + {t('title')} + + {t('description')} + + + +
+ {CREDIT_PACKAGES.map((pkg) => ( + + {pkg.popular && ( +
+ + {t('popular')} + +
+ )} -
-
-

Credit Packages

-

- Purchase additional credits to use our services -

-
-
- {CREDIT_PACKAGES.map((pkg) => ( - - {pkg.popular && ( -
- - Most Popular - -
- )} - - {/* + {/* {pkg.id} */} - - {/* Price and Credits - Left/Right Layout */} -
-
-
- - {pkg.credits.toLocaleString()} -
-
-
-
- {formatPrice(pkg.price, 'USD')} -
-
+ + {/* Price and Credits - Left/Right Layout */} +
+
+
+ + {pkg.credits.toLocaleString()}
- -
- - {pkg.description} +
+
+
+ {formatPrice(pkg.price, 'USD')}
+
+
- {/* purchase button */} - - - - ))} -
-
+
+ + {pkg.description} +
+ + {/* purchase button */} + +
+
+ ))}
@@ -195,7 +196,7 @@ export function CreditPackages() { - Complete Your Purchase + {t('completePurchase')} {paymentDialog.clientSecret && paymentDialog.packageId && ( diff --git a/src/components/settings/credits/credit-transactions-page.tsx b/src/components/settings/credits/credit-transactions-page.tsx index b6a9c31..4f18953 100644 --- a/src/components/settings/credits/credit-transactions-page.tsx +++ b/src/components/settings/credits/credit-transactions-page.tsx @@ -10,9 +10,9 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; export function CreditTransactionsPageClient() { - const t = useTranslations('Dashboard.admin.creditTransactions'); + const t = useTranslations('Dashboard.settings.credits.transactions'); const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(3); + const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(''); const [data, setData] = useState([]); const [total, setTotal] = useState(0); @@ -54,21 +54,17 @@ export function CreditTransactionsPageClient() { }, [pageIndex, pageSize, search, sorting, refreshTrigger]); return ( -
-

{t('title')}

- - -
+ ); } diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 43b7b0c..e8ec418 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -123,7 +123,8 @@ export function CreditTransactionsTable({ onPageSizeChange, onSortingChange, }: CreditTransactionsTableProps) { - const t = useTranslations('Dashboard.admin.creditTransactions'); + const t = useTranslations('Dashboard.settings.credits.transactions'); + const tTable = useTranslations('Common.table'); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -131,19 +132,6 @@ export function CreditTransactionsTable({ // 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 and color const getTransactionTypeIcon = (type: string) => { switch (type) { @@ -180,6 +168,24 @@ export function CreditTransactionsTable({ } }; + // 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: + return t('types.PURCHASE'); + case CREDIT_TRANSACTION_TYPE.USAGE: + return t('types.USAGE'); + case CREDIT_TRANSACTION_TYPE.EXPIRE: + return t('types.EXPIRE'); + default: + return type; + } + }; + // Table columns definition const columns: ColumnDef[] = [ { @@ -196,7 +202,7 @@ export function CreditTransactionsTable({ className={`px-2 py-1 flex items-center gap-1 ${getTransactionTypeColor(transaction.type)}`} > {getTransactionTypeIcon(transaction.type)} - {transaction.type} + {getTransactionTypeDisplayName(transaction.type)}
); @@ -419,6 +425,30 @@ export function CreditTransactionsTable({ .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { + const getColumnDisplayName = (columnId: string) => { + switch (columnId) { + case 'type': + return t('columns.type'); + case 'amount': + return t('columns.amount'); + case 'remainingAmount': + return t('columns.remainingAmount'); + case 'description': + return t('columns.description'); + case 'paymentId': + return t('columns.paymentId'); + case 'expirationDate': + return t('columns.expirationDate'); + case 'expirationDateProcessedAt': + return t('columns.expirationDateProcessedAt'); + case 'createdAt': + return t('columns.createdAt'); + case 'updatedAt': + return t('columns.updatedAt'); + default: + return columnId; + } + }; return ( - {t( - columnIdToTranslationKey[ - column.id as keyof typeof columnIdToTranslationKey - ] || 'columns.columns' - )} + {getColumnDisplayName(column.id)} ); })} @@ -483,7 +509,7 @@ export function CreditTransactionsTable({ colSpan={columns.length} className="h-24 text-center" > - {loading ? t('loading') : t('noResults')} + {loading ? tTable('loading') : tTable('noResults')} )} @@ -495,14 +521,14 @@ export function CreditTransactionsTable({
{total > 0 && ( - {t('totalRecords', { count: total })} + {tTable('totalRecords', { count: total })} )}
- {t('page')} {pageIndex + 1} {' / '} + {tTable('page')} {pageIndex + 1} {' / '} {Math.max(1, Math.ceil(total / pageSize))}
@@ -476,7 +479,7 @@ export function UsersTable({ onClick={() => onPageChange(0)} disabled={pageIndex === 0} > - {t('firstPage')} + {tTable('firstPage')}
diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index e8ec418..e56f1b7 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -125,13 +125,28 @@ export function CreditTransactionsTable({ }: CreditTransactionsTableProps) { const t = useTranslations('Dashboard.settings.credits.transactions'); const tTable = useTranslations('Common.table'); - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true } + ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); // 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 and color const getTransactionTypeIcon = (type: string) => { switch (type) { @@ -425,30 +440,6 @@ export function CreditTransactionsTable({ .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { - const getColumnDisplayName = (columnId: string) => { - switch (columnId) { - case 'type': - return t('columns.type'); - case 'amount': - return t('columns.amount'); - case 'remainingAmount': - return t('columns.remainingAmount'); - case 'description': - return t('columns.description'); - case 'paymentId': - return t('columns.paymentId'); - case 'expirationDate': - return t('columns.expirationDate'); - case 'expirationDateProcessedAt': - return t('columns.expirationDateProcessedAt'); - case 'createdAt': - return t('columns.createdAt'); - case 'updatedAt': - return t('columns.updatedAt'); - default: - return columnId; - } - }; return ( - {getColumnDisplayName(column.id)} + {t( + columnIdToTranslationKey[ + column.id as keyof typeof columnIdToTranslationKey + ] || 'columns.columns' + )} ); })} From 0af0aa3b09c5b6d3dc0024de675d9b5368ef4a68 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 6 Jul 2025 23:25:38 +0800 Subject: [PATCH 26/87] refactor: reorganize credit transaction types and update imports - Moved CREDIT_TRANSACTION_TYPE from constants to a new types.ts file for better modularity. - Updated import paths in credit-related components to reflect the new structure. - Removed the old CREDIT_TRANSACTION_TYPE definition from constants.ts to streamline the codebase. --- .../settings/credits/credit-transactions-table.tsx | 2 +- src/credits/credits.ts | 2 +- src/credits/types.ts | 10 ++++++++++ src/lib/constants.ts | 9 +-------- src/payment/provider/stripe.ts | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 src/credits/types.ts diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index e56f1b7..f9da1ad 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -54,7 +54,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '../../ui/badge'; import { Label } from '../../ui/label'; -import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; // Define the credit transaction interface interface CreditTransaction { diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 9506e7f..d5ca9f4 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -5,10 +5,10 @@ import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, - CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, REGISTER_GIFT_CREDITS, } from '../lib/constants'; +import { CREDIT_TRANSACTION_TYPE } from './types'; /** * Get user's current credit balance diff --git a/src/credits/types.ts b/src/credits/types.ts new file mode 100644 index 0000000..ce266b0 --- /dev/null +++ b/src/credits/types.ts @@ -0,0 +1,10 @@ +/** + * Credit transaction type enum + */ +export enum CREDIT_TRANSACTION_TYPE { + MONTHLY_REFRESH = 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT = 'REGISTER_GIFT', // credits earned by register gift + PURCHASE = 'PURCHASE', // credits earned by purchase + USAGE = 'USAGE', // credits spent by usage + EXPIRE = 'EXPIRE', // credits expired +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ba50ebb..39fd5d1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -42,11 +42,4 @@ export const REGISTER_GIFT_CREDITS = 100; // default credit expiration days export const CREDIT_EXPIRE_DAYS = 30; -// credit transaction type -export const CREDIT_TRANSACTION_TYPE = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh - REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift - PURCHASE: 'PURCHASE', // credits earned by purchase - USAGE: 'USAGE', // credits spent by usage - EXPIRE: 'EXPIRE', // credits expired -}; + diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 0e8b6bd..04269ab 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -8,7 +8,7 @@ import { } from '@/lib/price-plan'; import { sendNotification } from '@/notification/notification'; import { addCredits } from '@/credits/credits'; -import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { desc, eq } from 'drizzle-orm'; import { Stripe } from 'stripe'; import { From f7f7be2ef0320a44f21269d5ec166f0e58f7717e Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 7 Jul 2025 00:04:45 +0800 Subject: [PATCH 27/87] feat: add credits config in website config - Added a new credits management system with configurable credit packages in website.tsx. - Replaced hardcoded credit package definitions with a dynamic retrieval system using getCreditPackages and getCreditPackageById functions. - Updated CreditPackages and StripePaymentForm components to utilize the new credit package structure. - Removed obsolete CREDIT_PACKAGES constant from constants.ts to streamline the codebase. - Enhanced type definitions for credit packages in types.ts for better clarity and maintainability. - Updated README.md to reflect changes in credit packages configuration. --- biome.json | 2 + src/actions/credits.action.ts | 6 +- .../settings/credits/credit-packages.tsx | 57 +++++++++++++------ .../settings/credits/stripe-payment-form.tsx | 40 ++++++------- src/config/website.tsx | 33 +++++++++++ src/credits/README.md | 32 +++++++---- src/credits/index.ts | 18 ++++++ src/credits/types.ts | 22 +++++-- src/lib/constants.ts | 34 ----------- src/types/index.d.ts | 10 ++++ 10 files changed, 163 insertions(+), 91 deletions(-) create mode 100644 src/credits/index.ts diff --git a/biome.json b/biome.json index e0d7566..3a8be83 100644 --- a/biome.json +++ b/biome.json @@ -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" ] diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 3f2e41b..76b3edc 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,6 +1,6 @@ 'use server'; -import { CREDIT_PACKAGES } from '@/lib/constants'; +import { getCreditPackageById } from '@/credits'; import { addMonthlyFreeCredits, addRegisterGiftCredits, @@ -77,7 +77,7 @@ export const createCreditPaymentIntent = actionClient const { packageId } = parsedInput; // Find the credit package - const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { return { success: false, error: 'Invalid credit package' }; } @@ -127,7 +127,7 @@ export const confirmCreditPayment = actionClient const { packageId, paymentIntentId } = parsedInput; // Find the credit package - const creditPackage = CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { return { success: false, error: 'Invalid credit package' }; } diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 55de6bf..94b4eb4 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -1,11 +1,25 @@ 'use client'; -import { createCreditPaymentIntent, getCreditsAction } from '@/actions/credits.action'; +import { + createCreditPaymentIntent, + getCreditsAction, +} from '@/actions/credits.action'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { CREDIT_PACKAGES } from '@/lib/constants'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { getCreditPackageById, getCreditPackages } from '@/credits'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { useTransactionStore } from '@/stores/transaction-store'; @@ -21,6 +35,7 @@ export function CreditPackages() { const [loadingPackage, setLoadingPackage] = useState(null); const [credits, setCredits] = useState(null); const { refreshTrigger } = useTransactionStore(); + const [paymentDialog, setPaymentDialog] = useState<{ isOpen: boolean; packageId: string | null; @@ -67,8 +82,12 @@ export function CreditPackages() { clientSecret: result.data.clientSecret, }); } else { - const errorMessage = result?.data?.error || t('failedToCreatePaymentIntent'); - console.error('CreditPackages, failed to create payment intent:', errorMessage); + const errorMessage = + result?.data?.error || t('failedToCreatePaymentIntent'); + console.error( + 'CreditPackages, failed to create payment intent:', + errorMessage + ); toast.error(errorMessage); } } catch (error) { @@ -98,15 +117,13 @@ export function CreditPackages() { }); }; - const getPackageInfo = (packageId: string) => { - return CREDIT_PACKAGES.find((pkg) => pkg.id === packageId); - }; - return (
- {t('balance')} + + {t('balance')} +
@@ -133,12 +150,20 @@ export function CreditPackages() {
- {CREDIT_PACKAGES.map((pkg) => ( - + {getCreditPackages().map((pkg) => ( + {pkg.popular && (
- + {t('popular')}
@@ -203,7 +228,7 @@ export function CreditPackages() { diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx index 8f1f8b9..965b197 100644 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -3,6 +3,7 @@ import { confirmCreditPayment } from '@/actions/credits.action'; import { Button } from '@/components/ui/button'; import { Card, CardHeader, CardTitle } from '@/components/ui/card'; +import type { CreditPackage } from '@/credits/types'; import { formatPrice } from '@/lib/formatter'; import { useTransactionStore } from '@/stores/transaction-store'; import { @@ -13,19 +14,15 @@ import { } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useTranslations } from 'next-intl'; +import { useTheme } from 'next-themes'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; interface StripePaymentFormProps { clientSecret: string; packageId: string; - packageInfo: { - credits: number; - price: number; - description: string; - }; + packageInfo: CreditPackage; onPaymentSuccess: () => void; onPaymentCancel: () => void; } @@ -47,13 +44,16 @@ export function StripePaymentForm(props: StripePaymentFormProps) { }, []); const { resolvedTheme: theme } = useTheme(); - const options = useMemo(() => ({ - clientSecret: props.clientSecret, - appearance: { - theme: (theme === "dark" ? "night" : "stripe") as "night" | "stripe", - }, - loader: 'auto' as const, - }), [props.clientSecret, theme]); + const options = useMemo( + () => ({ + clientSecret: props.clientSecret, + appearance: { + theme: (theme === 'dark' ? 'night' : 'stripe') as 'night' | 'stripe', + }, + loader: 'auto' as const, + }), + [props.clientSecret, theme] + ); return ( @@ -65,11 +65,7 @@ export function StripePaymentForm(props: StripePaymentFormProps) { interface PaymentFormProps { clientSecret: string; packageId: string; - packageInfo: { - credits: number; - price: number; - description: string; - }; + packageInfo: CreditPackage; onPaymentSuccess: () => void; onPaymentCancel: () => void; } @@ -101,12 +97,12 @@ function PaymentForm({ // Confirm the payment using PaymentElement const { error } = await stripe.confirmPayment({ elements, - redirect: "if_required", + redirect: 'if_required', }); if (error) { console.error('PaymentForm, payment error:', error); - throw new Error(error.message || "Payment failed"); + throw new Error(error.message || 'Payment failed'); } else { // The payment was successful const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); @@ -126,11 +122,11 @@ function PaymentForm({ // toast.success(`${packageInfo.credits} credits have been added to your account.`); } else { console.error('PaymentForm, payment error:', result?.data?.error); - throw new Error( result?.data?.error || 'Failed to confirm payment' ); + throw new Error(result?.data?.error || 'Failed to confirm payment'); } } else { console.error('PaymentForm, no payment intent found'); - throw new Error("No payment intent found"); + throw new Error('No payment intent found'); } } } catch (error) { diff --git a/src/config/website.tsx b/src/config/website.tsx index 30f8235..28c9c22 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -129,4 +129,37 @@ export const websiteConfig: WebsiteConfig = { }, }, }, + credits: { + enableCredits: true, + packages: { + basic: { + id: 'basic', + credits: 100, + price: 990, + popular: false, + description: 'Perfect for getting started', + }, + standard: { + id: 'standard', + credits: 200, + price: 1490, + popular: true, + description: 'Most popular package', + }, + premium: { + id: 'premium', + credits: 500, + price: 3990, + popular: false, + description: 'Best value for heavy users', + }, + enterprise: { + id: 'enterprise', + credits: 1000, + price: 6990, + popular: false, + description: 'Tailored for enterprises', + }, + }, + }, }; diff --git a/src/credits/README.md b/src/credits/README.md index aa96bb5..aa9f2db 100644 --- a/src/credits/README.md +++ b/src/credits/README.md @@ -41,7 +41,7 @@ This document describes the credit management system implementation for the mksa - `src/payment/types.ts` - Payment types (updated) ### Configuration -- `src/lib/constants.ts` - Credit packages configuration +- `src/config/website.tsx` - Credit packages configuration - `env.example` - Environment variables template ### Pages @@ -98,23 +98,33 @@ await addCredits({ type: 'PURCHASE', description: 'Credit purchase' }); + +// Access credit packages from config +import { websiteConfig } from '@/config/website'; +const creditPackages = Object.values(websiteConfig.credits.packages); ``` ## Credit Packages Configuration -Edit `src/lib/constants.ts` to modify available credit packages: +Edit `src/config/website.tsx` to modify available credit packages: ```typescript -export const CREDIT_PACKAGES = [ - { - id: 'basic', - credits: 100, - price: 9.99, - popular: false, - description: 'Perfect for getting started', +export const websiteConfig: WebsiteConfig = { + // ... other config + credits: { + enableCredits: true, + packages: { + basic: { + id: 'basic', + credits: 100, + price: 990, // Price in cents + popular: false, + description: 'Perfect for getting started', + }, + // ... more packages + }, }, - // ... more packages -]; +}; ``` ## Webhook Events diff --git a/src/credits/index.ts b/src/credits/index.ts new file mode 100644 index 0000000..541a9a5 --- /dev/null +++ b/src/credits/index.ts @@ -0,0 +1,18 @@ +import { websiteConfig } from '@/config/website'; + +/** + * Get credit packages + * @returns Credit packages + */ +export function getCreditPackages() { + return Object.values(websiteConfig.credits.packages); +} + +/** + * Get credit package by id + * @param id - Credit package id + * @returns Credit package + */ +export function getCreditPackageById(id: string) { + return getCreditPackages().find((pkg) => pkg.id === id); +} diff --git a/src/credits/types.ts b/src/credits/types.ts index ce266b0..083326a 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -2,9 +2,21 @@ * Credit transaction type enum */ export enum CREDIT_TRANSACTION_TYPE { - MONTHLY_REFRESH = 'MONTHLY_REFRESH', // credits earned by monthly refresh - REGISTER_GIFT = 'REGISTER_GIFT', // credits earned by register gift - PURCHASE = 'PURCHASE', // credits earned by purchase - USAGE = 'USAGE', // credits spent by usage - EXPIRE = 'EXPIRE', // credits expired + MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh + REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift + PURCHASE = 'PURCHASE', // Credits earned by purchase + USAGE = 'USAGE', // Credits spent by usage + EXPIRE = 'EXPIRE', // Credits expired +} + +/** + * Credit package + */ +export interface CreditPackage { + id: string; // Unique identifier for the package + credits: number; // Number of credits in the package + price: number; // Price of the package in cents + popular: boolean; // Whether the package is popular + name?: string; // Display name of the package + description?: string; // Description of the package } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 39fd5d1..8acb5ca 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,38 +1,6 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; -// credit package definition (price in cents) -export const CREDIT_PACKAGES = [ - { - id: 'basic', - credits: 100, - price: 990, // 9.90 USD in cents - popular: false, - description: 'Perfect for getting started', - }, - { - id: 'standard', - credits: 200, - price: 1490, // 14.90 USD in cents - popular: true, - description: 'Most popular package', - }, - { - id: 'premium', - credits: 500, - price: 3990, // 39.90 USD in cents - popular: false, - description: 'Best value for heavy users', - }, - { - id: 'enterprise', - credits: 1000, - price: 6990, // 69.90 USD in cents - popular: false, - description: 'Tailored for enterprises', - }, -] as const; - // free monthly credits (10% of the smallest package) export const FREE_MONTHLY_CREDITS = 50; @@ -41,5 +9,3 @@ export const REGISTER_GIFT_CREDITS = 100; // default credit expiration days export const CREDIT_EXPIRE_DAYS = 30; - - diff --git a/src/types/index.d.ts b/src/types/index.d.ts index f1dd73a..16706c3 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { PricePlan } from '@/payment/types'; +import type { CreditPackage } from '@/credits/types'; /** * website config, without translations @@ -17,6 +18,7 @@ export type WebsiteConfig = { storage: StorageConfig; payment: PaymentConfig; price: PriceConfig; + credits: CreditsConfig; }; /** @@ -148,6 +150,14 @@ export interface PriceConfig { plans: Record; // Plans indexed by ID } +/** + * Credits configuration + */ +export interface CreditsConfig { + enableCredits: boolean; // Whether to enable credits + packages: Record; // Packages indexed by ID +} + /** * menu item, used for navbar links, sidebar links, footer links */ From e430a0c31912a3dec24d87113a01721cd3148c4e Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 7 Jul 2025 00:47:43 +0800 Subject: [PATCH 28/87] feat: implement credit packages management with translations and server integration - Added new credit packages structure in English and Chinese JSON files for better localization. - Introduced server-side functions to retrieve credit packages and package details. - Updated client-side components to utilize new credit package retrieval methods. - Refactored existing code to enhance modularity and maintainability by separating client and server logic. - Removed obsolete credit package retrieval functions to streamline the codebase. --- messages/en.json | 22 ++++++- messages/zh.json | 18 ++++++ src/actions/credits.action.ts | 6 +- .../settings/credits/credit-packages.tsx | 32 ++++++---- .../settings/credits/stripe-payment-form.tsx | 44 +++++++------- src/config/credits-config.tsx | 58 +++++++++++++++++++ src/credits/client.ts | 21 +++++++ src/credits/index.ts | 18 ------ src/credits/server.ts | 23 ++++++++ 9 files changed, 186 insertions(+), 56 deletions(-) create mode 100644 src/config/credits-config.tsx create mode 100644 src/credits/client.ts delete mode 100644 src/credits/index.ts create mode 100644 src/credits/server.ts diff --git a/messages/en.json b/messages/en.json index 7ca1e9c..d77b4f9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -111,6 +111,24 @@ } } }, + "CreditPackages": { + "basic": { + "name": "Basic", + "description": "Basic features for personal use" + }, + "standard": { + "name": "Standard", + "description": "Standard features for personal use" + }, + "premium": { + "name": "Premium", + "description": "Premium features for personal use" + }, + "enterprise": { + "name": "Enterprise", + "description": "Enterprise features for personal use" + } + }, "NotFoundPage": { "title": "404", "message": "Sorry, the page you are looking for does not exist.", @@ -498,8 +516,8 @@ "error": "Failed to unban user" }, "close": "Close" - } - }, + } + }, "settings": { "title": "Settings", "profile": { diff --git a/messages/zh.json b/messages/zh.json index 1eb9179..de58403 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -112,6 +112,24 @@ } } }, + "CreditPackages": { + "basic": { + "name": "基础版", + "description": "基础版功能介绍放这里" + }, + "standard": { + "name": "标准版", + "description": "标准版功能介绍放这里" + }, + "premium": { + "name": "高级版", + "description": "高级版功能介绍放这里" + }, + "enterprise": { + "name": "企业版", + "description": "企业版功能介绍放这里" + } + }, "NotFoundPage": { "title": "404", "message": "抱歉,您正在寻找的页面不存在", diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 76b3edc..9a77a48 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,12 +1,12 @@ 'use server'; -import { getCreditPackageById } from '@/credits'; import { addMonthlyFreeCredits, addRegisterGiftCredits, consumeCredits, getUserCredits, } from '@/credits/credits'; +import { getCreditPackageByIdInServer } from '@/credits/server'; import { getSession } from '@/lib/server'; import { confirmPaymentIntent, createPaymentIntent } from '@/payment'; import { createSafeActionClient } from 'next-safe-action'; @@ -77,7 +77,7 @@ export const createCreditPaymentIntent = actionClient const { packageId } = parsedInput; // Find the credit package - const creditPackage = getCreditPackageById(packageId); + const creditPackage = getCreditPackageByIdInServer(packageId); if (!creditPackage) { return { success: false, error: 'Invalid credit package' }; } @@ -127,7 +127,7 @@ export const confirmCreditPayment = actionClient const { packageId, paymentIntentId } = parsedInput; // Find the credit package - const creditPackage = getCreditPackageById(packageId); + const creditPackage = getCreditPackageByIdInServer(packageId); if (!creditPackage) { return { success: false, error: 'Invalid credit package' }; } diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 94b4eb4..afd1a61 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -19,7 +19,11 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { getCreditPackageById, getCreditPackages } from '@/credits'; +import { + getCreditPackageByIdInClient, + getCreditPackagesInClient, +} from '@/credits/client'; +import type { CreditPackage } from '@/credits/types'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { useTransactionStore } from '@/stores/transaction-store'; @@ -117,6 +121,10 @@ export function CreditPackages() { }); }; + const getPackageInfo = (packageId: string): CreditPackage | undefined => { + return getCreditPackageByIdInClient(packageId); + }; + return (
@@ -150,7 +158,7 @@ export function CreditPackages() {
- {getCreditPackages().map((pkg) => ( + {getCreditPackagesInClient().map((pkg) => ( {t('completePurchase')} - {paymentDialog.clientSecret && paymentDialog.packageId && ( - - )} + {paymentDialog.clientSecret && + paymentDialog.packageId && + getPackageInfo(paymentDialog.packageId) && ( + + )}
diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx index 965b197..a8bd91e 100644 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ b/src/components/settings/credits/stripe-payment-form.tsx @@ -77,9 +77,9 @@ function PaymentForm({ onPaymentSuccess, onPaymentCancel, }: PaymentFormProps) { + const t = useTranslations('Dashboard.settings.credits.packages'); const stripe = useStripe(); const elements = useElements(); - const t = useTranslations('Dashboard.settings.credits.packages'); const [processing, setProcessing] = useState(false); const { triggerRefresh } = useTransactionStore(); @@ -103,31 +103,31 @@ function PaymentForm({ if (error) { console.error('PaymentForm, payment error:', error); throw new Error(error.message || 'Payment failed'); - } else { - // The payment was successful - const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); - if (paymentIntent.paymentIntent) { - const result = await confirmCreditPayment({ - packageId, - paymentIntentId: paymentIntent.paymentIntent.id, - }); + } - if (result?.data?.success) { - console.log('PaymentForm, payment success'); - // Trigger refresh for transaction-dependent UI components - triggerRefresh(); + // The payment was successful + const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); + if (paymentIntent.paymentIntent) { + const result = await confirmCreditPayment({ + packageId, + paymentIntentId: paymentIntent.paymentIntent.id, + }); - // Show success toast - onPaymentSuccess(); - // toast.success(`${packageInfo.credits} credits have been added to your account.`); - } else { - console.error('PaymentForm, payment error:', result?.data?.error); - throw new Error(result?.data?.error || 'Failed to confirm payment'); - } + if (result?.data?.success) { + console.log('PaymentForm, payment success'); + // Trigger refresh for transaction-dependent UI components + triggerRefresh(); + + // Show success toast + onPaymentSuccess(); + // toast.success(`${packageInfo.credits} credits have been added to your account.`); } else { - console.error('PaymentForm, no payment intent found'); - throw new Error('No payment intent found'); + console.error('PaymentForm, payment error:', result?.data?.error); + throw new Error(result?.data?.error || 'Failed to confirm payment'); } + } else { + console.error('PaymentForm, no payment intent found'); + throw new Error('No payment intent found'); } } catch (error) { console.error('PaymentForm, payment error:', error); diff --git a/src/config/credits-config.tsx b/src/config/credits-config.tsx new file mode 100644 index 0000000..da9d2df --- /dev/null +++ b/src/config/credits-config.tsx @@ -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 { + const t = useTranslations('CreditPackages'); + const creditConfig = websiteConfig.credits; + const packages: Record = {}; + + // 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; +} diff --git a/src/credits/client.ts b/src/credits/client.ts new file mode 100644 index 0000000..d828662 --- /dev/null +++ b/src/credits/client.ts @@ -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); +} diff --git a/src/credits/index.ts b/src/credits/index.ts deleted file mode 100644 index 541a9a5..0000000 --- a/src/credits/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { websiteConfig } from '@/config/website'; - -/** - * Get credit packages - * @returns Credit packages - */ -export function getCreditPackages() { - return Object.values(websiteConfig.credits.packages); -} - -/** - * Get credit package by id - * @param id - Credit package id - * @returns Credit package - */ -export function getCreditPackageById(id: string) { - return getCreditPackages().find((pkg) => pkg.id === id); -} diff --git a/src/credits/server.ts b/src/credits/server.ts new file mode 100644 index 0000000..fe0a5d6 --- /dev/null +++ b/src/credits/server.ts @@ -0,0 +1,23 @@ +import { websiteConfig } from '@/config/website'; +import type { CreditPackage } from './types'; + +/** + * Get all credit packages, used in server components + * @returns Credit packages + */ +export function getAllCreditPackagesInServer(): CreditPackage[] { + return Object.values(websiteConfig.credits.packages); +} + +/** + * Get credit package by id, used in server components + * @param id - Credit package id + * @returns Credit package + */ +export function getCreditPackageByIdInServer( + id: string +): CreditPackage | undefined { + return websiteConfig.credits.packages[ + id as keyof typeof websiteConfig.credits.packages + ]; +} From 73ce18f56494e8030cf16377b725fb3e9230c38d Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 7 Jul 2025 01:10:06 +0800 Subject: [PATCH 29/87] refactor: update credit package descriptions and improve component structure - Revised credit package descriptions in the English JSON file for clarity and consistency. - Refactored CreditPackages component to utilize a more efficient method for retrieving credit packages. - Removed obsolete functions and streamlined the code for better maintainability and performance. --- messages/en.json | 8 ++++---- .../settings/credits/credit-packages.tsx | 19 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/messages/en.json b/messages/en.json index d77b4f9..fbb86e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -114,19 +114,19 @@ "CreditPackages": { "basic": { "name": "Basic", - "description": "Basic features for personal use" + "description": "Basic credits package description" }, "standard": { "name": "Standard", - "description": "Standard features for personal use" + "description": "Standard credits package description" }, "premium": { "name": "Premium", - "description": "Premium features for personal use" + "description": "Premium credits package description" }, "enterprise": { "name": "Enterprise", - "description": "Enterprise features for personal use" + "description": "Enterprise credits package description" } }, "NotFoundPage": { diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index afd1a61..8b48748 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -19,10 +19,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - getCreditPackageByIdInClient, - getCreditPackagesInClient, -} from '@/credits/client'; +import { getCreditPackages } from '@/config/credits-config'; import type { CreditPackage } from '@/credits/types'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; @@ -33,6 +30,10 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { StripePaymentForm } from './stripe-payment-form'; +/** + * Credit packages component + * @returns Credit packages component + */ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); const [loadingCredits, setLoadingCredits] = useState(true); @@ -50,6 +51,8 @@ export function CreditPackages() { clientSecret: null, }); + const creditPackages = Object.values(getCreditPackages()); + const fetchCredits = async () => { try { setLoadingCredits(true); @@ -122,7 +125,7 @@ export function CreditPackages() { }; const getPackageInfo = (packageId: string): CreditPackage | undefined => { - return getCreditPackageByIdInClient(packageId); + return creditPackages.find((pkg) => pkg.id === packageId); }; return ( @@ -158,7 +161,7 @@ export function CreditPackages() {
- {getCreditPackagesInClient().map((pkg) => ( + {creditPackages.map((pkg) => ( )} - {/* - {pkg.id} - */} - {/* Price and Credits - Left/Right Layout */}
From 2e8f70dc76ab6ff14e98b05306b47bd29e160f32 Mon Sep 17 00:00:00 2001 From: javayhu Date: Tue, 8 Jul 2025 00:48:17 +0800 Subject: [PATCH 30/87] feat: enhance credits management with new configurations - Added credit expiration days, register gift credits, and free monthly credits options in website configuration. - Updated credits handling functions to utilize the new configuration settings for improved flexibility and maintainability. - Removed obsolete constants related to credits from constants.ts to streamline the codebase. - Enhanced type definitions for credits configuration in index.d.ts for better clarity. --- src/config/website.tsx | 13 +++++++--- src/credits/credits.ts | 57 ++++++++++++++++++++++++++---------------- src/lib/constants.ts | 9 ------- src/types/index.d.ts | 9 +++++++ 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/config/website.tsx b/src/config/website.tsx index 28c9c22..83e781a 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -131,34 +131,39 @@ export const websiteConfig: WebsiteConfig = { }, credits: { enableCredits: true, + creditExpireDays: 30, + registerGiftCredits: { + enable: true, + credits: 100, + }, + freeMonthlyCredits: { + enable: true, + credits: 50, + }, packages: { basic: { id: 'basic', credits: 100, price: 990, popular: false, - description: 'Perfect for getting started', }, standard: { id: 'standard', credits: 200, price: 1490, popular: true, - description: 'Most popular package', }, premium: { id: 'premium', credits: 500, price: 3990, popular: false, - description: 'Best value for heavy users', }, enterprise: { id: 'enterprise', credits: 1000, price: 6990, popular: false, - description: 'Tailored for enterprises', }, }, }, diff --git a/src/credits/credits.ts b/src/credits/credits.ts index d5ca9f4..90592c5 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -1,13 +1,9 @@ import { randomUUID } from 'crypto'; +import { websiteConfig } from '@/config/website'; import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; -import { - CREDIT_EXPIRE_DAYS, - FREE_MONTHLY_CREDITS, - REGISTER_GIFT_CREDITS, -} from '../lib/constants'; import { CREDIT_TRANSACTION_TYPE } from './types'; /** @@ -45,7 +41,7 @@ export async function updateUserLastRefreshAt(userId: string, date: Date) { * Write a credit transaction record * @param params - Credit transaction parameters */ -export async function logCreditTransaction({ +export async function saveCreditTransaction({ userId, type, amount, @@ -61,11 +57,16 @@ export async function logCreditTransaction({ expirationDate?: Date; }) { if (!userId || !type || !description) { - console.error('Invalid params', userId, type, description); + console.error( + 'saveCreditTransaction, invalid params', + userId, + type, + description + ); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount === 0) { - console.error('Invalid amount', userId, amount); + console.error('saveCreditTransaction, invalid amount', userId, amount); throw new Error('Invalid amount'); } const db = await getDb(); @@ -95,7 +96,7 @@ export async function addCredits({ type, description, paymentId, - expireDays = CREDIT_EXPIRE_DAYS, + expireDays = websiteConfig.credits.creditExpireDays.days, }: { userId: string; amount: number; @@ -105,15 +106,15 @@ export async function addCredits({ expireDays?: number; }) { if (!userId || !type || !description) { - console.error('Invalid params', userId, type, description); + console.error('addCredits, invalid params', userId, type, description); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - console.error('Invalid amount', userId, amount); + console.error('addCredits, invalid amount', userId, amount); throw new Error('Invalid amount'); } if (!Number.isFinite(expireDays) || expireDays <= 0) { - console.error('Invalid expire days', userId, expireDays); + console.error('addCredits, invalid expire days', userId, expireDays); throw new Error('Invalid expire days'); } // Process expired credits first @@ -150,7 +151,7 @@ export async function addCredits({ }); } // Write credit transaction record - await logCreditTransaction({ + await saveCreditTransaction({ userId, type, amount, @@ -189,18 +190,20 @@ export async function consumeCredits({ description: string; }) { if (!userId || !description) { - console.error('Invalid params', userId, description); + console.error('consumeCredits, invalid params', userId, description); throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - console.error('Invalid amount', userId, amount); + 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( `Insufficient credits for user ${userId}, required: ${amount}` ); + console.error( + `Insufficient credits for user ${userId}, required: ${amount}` + ); throw new Error('Insufficient credits'); } // FIFO consumption: consume from the earliest unexpired credits first @@ -251,7 +254,7 @@ export async function consumeCredits({ .set({ currentCredits: newBalance, updatedAt: new Date() }) .where(eq(userCredit.userId, userId)); // Write usage record - await logCreditTransaction({ + await saveCreditTransaction({ userId, type: CREDIT_TRANSACTION_TYPE.USAGE, amount: -amount, @@ -319,7 +322,7 @@ export async function processExpiredCredits(userId: string) { .set({ currentCredits: newBalance, updatedAt: now }) .where(eq(userCredit.userId, userId)); // Write expire record - await logCreditTransaction({ + await saveCreditTransaction({ userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, @@ -333,6 +336,10 @@ export async function processExpiredCredits(userId: string) { * @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 @@ -347,11 +354,12 @@ export async function addRegisterGiftCredits(userId: string) { .limit(1); // add register gift credits if user has not received them yet if (record.length === 0) { + const credits = websiteConfig.credits.registerGiftCredits.credits; await addCredits({ userId, - amount: REGISTER_GIFT_CREDITS, + amount: credits, type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, - description: `Register gift credits: ${REGISTER_GIFT_CREDITS}`, + description: `Register gift credits: ${credits}`, }); } } @@ -361,6 +369,10 @@ export async function addRegisterGiftCredits(userId: string) { * @param userId - User ID */ export async function addMonthlyFreeCredits(userId: string) { + if (!websiteConfig.credits.freeMonthlyCredits.enable) { + console.log('addMonthlyFreeCredits, disabled'); + return; + } // Check last refresh time const db = await getDb(); const record = await db @@ -381,11 +393,12 @@ export async function addMonthlyFreeCredits(userId: string) { } // add credits if it's a new month if (canAdd) { + const credits = websiteConfig.credits.freeMonthlyCredits.credits; await addCredits({ userId, - amount: FREE_MONTHLY_CREDITS, + amount: credits, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, + description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8acb5ca..39459fd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,11 +1,2 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; - -// free monthly credits (10% of the smallest package) -export const FREE_MONTHLY_CREDITS = 50; - -// register gift credits (for new user registration) -export const REGISTER_GIFT_CREDITS = 100; - -// default credit expiration days -export const CREDIT_EXPIRE_DAYS = 30; diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 16706c3..8f88b35 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -155,6 +155,15 @@ export interface PriceConfig { */ export interface CreditsConfig { enableCredits: boolean; // Whether to enable credits + creditExpireDays: number; // The number of days to expire the credits + registerGiftCredits: { + enable: boolean; // Whether to enable register gift credits + credits: number; // The number of credits to give to the user + }; + freeMonthlyCredits: { + enable: boolean; // Whether to enable free monthly credits + credits: number; // The number of credits to give to the user + }; packages: Record; // Packages indexed by ID } From adb9b8057203323ae18027e9204d23884489552f Mon Sep 17 00:00:00 2001 From: javayhu Date: Tue, 8 Jul 2025 00:51:04 +0800 Subject: [PATCH 31/87] fix: update default expireDays assignment in addCredits function --- src/credits/credits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 90592c5..c167f5a 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -96,7 +96,7 @@ export async function addCredits({ type, description, paymentId, - expireDays = websiteConfig.credits.creditExpireDays.days, + expireDays = websiteConfig.credits.creditExpireDays, }: { userId: string; amount: number; From a7738f0cbf7a97f9dd220cf0e7be04d05501ee3f Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 9 Jul 2025 00:22:07 +0800 Subject: [PATCH 32/87] feat: implement credit checkout session and enhance credit package management - Added new credit checkout session functionality to facilitate credit purchases. - Introduced credit package configurations in env.example for better management. - Updated English and Chinese JSON files with new messages for checkout processes. - Refactored existing components to utilize the new credit checkout session and streamline the credit purchasing workflow. - Removed obsolete payment intent handling to simplify the codebase. --- env.example | 8 + messages/en.json | 5 +- messages/zh.json | 5 +- src/actions/create-checkout-session.ts | 4 +- src/actions/create-credit-checkout-session.ts | 124 ++++++++ src/actions/credits.action.ts | 92 ------ .../credits/credit-checkout-button.tsx | 149 +++++++++ .../settings/credits/credit-packages.tsx | 164 +++------- .../settings/credits/stripe-payment-form.tsx | 196 ------------ src/config/website.tsx | 36 ++- src/credits/types.ts | 9 +- src/payment/index.ts | 40 +-- src/payment/provider/stripe.ts | 295 ++++++++++++------ src/payment/types.ts | 52 ++- 14 files changed, 611 insertions(+), 568 deletions(-) create mode 100644 src/actions/create-credit-checkout-session.ts create mode 100644 src/components/settings/credits/credit-checkout-button.tsx delete mode 100644 src/components/settings/credits/stripe-payment-form.tsx diff --git a/env.example b/env.example index 7ecd61a..572508a 100644 --- a/env.example +++ b/env.example @@ -72,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 diff --git a/messages/en.json b/messages/en.json index fbb86e9..5a6dc1d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -602,7 +602,10 @@ "creditsAdded": "Credits have been added to your account", "cancel": "Cancel", "purchaseFailed": "Purchase credits failed", - "pay": "Pay" + "checkoutFailed": "Failed to create checkout session", + "loading": "Loading...", + "pay": "Pay", + "notConfigured": "Not configured" }, "tabs": { "balance": "Balance", diff --git a/messages/zh.json b/messages/zh.json index de58403..89c6de3 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -603,7 +603,10 @@ "creditsAdded": "积分已添加到您的账户", "cancel": "取消", "purchaseFailed": "购买积分失败", - "pay": "支付" + "checkoutFailed": "创建支付会话失败", + "loading": "加载中...", + "pay": "支付", + "notConfigured": "未配置" }, "tabs": { "balance": "积分余额", diff --git a/src/actions/create-checkout-session.ts b/src/actions/create-checkout-session.ts index f5b6afb..4c5fd62 100644 --- a/src/actions/create-checkout-session.ts +++ b/src/actions/create-checkout-session.ts @@ -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); diff --git a/src/actions/create-credit-checkout-session.ts b/src/actions/create-credit-checkout-session.ts new file mode 100644 index 0000000..6ad7d11 --- /dev/null +++ b/src/actions/create-credit-checkout-session.ts @@ -0,0 +1,124 @@ +'use server'; + +import { websiteConfig } from '@/config/website'; +import { getCreditPackageByIdInServer } 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 = getCreditPackageByIdInServer(packageId); + if (!creditPackage) { + return { + success: false, + error: 'Credit package not found', + }; + } + + // Add metadata to identify this as a credit purchase + const customMetadata: Record = { + ...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.SettingsCredits}?session_id={CHECKOUT_SESSION_ID}`, + locale + ); + const cancelUrl = getUrlWithLocale(Routes.SettingsCredits, 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', + }; + } + }); diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 9a77a48..6b68cb4 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -6,11 +6,8 @@ import { consumeCredits, getUserCredits, } from '@/credits/credits'; -import { getCreditPackageByIdInServer } from '@/credits/server'; import { getSession } from '@/lib/server'; -import { confirmPaymentIntent, createPaymentIntent } from '@/payment'; import { createSafeActionClient } from 'next-safe-action'; -import { revalidatePath } from 'next/cache'; import { z } from 'zod'; const actionClient = createSafeActionClient(); @@ -62,92 +59,3 @@ export const consumeCreditsAction = actionClient return { success: false, error: (e as Error).message }; } }); - -// Credit purchase payment intent action -const createPaymentIntentSchema = z.object({ - packageId: z.string().min(1), -}); - -export const createCreditPaymentIntent = actionClient - .schema(createPaymentIntentSchema) - .action(async ({ parsedInput }) => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - - const { packageId } = parsedInput; - - // Find the credit package - const creditPackage = getCreditPackageByIdInServer(packageId); - if (!creditPackage) { - return { success: false, error: 'Invalid credit package' }; - } - - const customMetadata: Record = { - packageId, - price: creditPackage.price.toString(), - credits: creditPackage.credits.toString(), - userId: session.user.id, - userName: session.user.name, - }; - - try { - // Create payment intent - const paymentIntent = await createPaymentIntent({ - amount: creditPackage.price, - currency: 'usd', - metadata: { - packageId, - userId: session.user.id, - credits: creditPackage.credits.toString(), - }, - }); - - return { - success: true, - clientSecret: paymentIntent.clientSecret, - }; - } catch (error) { - console.error('Create credit payment intent error:', error); - return { success: false, error: 'Failed to create payment intent' }; - } - }); - -// Confirm credit payment action -const confirmPaymentSchema = z.object({ - packageId: z.string().min(1), - paymentIntentId: z.string().min(1), -}); - -export const confirmCreditPayment = actionClient - .schema(confirmPaymentSchema) - .action(async ({ parsedInput }) => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - - const { packageId, paymentIntentId } = parsedInput; - - // Find the credit package - const creditPackage = getCreditPackageByIdInServer(packageId); - if (!creditPackage) { - return { success: false, error: 'Invalid credit package' }; - } - - try { - // Confirm payment intent - const isSuccessful = await confirmPaymentIntent({ - paymentIntentId, - }); - - if (!isSuccessful) { - return { success: false, error: 'Payment confirmation failed' }; - } - - // Revalidate the credits page to show updated balance - revalidatePath('/settings/credits'); - - return { success: true }; - } catch (error) { - console.error('Confirm credit payment error:', error); - return { success: false, error: 'Failed to confirm payment' }; - } - }); diff --git a/src/components/settings/credits/credit-checkout-button.tsx b/src/components/settings/credits/credit-checkout-button.tsx new file mode 100644 index 0000000..4bc7a42 --- /dev/null +++ b/src/components/settings/credits/credit-checkout-button.tsx @@ -0,0 +1,149 @@ +'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; + 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); + + // Get the credit package to find the priceId + // const creditPackages = getCreditPackages(); + // const creditPackage = creditPackages[packageId]; + + // if (!creditPackage) { + // toast.error('Invalid credit package'); + // return; + // } + + // if (!creditPackage.price.priceId) { + // toast.error(t('notConfigured')); + // return; + // } + + 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 ( + + ); +} diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 8b48748..382bcd5 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -1,11 +1,7 @@ 'use client'; -import { - createCreditPaymentIntent, - getCreditsAction, -} from '@/actions/credits.action'; +import { getCreditsAction } from '@/actions/credits.action'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Card, CardContent, @@ -13,22 +9,18 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { getCreditPackages } from '@/config/credits-config'; -import type { CreditPackage } from '@/credits/types'; +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { useTransactionStore } from '@/stores/transaction-store'; -import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { StripePaymentForm } from './stripe-payment-form'; +import { CreditCheckoutButton } from './credit-checkout-button'; /** * Credit packages component @@ -37,19 +29,11 @@ import { StripePaymentForm } from './stripe-payment-form'; export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); const [loadingCredits, setLoadingCredits] = useState(true); - const [loadingPackage, setLoadingPackage] = useState(null); const [credits, setCredits] = useState(null); const { refreshTrigger } = useTransactionStore(); - - const [paymentDialog, setPaymentDialog] = useState<{ - isOpen: boolean; - packageId: string | null; - clientSecret: string | null; - }>({ - isOpen: false, - packageId: null, - clientSecret: null, - }); + const currentUser = useCurrentUser(); + const searchParams = useSearchParams(); + const router = useLocaleRouter(); const creditPackages = Object.values(getCreditPackages()); @@ -73,61 +57,28 @@ export function CreditPackages() { } }; + // Check for payment success and show success message + useEffect(() => { + const sessionId = searchParams.get('session_id'); + if (sessionId) { + // Show success toast + toast.success(t('creditsAdded')); + + // Refresh credits data to show updated balance + fetchCredits(); + + // Clean up URL parameters + const url = new URL(window.location.href); + url.searchParams.delete('session_id'); + router.replace(url.pathname + url.search); + } + }, [searchParams, router]); + // Initial fetch and listen for transaction updates useEffect(() => { fetchCredits(); }, [refreshTrigger]); - const handlePurchase = async (packageId: string) => { - try { - setLoadingPackage(packageId); - const result = await createCreditPaymentIntent({ packageId }); - if (result?.data?.success && result?.data?.clientSecret) { - setPaymentDialog({ - isOpen: true, - packageId, - clientSecret: result.data.clientSecret, - }); - } else { - const errorMessage = - result?.data?.error || t('failedToCreatePaymentIntent'); - console.error( - 'CreditPackages, failed to create payment intent:', - errorMessage - ); - toast.error(errorMessage); - } - } catch (error) { - console.error('CreditPackages, failed to initiate payment:', error); - toast.error(t('failedToInitiatePayment')); - } finally { - setLoadingPackage(null); - } - }; - - const handlePaymentSuccess = () => { - console.log('CreditPackages, payment success'); - setPaymentDialog({ - isOpen: false, - packageId: null, - clientSecret: null, - }); - toast.success(t('creditsAdded')); - }; - - const handlePaymentCancel = () => { - console.log('CreditPackages, payment cancelled'); - setPaymentDialog({ - isOpen: false, - packageId: null, - clientSecret: null, - }); - }; - - const getPackageInfo = (packageId: string): CreditPackage | undefined => { - return creditPackages.find((pkg) => pkg.id === packageId); - }; - return (
@@ -161,15 +112,15 @@ export function CreditPackages() {
- {creditPackages.map((pkg) => ( + {creditPackages.map((creditPackage) => ( - {pkg.popular && ( + {creditPackage.popular && (
- {pkg.credits.toLocaleString()} + {creditPackage.credits.toLocaleString()}
- {formatPrice(pkg.price, 'USD')} + {formatPrice( + creditPackage.price.amount, + creditPackage.price.currency + )}
- {pkg.description} + {creditPackage.description}
- {/* purchase button */} - + {!creditPackage.price.priceId + ? t('notConfigured') + : t('purchase')} +
))}
- - {/* Payment Dialog */} - - - - {t('completePurchase')} - - - {paymentDialog.clientSecret && - paymentDialog.packageId && - getPackageInfo(paymentDialog.packageId) && ( - - )} - -
); } diff --git a/src/components/settings/credits/stripe-payment-form.tsx b/src/components/settings/credits/stripe-payment-form.tsx deleted file mode 100644 index a8bd91e..0000000 --- a/src/components/settings/credits/stripe-payment-form.tsx +++ /dev/null @@ -1,196 +0,0 @@ -'use client'; - -import { confirmCreditPayment } from '@/actions/credits.action'; -import { Button } from '@/components/ui/button'; -import { Card, CardHeader, CardTitle } from '@/components/ui/card'; -import type { CreditPackage } from '@/credits/types'; -import { formatPrice } from '@/lib/formatter'; -import { useTransactionStore } from '@/stores/transaction-store'; -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; -import { CoinsIcon, Loader2Icon } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { useTheme } from 'next-themes'; -import { useMemo, useState } from 'react'; -import { toast } from 'sonner'; - -interface StripePaymentFormProps { - clientSecret: string; - packageId: string; - packageInfo: CreditPackage; - onPaymentSuccess: () => void; - onPaymentCancel: () => void; -} - -/** - * StripePaymentForm is a component that displays a payment form for a credit package. - * It uses the Stripe Elements API to display a payment form. - * - * @param props - The props for the StripePaymentForm component. - * @returns The StripePaymentForm component. - */ -export function StripePaymentForm(props: StripePaymentFormProps) { - if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { - throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set'); - } - - const stripePromise = useMemo(() => { - return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); - }, []); - - const { resolvedTheme: theme } = useTheme(); - const options = useMemo( - () => ({ - clientSecret: props.clientSecret, - appearance: { - theme: (theme === 'dark' ? 'night' : 'stripe') as 'night' | 'stripe', - }, - loader: 'auto' as const, - }), - [props.clientSecret, theme] - ); - - return ( - - - - ); -} - -interface PaymentFormProps { - clientSecret: string; - packageId: string; - packageInfo: CreditPackage; - onPaymentSuccess: () => void; - onPaymentCancel: () => void; -} - -function PaymentForm({ - clientSecret, - packageId, - packageInfo, - onPaymentSuccess, - onPaymentCancel, -}: PaymentFormProps) { - const t = useTranslations('Dashboard.settings.credits.packages'); - const stripe = useStripe(); - const elements = useElements(); - const [processing, setProcessing] = useState(false); - const { triggerRefresh } = useTransactionStore(); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - - if (!stripe || !elements) { - console.error('Stripe or elements not found'); - return; - } - - setProcessing(true); - - try { - // Confirm the payment using PaymentElement - const { error } = await stripe.confirmPayment({ - elements, - redirect: 'if_required', - }); - - if (error) { - console.error('PaymentForm, payment error:', error); - throw new Error(error.message || 'Payment failed'); - } - - // The payment was successful - const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); - if (paymentIntent.paymentIntent) { - const result = await confirmCreditPayment({ - packageId, - paymentIntentId: paymentIntent.paymentIntent.id, - }); - - if (result?.data?.success) { - console.log('PaymentForm, payment success'); - // Trigger refresh for transaction-dependent UI components - triggerRefresh(); - - // Show success toast - onPaymentSuccess(); - // toast.success(`${packageInfo.credits} credits have been added to your account.`); - } else { - console.error('PaymentForm, payment error:', result?.data?.error); - throw new Error(result?.data?.error || 'Failed to confirm payment'); - } - } else { - console.error('PaymentForm, no payment intent found'); - throw new Error('No payment intent found'); - } - } catch (error) { - console.error('PaymentForm, payment error:', error); - toast.error(t('purchaseFailed')); - } finally { - setProcessing(false); - } - }; - - return ( -
- - - -
-
- -
- {packageInfo.credits.toLocaleString()} -
-
-
- {formatPrice(packageInfo.price, 'USD')} -
-
- {/*
- - {packageInfo.description} -
*/} -
-
-
- - - -
- - -
- -
- ); -} diff --git a/src/config/website.tsx b/src/config/website.tsx index 83e781a..7504a7f 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -143,27 +143,47 @@ export const websiteConfig: WebsiteConfig = { packages: { basic: { id: 'basic', - credits: 100, - price: 990, popular: false, + credits: 100, + price: { + priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC!, + amount: 990, + currency: 'USD', + allowPromotionCode: true, + }, }, standard: { id: 'standard', - credits: 200, - price: 1490, popular: true, + credits: 200, + price: { + priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!, + amount: 1490, + currency: 'USD', + allowPromotionCode: true, + }, }, premium: { id: 'premium', - credits: 500, - price: 3990, popular: false, + credits: 500, + price: { + priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!, + amount: 3990, + currency: 'USD', + allowPromotionCode: true, + }, }, enterprise: { id: 'enterprise', - credits: 1000, - price: 6990, popular: false, + credits: 1000, + price: { + priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!, + amount: 6990, + currency: 'USD', + allowPromotionCode: true, + }, }, }, }, diff --git a/src/credits/types.ts b/src/credits/types.ts index 083326a..9618a01 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -9,13 +9,20 @@ export enum CREDIT_TRANSACTION_TYPE { EXPIRE = 'EXPIRE', // Credits expired } +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: number; // Price of the package in cents + 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 diff --git a/src/payment/index.ts b/src/payment/index.ts index 0d52125..ce0f0bc 100644 --- a/src/payment/index.ts +++ b/src/payment/index.ts @@ -2,11 +2,9 @@ import { websiteConfig } from '@/config/website'; import { StripeProvider } from './provider/stripe'; import type { CheckoutResult, - ConfirmPaymentIntentParams, CreateCheckoutParams, - CreatePaymentIntentParams, + CreateCreditCheckoutParams, CreatePortalParams, - PaymentIntentResult, PaymentProvider, PortalResult, Subscription, @@ -59,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 => { + const provider = getPaymentProvider(); + return provider.createCreditCheckout(params); +}; + /** * Create a customer portal session * @param params Parameters for creating the portal @@ -95,27 +105,3 @@ export const getSubscriptions = async ( const provider = getPaymentProvider(); return provider.getSubscriptions(params); }; - -/** - * Create a payment intent - * @param params Parameters for creating the payment intent - * @returns Payment intent result - */ -export const createPaymentIntent = async ( - params: CreatePaymentIntentParams -): Promise => { - const provider = getPaymentProvider(); - return provider.createPaymentIntent(params); -}; - -/** - * Confirm a payment intent - * @param params Parameters for confirming the payment intent - * @returns True if successful - */ -export const confirmPaymentIntent = async ( - params: ConfirmPaymentIntentParams -): Promise => { - const provider = getPaymentProvider(); - return provider.confirmPaymentIntent(params); -}; diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 04269ab..5fbe79f 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -1,23 +1,18 @@ import { randomUUID } from 'crypto'; -import { getDb } from '@/db'; -import { payment, session, user } from '@/db/schema'; -import { - findPlanByPlanId, - findPlanByPriceId, - findPriceInPlan, -} from '@/lib/price-plan'; -import { sendNotification } from '@/notification/notification'; import { addCredits } from '@/credits/credits'; +import { getCreditPackageByIdInServer } from '@/credits/server'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; +import { getDb } from '@/db'; +import { payment, user } from '@/db/schema'; +import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan'; +import { sendNotification } from '@/notification/notification'; import { desc, eq } from 'drizzle-orm'; import { Stripe } from 'stripe'; import { type CheckoutResult, - type ConfirmPaymentIntentParams, type CreateCheckoutParams, - type CreatePaymentIntentParams, + type CreateCreditCheckoutParams, type CreatePortalParams, - type PaymentIntentResult, type PaymentProvider, type PaymentStatus, PaymentTypes, @@ -284,6 +279,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 { + const { + packageId, + priceId, + customerEmail, + successUrl, + cancelUrl, + metadata, + locale, + } = params; + + try { + // Get credit package + const creditPackage = getCreditPackageByIdInServer(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 @@ -399,7 +492,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); + } } } } else if (eventType.startsWith('payment_intent.')) { @@ -610,37 +707,107 @@ export class StripeProvider implements PaymentProvider { return; } - // Create a one-time payment record - const now = new Date(); - const db = await getDb(); - const result = await db - .insert(payment) - .values({ - id: randomUUID(), - priceId: priceId, - type: PaymentTypes.ONE_TIME, - userId: userId, - customerId: customerId, - status: 'completed', // One-time payments are always completed - periodStart: now, - createdAt: now, - updatedAt: now, - }) - .returning({ id: payment.id }); + try { + // Create a one-time payment record + const now = new Date(); + const db = await getDb(); + const result = await db + .insert(payment) + .values({ + id: randomUUID(), + priceId: priceId, + type: PaymentTypes.ONE_TIME, + userId: userId, + customerId: customerId, + status: 'completed', // One-time payments are always completed + periodStart: now, + createdAt: now, + updatedAt: now, + }) + .returning({ id: payment.id }); - if (result.length === 0) { - console.warn( - `<< Failed to create one-time payment record for user ${userId}` + if (result.length === 0) { + console.warn( + `<< Failed to create one-time payment record for user ${userId}` + ); + return; + } + console.log( + `<< Created one-time payment record for user ${userId}, price: ${priceId}` ); + + // 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 { + const customerId = session.customer as string; + console.log(`>> Handle credit purchase for customer ${customerId}`); + + // get userId from session metadata, we add it in the createCheckout session + const userId = session.metadata?.userId; + if (!userId) { + console.warn(`<< No userId found for checkout session ${session.id}`); return; } - console.log( - `<< Created one-time payment record for user ${userId}, price: ${priceId}` - ); - // Send notification - const amount = session.amount_total ? session.amount_total / 100 : 0; - await sendNotification(session.id, customerId, userId, amount); + // get packageId from session metadata + const packageId = session.metadata?.packageId; + if (!packageId) { + console.warn(`<< No packageId found for checkout session ${session.id}`); + return; + } + + // get priceId from session metadata, not from line items + // 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}`); + return; + } + + // get credits from session metadata + const credits = session.metadata?.credits; + if (!credits) { + console.warn(`<< No credits found for checkout session ${session.id}`); + return; + } + + try { + // Add credits to user account using existing addCredits method + const amount = session.amount_total ? session.amount_total / 100 : 0; + await addCredits({ + userId, + amount: Number.parseInt(credits), + type: CREDIT_TRANSACTION_TYPE.PURCHASE, + description: `Credit package purchase: ${packageId} - ${credits} credits for $${amount}`, + paymentId: session.id, + }); + + console.log( + `<< Added ${credits} credits to user ${userId} for $${amount}` + ); + } catch (error) { + console.error( + `<< onCreditPurchase error for session ${session.id}:`, + error + ); + throw error; + } } /** @@ -666,7 +833,7 @@ export class StripeProvider implements PaymentProvider { // Add credits to user account using existing addCredits method await addCredits({ userId, - amount: parseInt(credits), + amount: Number.parseInt(credits), type: CREDIT_TRANSACTION_TYPE.PURCHASE, description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`, paymentId: paymentIntent.id, @@ -684,56 +851,6 @@ export class StripeProvider implements PaymentProvider { } } - /** - * Create a payment intent - * @param params Parameters for creating the payment intent - * @returns Payment intent result - */ - public async createPaymentIntent( - params: CreatePaymentIntentParams - ): Promise { - const { amount, currency, metadata } = params; - - try { - const paymentIntent = await this.stripe.paymentIntents.create({ - amount, - currency, - metadata, - automatic_payment_methods: { - enabled: true, - }, - }); - - return { - id: paymentIntent.id, - clientSecret: paymentIntent.client_secret!, - }; - } catch (error) { - console.error('Create payment intent error:', error); - throw new Error('Failed to create payment intent'); - } - } - - /** - * Confirm a payment intent - * @param params Parameters for confirming the payment intent - * @returns True if successful - */ - public async confirmPaymentIntent( - params: ConfirmPaymentIntentParams - ): Promise { - const { paymentIntentId } = params; - - try { - const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); - - return paymentIntent.status === 'succeeded'; - } catch (error) { - console.error('Confirm payment intent error:', error); - throw new Error('Failed to confirm payment intent'); - } - } - /** * Map Stripe subscription interval to our own interval types * @param subscription Stripe subscription diff --git a/src/payment/types.ts b/src/payment/types.ts index 7b80936..05ca5bf 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -128,6 +128,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; + locale?: Locale; +} + /** * Result of creating a checkout session */ @@ -159,30 +172,6 @@ export interface getSubscriptionsParams { userId: string; } -/** - * Parameters for creating a payment intent - */ -export interface CreatePaymentIntentParams { - amount: number; - currency: string; - metadata?: Record; -} - -/** - * Result of creating a payment intent - */ -export interface PaymentIntentResult { - id: string; - clientSecret: string; -} - -/** - * Parameters for confirming a payment intent - */ -export interface ConfirmPaymentIntentParams { - paymentIntentId: string; -} - /** * Payment provider interface */ @@ -192,6 +181,11 @@ export interface PaymentProvider { */ createCheckout(params: CreateCheckoutParams): Promise; + /** + * Create a credit checkout session + */ + createCreditCheckout(params: CreateCreditCheckoutParams): Promise; + /** * Create a customer portal session */ @@ -202,16 +196,6 @@ export interface PaymentProvider { */ getSubscriptions(params: getSubscriptionsParams): Promise; - /** - * Create a payment intent - */ - createPaymentIntent(params: CreatePaymentIntentParams): Promise; - - /** - * Confirm a payment intent - */ - confirmPaymentIntent(params: ConfirmPaymentIntentParams): Promise; - /** * Handle webhook events */ From 3e0861f8833c4a097d20728e215f82844351ee32 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 9 Jul 2025 21:58:35 +0800 Subject: [PATCH 33/87] fix: update sidebar rendering logic and enhance credit package success toast - Updated DashboardSidebar to conditionally render SidebarMain based on loading state. - Modified CreditPackages to trigger a refresh of credits data and show success toast with a delay to avoid React lifecycle conflicts. --- src/components/dashboard/dashboard-sidebar.tsx | 2 +- src/components/settings/credits/credit-packages.tsx | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/dashboard-sidebar.tsx b/src/components/dashboard/dashboard-sidebar.tsx index f010307..5bdae62 100644 --- a/src/components/dashboard/dashboard-sidebar.tsx +++ b/src/components/dashboard/dashboard-sidebar.tsx @@ -68,7 +68,7 @@ export function DashboardSidebar({ - + {!isPending && mounted && } diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 382bcd5..d69c6aa 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -30,7 +30,7 @@ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); const [loadingCredits, setLoadingCredits] = useState(true); const [credits, setCredits] = useState(null); - const { refreshTrigger } = useTransactionStore(); + const { refreshTrigger, triggerRefresh } = useTransactionStore(); const currentUser = useCurrentUser(); const searchParams = useSearchParams(); const router = useLocaleRouter(); @@ -61,11 +61,13 @@ export function CreditPackages() { useEffect(() => { const sessionId = searchParams.get('session_id'); if (sessionId) { - // Show success toast - toast.success(t('creditsAdded')); + // Show success toast (delayed to avoid React lifecycle conflicts) + setTimeout(() => { + toast.success(t('creditsAdded')); + }, 0); // Refresh credits data to show updated balance - fetchCredits(); + triggerRefresh(); // Clean up URL parameters const url = new URL(window.location.href); From 04f7f891a444b48057eb68875c15bbce0a6d61da Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 01:12:11 +0800 Subject: [PATCH 34/87] feat: update credit expiration handling and configuration - Added expireDays property to credit packages and related configurations in website.tsx for better management of credit expiration. - Modified addCredits function to handle expireDays more flexibly, allowing for undefined values. - Updated functions for adding register gift and monthly free credits to utilize the new expireDays configuration. - Enhanced type definitions for credits to include optional expireDays for improved clarity. - Removed obsolete creditExpireDays from the credits configuration to streamline the codebase. --- src/config/website.tsx | 7 +- src/credits/README.md | 173 ----------------------------------------- src/credits/credits.ts | 13 +++- src/credits/types.ts | 1 + src/types/index.d.ts | 3 +- 5 files changed, 19 insertions(+), 178 deletions(-) delete mode 100644 src/credits/README.md diff --git a/src/config/website.tsx b/src/config/website.tsx index 7504a7f..9ac824a 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -131,20 +131,22 @@ export const websiteConfig: WebsiteConfig = { }, credits: { enableCredits: true, - creditExpireDays: 30, registerGiftCredits: { enable: true, credits: 100, + expireDays: 30, }, freeMonthlyCredits: { 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, @@ -156,6 +158,7 @@ export const websiteConfig: WebsiteConfig = { id: 'standard', popular: true, credits: 200, + expireDays: 60, price: { priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STANDARD!, amount: 1490, @@ -167,6 +170,7 @@ export const websiteConfig: WebsiteConfig = { id: 'premium', popular: false, credits: 500, + expireDays: 90, price: { priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PREMIUM!, amount: 3990, @@ -178,6 +182,7 @@ export const websiteConfig: WebsiteConfig = { id: 'enterprise', popular: false, credits: 1000, + expireDays: 180, price: { priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!, amount: 6990, diff --git a/src/credits/README.md b/src/credits/README.md deleted file mode 100644 index aa9f2db..0000000 --- a/src/credits/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Credit Management System Implementation - -## Overview - -This document describes the credit management system implementation for the mksaas-template project, which allows users to purchase credits using Stripe payments. - -## Features Implemented - -### 1. Credit Packages -- Defined credit packages with different tiers (Basic, Standard, Premium, Enterprise) -- Each package includes credits amount, price, and description -- Popular package highlighting - -### 2. Payment Integration -- Stripe PaymentIntent integration for credit purchases -- Secure payment processing with webhook verification -- Automatic credit addition upon successful payment - -### 3. UI Components -- Credit balance display with refresh functionality -- Credit packages selection interface -- Stripe payment form integration -- Modern, responsive design - -### 4. Database Integration -- Credit transaction recording -- User credit balance management -- Proper error handling and validation - -## Files Created/Modified - -### Core Components -- `src/components/dashboard/credit-packages.tsx` - Main credit packages interface -- `src/components/dashboard/credit-balance.tsx` - Credit balance display -- `src/components/dashboard/stripe-payment-form.tsx` - Stripe payment integration - -### Actions & API -- `src/actions/credits.action.ts` - Credit-related server actions -- `src/app/api/webhooks/stripe/route.ts` - Stripe webhook handler -- `src/payment/index.ts` - Payment provider interface (updated) -- `src/payment/types.ts` - Payment types (updated) - -### Configuration -- `src/config/website.tsx` - Credit packages configuration -- `env.example` - Environment variables template - -### Pages -- `src/app/[locale]/(protected)/settings/credits/page.tsx` - Credits management page - -## Environment Variables Required - -Add these to your `.env.local` file: - -```env -# Stripe Configuration -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..." -STRIPE_SECRET_KEY="sk_test_..." -STRIPE_WEBHOOK_SECRET="whsec_..." -``` - -## Setup Instructions - -### 1. Stripe Configuration -1. Create a Stripe account at https://dashboard.stripe.com -2. Get your API keys from the Stripe dashboard -3. Set up a webhook endpoint pointing to `/api/webhooks/stripe` -4. Copy the webhook secret and add it to your environment variables - -### 2. Database Setup -Make sure your database schema includes the required credit tables as defined in `src/db/schema.ts`. - -### 3. Environment Variables -Copy the required environment variables from `env.example` to your `.env.local` file and fill in the values. - -## Usage - -### For Users -1. Navigate to `/settings/credits` -2. View current credit balance -3. Select a credit package -4. Complete payment using Stripe -5. Credits are automatically added to account - -### For Developers -```typescript -// Get user credits -const result = await getCreditsAction(); - -// Create payment intent -const paymentIntent = await createCreditPaymentIntent({ - packageId: 'standard' -}); - -// Add credits manually -await addCredits({ - userId: 'user-id', - amount: 100, - type: 'PURCHASE', - description: 'Credit purchase' -}); - -// Access credit packages from config -import { websiteConfig } from '@/config/website'; -const creditPackages = Object.values(websiteConfig.credits.packages); -``` - -## Credit Packages Configuration - -Edit `src/config/website.tsx` to modify available credit packages: - -```typescript -export const websiteConfig: WebsiteConfig = { - // ... other config - credits: { - enableCredits: true, - packages: { - basic: { - id: 'basic', - credits: 100, - price: 990, // Price in cents - popular: false, - description: 'Perfect for getting started', - }, - // ... more packages - }, - }, -}; -``` - -## Webhook Events - -The system handles these Stripe webhook events: -- `payment_intent.succeeded` - Adds credits to user account upon successful payment - -## Security Features - -1. **Webhook Verification**: All webhook requests are verified using Stripe signatures -2. **Payment Validation**: Amount and package validation before processing -3. **User Authentication**: All credit operations require authenticated users -4. **Metadata Validation**: Payment metadata is validated before processing - -## Error Handling - -The system includes comprehensive error handling for: -- Invalid payment attempts -- Network failures -- Database errors -- Authentication issues -- Webhook verification failures - -## Testing - -To test the credit purchase flow: -1. Use Stripe test cards (e.g., `4242424242424242`) -2. Monitor webhook events in Stripe dashboard -3. Check credit balance updates in the application - -## Integration Notes - -This implementation: -- Uses Next.js server actions for secure server-side operations -- Integrates with existing Drizzle ORM schema -- Follows the existing payment provider pattern -- Maintains consistency with the existing codebase architecture - -## Future Enhancements - -Potential improvements: -- Credit transaction history display -- Credit expiration management -- Bulk credit operations -- Credit usage analytics -- Subscription-based credit allocation diff --git a/src/credits/credits.ts b/src/credits/credits.ts index c167f5a..4f16e47 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -96,7 +96,7 @@ export async function addCredits({ type, description, paymentId, - expireDays = websiteConfig.credits.creditExpireDays, + expireDays, }: { userId: string; amount: number; @@ -113,7 +113,10 @@ export async function addCredits({ console.error('addCredits, invalid amount', userId, amount); throw new Error('Invalid amount'); } - if (!Number.isFinite(expireDays) || expireDays <= 0) { + if ( + expireDays !== undefined && + (!Number.isFinite(expireDays) || expireDays <= 0) + ) { console.error('addCredits, invalid expire days', userId, expireDays); throw new Error('Invalid expire days'); } @@ -159,7 +162,7 @@ export async function addCredits({ paymentId, // NOTE: there is no expiration date for PURCHASE type expirationDate: - type === CREDIT_TRANSACTION_TYPE.PURCHASE + type === CREDIT_TRANSACTION_TYPE.PURCHASE || expireDays === undefined ? undefined : addDays(new Date(), expireDays), }); @@ -355,11 +358,13 @@ export async function addRegisterGiftCredits(userId: string) { // 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, }); } } @@ -394,11 +399,13 @@ export async function addMonthlyFreeCredits(userId: string) { // add credits if it's a new month if (canAdd) { const credits = websiteConfig.credits.freeMonthlyCredits.credits; + const expireDays = websiteConfig.credits.freeMonthlyCredits.expireDays; await addCredits({ userId, amount: credits, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expireDays, }); } } diff --git a/src/credits/types.ts b/src/credits/types.ts index 9618a01..0b2fab4 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -26,4 +26,5 @@ export interface CreditPackage { 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 } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 8f88b35..e8cf263 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -155,14 +155,15 @@ export interface PriceConfig { */ export interface CreditsConfig { enableCredits: boolean; // Whether to enable credits - creditExpireDays: number; // The number of days to expire the credits 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 }; freeMonthlyCredits: { enable: boolean; // Whether to enable free monthly 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; // Packages indexed by ID } From cd710bb9edc3f12a30feee0c26f12e616cdffd23 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 09:58:48 +0800 Subject: [PATCH 35/87] feat: support register gift credits --- src/credits/credits.ts | 24 +++++++++++++++++------- src/credits/types.ts | 3 +++ src/lib/auth.ts | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 4f16e47..9cde6c0 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -21,6 +21,11 @@ export async function getUserCredits(userId: string): Promise { 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 @@ -29,6 +34,11 @@ export async function updateUserCredits(userId: string, credits: number) { .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 @@ -63,11 +73,11 @@ export async function saveCreditTransaction({ type, description ); - throw new Error('Invalid params'); + throw new Error('saveCreditTransaction, invalid params'); } if (!Number.isFinite(amount) || amount === 0) { console.error('saveCreditTransaction, invalid amount', userId, amount); - throw new Error('Invalid amount'); + throw new Error('saveCreditTransaction, invalid amount'); } const db = await getDb(); await db.insert(creditTransaction).values({ @@ -107,18 +117,18 @@ export async function addCredits({ }) { if (!userId || !type || !description) { console.error('addCredits, invalid params', userId, type, description); - throw new Error('Invalid params'); + throw new Error('addCredits, invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { console.error('addCredits, invalid amount', userId, amount); - throw new Error('Invalid amount'); + throw new Error('addCredits, invalid amount'); } if ( expireDays !== undefined && (!Number.isFinite(expireDays) || expireDays <= 0) ) { console.error('addCredits, invalid expire days', userId, expireDays); - throw new Error('Invalid expire days'); + throw new Error('addCredits, invalid expire days'); } // Process expired credits first await processExpiredCredits(userId); @@ -194,11 +204,11 @@ export async function consumeCredits({ }) { if (!userId || !description) { console.error('consumeCredits, invalid params', userId, description); - throw new Error('Invalid params'); + throw new Error('consumeCredits, invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { console.error('consumeCredits, invalid amount', userId, amount); - throw new Error('Invalid amount'); + throw new Error('consumeCredits, invalid amount'); } // Process expired credits first await processExpiredCredits(userId); diff --git a/src/credits/types.ts b/src/credits/types.ts index 0b2fab4..0f94a21 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -9,6 +9,9 @@ export enum CREDIT_TRANSACTION_TYPE { 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.) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bd4c3c7..bab07b8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ import { websiteConfig } from '@/config/website'; +import { addCredits } from '@/credits/credits'; +import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { getDb } from '@/db/index'; import { defaultMessages } from '@/i18n/messages'; import { LOCALE_COOKIE_NAME, routing } from '@/i18n/routing'; @@ -129,6 +131,19 @@ export const auth = betterAuth({ 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 + ) { + await addCredits({ + userId: user.id, + amount: websiteConfig.credits.registerGiftCredits.credits, + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: 'Register gift credits', + expireDays: websiteConfig.credits.registerGiftCredits.expireDays, + }); + } }, }, }, From 50c500deb57a1b11633cbfb571f8ffaa5c9e82b9 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 10:15:42 +0800 Subject: [PATCH 36/87] refactor: streamline credit handling in StripeProvider - Updated StripeProvider to retrieve priceId from session metadata instead of line items. - Introduced credit package retrieval to include expiration information when adding credits. - Enhanced logging to reflect credit expiration details during credit addition. - Removed obsolete code related to priceId retrieval for improved clarity and maintainability. --- src/payment/provider/stripe.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 5fbe79f..a6b22fc 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -772,14 +772,6 @@ export class StripeProvider implements PaymentProvider { return; } - // get priceId from session metadata, not from line items - // 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}`); - return; - } - // get credits from session metadata const credits = session.metadata?.credits; if (!credits) { @@ -787,8 +779,15 @@ export class StripeProvider implements PaymentProvider { return; } + // get credit package + const creditPackage = getCreditPackageByIdInServer(packageId); + if (!creditPackage) { + console.warn(`<< Credit package ${packageId} not found`); + return; + } + try { - // Add credits to user account using existing addCredits method + // add credits to user account const amount = session.amount_total ? session.amount_total / 100 : 0; await addCredits({ userId, @@ -796,10 +795,11 @@ export class StripeProvider implements PaymentProvider { type: CREDIT_TRANSACTION_TYPE.PURCHASE, description: `Credit package purchase: ${packageId} - ${credits} credits for $${amount}`, paymentId: session.id, + expireDays: creditPackage.expireDays, }); console.log( - `<< Added ${credits} credits to user ${userId} for $${amount}` + `<< Added ${credits} credits to user ${userId} for $${amount}${creditPackage.expireDays ? ` (expires in ${creditPackage.expireDays} days)` : ' (no expiration)'}` ); } catch (error) { console.error( @@ -830,6 +830,13 @@ export class StripeProvider implements PaymentProvider { } try { + // Get credit package to get expiration info + const creditPackage = getCreditPackageByIdInServer(packageId); + if (!creditPackage) { + console.warn(`<< Credit package ${packageId} not found`); + return; + } + // Add credits to user account using existing addCredits method await addCredits({ userId, @@ -837,10 +844,11 @@ export class StripeProvider implements PaymentProvider { type: CREDIT_TRANSACTION_TYPE.PURCHASE, description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`, paymentId: paymentIntent.id, + expireDays: creditPackage.expireDays, }); console.log( - `<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}` + `<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}${creditPackage.expireDays ? ` (expires in ${creditPackage.expireDays} days)` : ' (no expiration)'}` ); } catch (error) { console.error( From e011d0980319c7375eaa59f5318286c7327ef648 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 10:54:07 +0800 Subject: [PATCH 37/87] feat: enhance CreditPackages component with loading indicator --- .../settings/credits/credit-packages.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index d69c6aa..4d19bec 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -15,7 +15,7 @@ import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { useTransactionStore } from '@/stores/transaction-store'; -import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react'; +import { CircleCheckBigIcon, CoinsIcon, RefreshCwIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -84,7 +84,7 @@ export function CreditPackages() { return (
- + {t('balance')} @@ -92,12 +92,14 @@ export function CreditPackages() {
- -
+ {/* */} +
{loadingCredits ? ( - ... + ) : ( - credits?.toLocaleString() || 0 +
+ {credits?.toLocaleString() || 0} +
)}
From 737bd7f80fbbe991330d92c11f1a912d55a01b15 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 14:42:36 +0800 Subject: [PATCH 38/87] feat: enhance credits management with subscription renewal and lifetime monthly credits --- src/config/website.tsx | 23 ++++-- src/credits/credits.ts | 125 +++++++++++++++++++++++++++++++-- src/credits/types.ts | 12 ++-- src/db/schema.ts | 2 +- src/payment/provider/stripe.ts | 57 ++++++++++++--- src/payment/types.ts | 10 +++ src/types/index.d.ts | 5 -- 7 files changed, 203 insertions(+), 31 deletions(-) diff --git a/src/config/website.tsx b/src/config/website.tsx index 9ac824a..7f0dc32 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -90,6 +90,11 @@ export const websiteConfig: WebsiteConfig = { prices: [], isFree: true, isLifetime: false, + credits: { + enable: true, + amount: 50, + expireDays: 30, + }, }, pro: { id: 'pro', @@ -112,6 +117,11 @@ export const websiteConfig: WebsiteConfig = { isFree: false, isLifetime: false, recommended: true, + credits: { + enable: true, + amount: 1000, + expireDays: 90, + }, }, lifetime: { id: 'lifetime', @@ -126,6 +136,11 @@ export const websiteConfig: WebsiteConfig = { ], isFree: false, isLifetime: true, + credits: { + enable: true, + amount: 2000, + expireDays: 120, + }, }, }, }, @@ -136,11 +151,7 @@ export const websiteConfig: WebsiteConfig = { credits: 100, expireDays: 30, }, - freeMonthlyCredits: { - enable: true, - credits: 50, - expireDays: 30, - }, + packages: { basic: { id: 'basic', @@ -182,7 +193,7 @@ export const websiteConfig: WebsiteConfig = { id: 'enterprise', popular: false, credits: 1000, - expireDays: 180, + expireDays: 120, price: { priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!, amount: 6990, diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 9cde6c0..de04226 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'crypto'; import { websiteConfig } from '@/config/website'; import { getDb } from '@/db'; -import { creditTransaction, userCredit } from '@/db/schema'; +import { creditTransaction, payment, user, userCredit } from '@/db/schema'; +import { findPlanByPriceId } from '@/lib/price-plan'; import { addDays, isAfter } from 'date-fns'; -import { and, asc, eq, or } from 'drizzle-orm'; +import { and, asc, desc, eq, or } from 'drizzle-orm'; import { CREDIT_TRANSACTION_TYPE } from './types'; /** @@ -384,8 +385,18 @@ export async function addRegisterGiftCredits(userId: string) { * @param userId - User ID */ export async function addMonthlyFreeCredits(userId: string) { - if (!websiteConfig.credits.freeMonthlyCredits.enable) { - console.log('addMonthlyFreeCredits, disabled'); + const freePlan = Object.values(websiteConfig.price.plans).find( + (plan) => plan.isFree + ); + if (!freePlan) { + console.log('addMonthlyFreeCredits, no free plan found'); + return; + } + if (freePlan.disabled || !freePlan.credits?.enable) { + console.log( + 'addMonthlyFreeCredits, plan disabled or credits disabled', + freePlan.id + ); return; } // Check last refresh time @@ -402,14 +413,15 @@ export async function addMonthlyFreeCredits(userId: string) { 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 = websiteConfig.credits.freeMonthlyCredits.credits; - const expireDays = websiteConfig.credits.freeMonthlyCredits.expireDays; + const credits = freePlan.credits.amount; + const expireDays = freePlan.credits.expireDays; await addCredits({ userId, amount: credits, @@ -419,3 +431,104 @@ export async function addMonthlyFreeCredits(userId: string) { }); } } + +/** + * Add subscription renewal credits + * @param userId - User ID + * @param priceId - Price ID + */ +export async function addSubscriptionRenewalCredits( + userId: string, + priceId: string +) { + const pricePlan = findPlanByPriceId(priceId); + if ( + !pricePlan || + pricePlan.isFree || + !pricePlan.credits || + !pricePlan.credits.enable + ) { + console.log( + `addSubscriptionRenewalCredits, no credits configured for plan ${priceId}` + ); + return; + } + + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; + + await addCredits({ + userId, + amount: credits, + type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, + description: `Subscription renewal credits for ${priceId}: ${credits}`, + expireDays, + }); + + console.log( + `Added ${credits} subscription renewal 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 + ); + 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(`Added ${credits} lifetime monthly credits for user ${userId}`); + } +} diff --git a/src/credits/types.ts b/src/credits/types.ts index 0f94a21..9641f54 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -2,11 +2,13 @@ * Credit transaction type enum */ export enum CREDIT_TRANSACTION_TYPE { - MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh - REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift - PURCHASE = 'PURCHASE', // Credits earned by purchase - USAGE = 'USAGE', // Credits spent by usage - EXPIRE = 'EXPIRE', // Credits expired + MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh (free users) + REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift + PURCHASE = 'PURCHASE', // Credits earned by purchase + 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 } /** diff --git a/src/db/schema.ts b/src/db/schema.ts index fd56eeb..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -86,7 +86,7 @@ export const creditTransaction = pgTable("credit_transaction", { description: text("description"), amount: integer("amount").notNull(), remainingAmount: integer("remaining_amount"), - paymentId: text("payment_id"), // payment_intent_id + paymentId: text("payment_id"), expirationDate: timestamp("expiration_date"), expirationDateProcessedAt: timestamp("expiration_date_processed_at"), createdAt: timestamp("created_at").notNull().defaultNow(), diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index a6b22fc..e68636e 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { addCredits } from '@/credits/credits'; +import { addCredits, addSubscriptionRenewalCredits } from '@/credits/credits'; import { getCreditPackageByIdInServer } from '@/credits/server'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { getDb } from '@/db'; @@ -608,6 +608,34 @@ export class StripeProvider implements PaymentProvider { return; } + // Get current payment record to check for period changes (indicating renewal) + const db = await getDb(); + const currentPayment = await db + .select({ + userId: payment.userId, + periodStart: payment.periodStart, + periodEnd: payment.periodEnd, + }) + .from(payment) + .where(eq(payment.subscriptionId, stripeSubscription.id)) + .limit(1); + + // get new period start and end + const newPeriodStart = stripeSubscription.current_period_start + ? new Date(stripeSubscription.current_period_start * 1000) + : undefined; + const newPeriodEnd = stripeSubscription.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000) + : undefined; + + // Check if this is a renewal (period has changed and subscription is active) + const isRenewal = + currentPayment.length > 0 && + stripeSubscription.status === 'active' && + currentPayment[0].periodStart && + newPeriodStart && + currentPayment[0].periodStart.getTime() !== newPeriodStart.getTime(); + // update fields const updateFields: any = { priceId: priceId, @@ -615,12 +643,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) @@ -631,7 +655,6 @@ export class StripeProvider implements PaymentProvider { updatedAt: new Date(), }; - const db = await getDb(); const result = await db .update(payment) .set(updateFields) @@ -642,6 +665,24 @@ export class StripeProvider implements PaymentProvider { console.log( `<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}` ); + + // Add credits for subscription renewal + if (isRenewal && currentPayment[0].userId) { + try { + await addSubscriptionRenewalCredits( + currentPayment[0].userId, + priceId + ); + console.log( + `<< Added renewal credits for user ${currentPayment[0].userId}, priceId: ${priceId}` + ); + } catch (error) { + console.error( + `<< Failed to add renewal credits for user ${currentPayment[0].userId}:`, + error + ); + } + } } else { console.warn( `<< No payment record found for Stripe subscription ${stripeSubscription.id}` diff --git a/src/payment/types.ts b/src/payment/types.ts index 05ca5bf..c0e9787 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -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 + expireDays?: number; // Number of days until credits expire, undefined means no expiration +} + /** * Price plan definition * @@ -72,6 +81,7 @@ export interface PricePlan { isLifetime: boolean; // Whether this is a lifetime plan recommended?: boolean; // Whether to mark this plan as recommended in UI disabled?: boolean; // Whether to disable this plan in UI + credits?: Credits; // Credits configuration for this plan } /** diff --git a/src/types/index.d.ts b/src/types/index.d.ts index e8cf263..39fd6d3 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -160,11 +160,6 @@ export interface CreditsConfig { 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 }; - freeMonthlyCredits: { - enable: boolean; // Whether to enable free monthly 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; // Packages indexed by ID } From 861502c28feb62273c5b52b6493dfe88a3d4e6b8 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 14:52:23 +0800 Subject: [PATCH 39/87] feat: implement credit distribution for all users based on subscription status --- src/credits/credits.ts | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/credits/credits.ts b/src/credits/credits.ts index de04226..24c3a5b 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -532,3 +532,70 @@ export async function addLifetimeMonthlyCredits(userId: string) { console.log(`Added ${credits} lifetime monthly credits for user ${userId}`); } } + +/** + * 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('Starting credit distribution to all users...'); + + 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 + + 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), + eq(payment.status, 'active') + ) + ) + .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( + `Error processing credits for user ${userRecord.userId}:`, + error + ); + errorCount++; + } + } + + console.log( + `Credit distribution completed. Processed: ${processedCount}, Errors: ${errorCount}` + ); + return { processedCount, errorCount }; +} From 5c213d014a12b00b8fc545f5d01ae3d073989708 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 14:54:07 +0800 Subject: [PATCH 40/87] refactor: 'popular' instead of 'recommended' for price plan --- src/components/pricing/pricing-card.tsx | 8 ++++---- src/config/website.tsx | 2 +- src/payment/types.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/pricing/pricing-card.tsx b/src/components/pricing/pricing-card.tsx index c2b729a..e526b0f 100644 --- a/src/components/pricing/pricing-card.tsx +++ b/src/components/pricing/pricing-card.tsx @@ -107,14 +107,14 @@ export function PricingCard({ {/* show popular badge if plan is recommended */} - {plan.recommended && ( + {plan.popular && ( {t('yourCurrentPlan')} @@ -203,7 +203,7 @@ export function PricingCard({ {hasTrialPeriod && (
{t('daysTrial', { days: price.trialPeriodDays as number })} diff --git a/src/config/website.tsx b/src/config/website.tsx index 7f0dc32..0a8c76c 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -116,7 +116,7 @@ export const websiteConfig: WebsiteConfig = { ], isFree: false, isLifetime: false, - recommended: true, + popular: true, credits: { enable: true, amount: 1000, diff --git a/src/payment/types.ts b/src/payment/types.ts index c0e9787..e07f87c 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -79,7 +79,7 @@ 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 } From 1c7848f6b0231602d3d371f83c3a4a69feb5de61 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 15:06:58 +0800 Subject: [PATCH 41/87] refactor: change getAllCreditPackagesInServer to getAllCreditPackages --- src/actions/create-credit-checkout-session.ts | 4 ++-- src/credits/server.ts | 14 +++++--------- src/payment/provider/stripe.ts | 8 ++++---- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/actions/create-credit-checkout-session.ts b/src/actions/create-credit-checkout-session.ts index 6ad7d11..826a512 100644 --- a/src/actions/create-credit-checkout-session.ts +++ b/src/actions/create-credit-checkout-session.ts @@ -1,7 +1,7 @@ 'use server'; import { websiteConfig } from '@/config/website'; -import { getCreditPackageByIdInServer } from '@/credits/server'; +import { getCreditPackageById } from '@/credits/server'; import { getSession } from '@/lib/server'; import { getUrlWithLocale } from '@/lib/urls/urls'; import { createCreditCheckout } from '@/payment'; @@ -60,7 +60,7 @@ export const createCreditCheckoutSession = actionClient const locale = await getLocale(); // Find the credit package - const creditPackage = getCreditPackageByIdInServer(packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { return { success: false, diff --git a/src/credits/server.ts b/src/credits/server.ts index fe0a5d6..f812f84 100644 --- a/src/credits/server.ts +++ b/src/credits/server.ts @@ -2,22 +2,18 @@ import { websiteConfig } from '@/config/website'; import type { CreditPackage } from './types'; /** - * Get all credit packages, used in server components + * Get all credit packages, can be used in server or client components * @returns Credit packages */ -export function getAllCreditPackagesInServer(): CreditPackage[] { +export function getAllCreditPackages(): CreditPackage[] { return Object.values(websiteConfig.credits.packages); } /** - * Get credit package by id, used in server components + * Get credit package by id, can be used in server or client components * @param id - Credit package id * @returns Credit package */ -export function getCreditPackageByIdInServer( - id: string -): CreditPackage | undefined { - return websiteConfig.credits.packages[ - id as keyof typeof websiteConfig.credits.packages - ]; +export function getCreditPackageById(id: string): CreditPackage | undefined { + return getAllCreditPackages().find((pkg) => pkg.id === id); } diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index e68636e..e3508ec 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import { addCredits, addSubscriptionRenewalCredits } from '@/credits/credits'; -import { getCreditPackageByIdInServer } from '@/credits/server'; +import { getCreditPackageById } from '@/credits/server'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { getDb } from '@/db'; import { payment, user } from '@/db/schema'; @@ -299,7 +299,7 @@ export class StripeProvider implements PaymentProvider { try { // Get credit package - const creditPackage = getCreditPackageByIdInServer(packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { throw new Error(`Credit package with ID ${packageId} not found`); } @@ -821,7 +821,7 @@ export class StripeProvider implements PaymentProvider { } // get credit package - const creditPackage = getCreditPackageByIdInServer(packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { console.warn(`<< Credit package ${packageId} not found`); return; @@ -872,7 +872,7 @@ export class StripeProvider implements PaymentProvider { try { // Get credit package to get expiration info - const creditPackage = getCreditPackageByIdInServer(packageId); + const creditPackage = getCreditPackageById(packageId); if (!creditPackage) { console.warn(`<< Credit package ${packageId} not found`); return; From f649db26aea1ce824131cd1adb0448cc6fe44a6f Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 15:41:11 +0800 Subject: [PATCH 42/87] feat: add CreditsBalanceButton and CreditsBalanceMenu components --- src/components/dashboard/dashboard-header.tsx | 4 +- ...balance.tsx => credits-balance-button.tsx} | 18 +++--- .../layout/credits-balance-menu.tsx | 57 +++++++++++++++++++ src/components/layout/navbar-mobile.tsx | 3 +- src/components/layout/navbar.tsx | 9 +-- src/components/layout/user-button.tsx | 14 +++++ .../settings/credits/credit-packages.tsx | 4 +- src/config/avatar-config.tsx | 6 -- src/config/navbar-config.tsx | 14 ++--- 9 files changed, 97 insertions(+), 32 deletions(-) rename src/components/layout/{credits-balance.tsx => credits-balance-button.tsx} (74%) create mode 100644 src/components/layout/credits-balance-menu.tsx diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 1cb04e6..eaabda1 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -8,10 +8,10 @@ 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'; -import { CreditsBalance } from '../layout/credits-balance'; interface DashboardBreadcrumbItem { label: string; @@ -73,7 +73,7 @@ export function DashboardHeader({
{actions} - + {isDemo && } diff --git a/src/components/layout/credits-balance.tsx b/src/components/layout/credits-balance-button.tsx similarity index 74% rename from src/components/layout/credits-balance.tsx rename to src/components/layout/credits-balance-button.tsx index 5f69707..03cb9da 100644 --- a/src/components/layout/credits-balance.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -5,10 +5,10 @@ import { Button } from '@/components/ui/button'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; import { useTransactionStore } from '@/stores/transaction-store'; -import { CoinsIcon } from 'lucide-react'; +import { CoinsIcon, Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; -export function CreditsBalance() { +export function CreditsBalanceButton() { const router = useLocaleRouter(); const { refreshTrigger } = useTransactionStore(); const [credits, setCredits] = useState(0); @@ -22,7 +22,7 @@ export function CreditsBalance() { setCredits(result.data.credits); } } catch (error) { - console.error('CreditsBalance, fetch credits error:', error); + console.error('CreditsBalanceButton, fetch credits error:', error); } finally { setLoading(false); } @@ -37,14 +37,18 @@ export function CreditsBalance() { return ( ); diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx new file mode 100644 index 0000000..529a192 --- /dev/null +++ b/src/components/layout/credits-balance-menu.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { getCreditsAction } from '@/actions/credits.action'; +import { useLocaleRouter } from '@/i18n/navigation'; +import { Routes } from '@/routes'; +import { useTransactionStore } from '@/stores/transaction-store'; +import { CoinsIcon, Loader2Icon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export function CreditsBalanceMenu() { + const router = useLocaleRouter(); + const { refreshTrigger } = useTransactionStore(); + const [credits, setCredits] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchCredits = async () => { + try { + const result = await getCreditsAction(); + if (result?.data?.success && result.data.credits !== undefined) { + setCredits(result.data.credits); + } + } catch (error) { + console.error('CreditsBalanceMenu, fetch credits error:', error); + } finally { + setLoading(false); + } + }; + + fetchCredits(); + }, [refreshTrigger]); + + const handleClick = () => { + router.push(Routes.SettingsCredits); + }; + + return ( +
+
+ +

Credits

+
+
+

+ {loading ? ( + + ) : ( + credits.toLocaleString() + )} +

+
+
+ ); +} diff --git a/src/components/layout/navbar-mobile.tsx b/src/components/layout/navbar-mobile.tsx index fb530ec..df26ea0 100644 --- a/src/components/layout/navbar-mobile.tsx +++ b/src/components/layout/navbar-mobile.tsx @@ -28,7 +28,6 @@ import { useEffect, useState } from 'react'; import { RemoveScroll } from 'react-remove-scroll'; import { Skeleton } from '../ui/skeleton'; import { UserButtonMobile } from './user-button-mobile'; -import { CreditsBalance } from './credits-balance'; export function NavbarMobile({ className, @@ -96,7 +95,7 @@ export function NavbarMobile({ ) : currentUser ? ( <> - + {/* */} ) : null} diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index af7409b..99b1763 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -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,11 +24,9 @@ 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'; -import { CreditsBalance } from './credits-balance'; interface NavBarProps { scroll?: boolean; @@ -224,7 +221,7 @@ export function Navbar({ scroll }: NavBarProps) { ) : currentUser ? ( <> - + {/* */} ) : ( diff --git a/src/components/layout/user-button.tsx b/src/components/layout/user-button.tsx index cf8ba4f..b3d04a2 100644 --- a/src/components/layout/user-button.tsx +++ b/src/components/layout/user-button.tsx @@ -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,8 @@ import { LogOutIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { toast } from 'sonner'; +import { CreditsBalanceButton } from './credits-balance-button'; +import { CreditsBalanceMenu } from './credits-balance-menu'; interface UserButtonProps { user: User; @@ -57,6 +60,7 @@ export function UserButton({ user }: UserButtonProps) { /> + {/* show user name and email */}

{user.name}

@@ -67,6 +71,16 @@ export function UserButton({ user }: UserButtonProps) {
+ {/* show credits balance button if credits are enabled */} + {websiteConfig.credits.enableCredits && ( + <> + + + + + + )} + {avatarLinks.map((item) => ( */}
{loadingCredits ? ( - + ) : (
{credits?.toLocaleString() || 0} diff --git a/src/config/avatar-config.tsx b/src/config/avatar-config.tsx index 749e125..78d5083 100644 --- a/src/config/avatar-config.tsx +++ b/src/config/avatar-config.tsx @@ -3,7 +3,6 @@ import { Routes } from '@/routes'; import type { MenuItem } from '@/types'; import { - CoinsIcon, CreditCardIcon, LayoutDashboardIcon, Settings2Icon, @@ -34,11 +33,6 @@ export function getAvatarLinks(): MenuItem[] { href: Routes.SettingsBilling, icon: , }, - { - title: t('credits'), - href: Routes.SettingsCredits, - icon: , - }, { title: t('settings'), href: Routes.SettingsProfile, diff --git a/src/config/navbar-config.tsx b/src/config/navbar-config.tsx index 19b665b..3bd9e85 100644 --- a/src/config/navbar-config.tsx +++ b/src/config/navbar-config.tsx @@ -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: , - // href: Routes.AIText, - // external: false, - // }, + { + title: t('ai.items.text.title'), + description: t('ai.items.text.description'), + icon: , + href: Routes.AIText, + external: false, + }, { title: t('ai.items.image.title'), description: t('ai.items.image.description'), From bbae584c88eab1a8c7a9b8dc1df9ad13dd5a4271 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 15:56:15 +0800 Subject: [PATCH 43/87] fix: update DashboardHeaderand update credits label in CreditsBalanceMenu to use translations --- src/components/dashboard/dashboard-header.tsx | 2 +- src/components/layout/credits-balance-menu.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index eaabda1..629e518 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -74,9 +74,9 @@ export function DashboardHeader({ {actions} - {isDemo && } + {isDemo && }
diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index 529a192..160cd37 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -5,9 +5,11 @@ import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; import { useTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; export function CreditsBalanceMenu() { + const t = useTranslations('Marketing.avatar'); const router = useLocaleRouter(); const { refreshTrigger } = useTransactionStore(); const [credits, setCredits] = useState(0); @@ -41,7 +43,7 @@ export function CreditsBalanceMenu() { >
-

Credits

+

{t('credits')}

From 6c1a4685cdb27803f9ffd6a836c04b9a56a261eb Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 16:30:56 +0800 Subject: [PATCH 44/87] refactor: add disabled to credits config --- messages/en.json | 3 +-- messages/zh.json | 3 +-- .../settings/credits/credit-checkout-button.tsx | 14 -------------- .../settings/credits/credit-packages.tsx | 9 +++++---- src/credits/types.ts | 1 + 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/messages/en.json b/messages/en.json index 5a6dc1d..3bcaa8c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -604,8 +604,7 @@ "purchaseFailed": "Purchase credits failed", "checkoutFailed": "Failed to create checkout session", "loading": "Loading...", - "pay": "Pay", - "notConfigured": "Not configured" + "pay": "Pay" }, "tabs": { "balance": "Balance", diff --git a/messages/zh.json b/messages/zh.json index 89c6de3..61e67fa 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -605,8 +605,7 @@ "purchaseFailed": "购买积分失败", "checkoutFailed": "创建支付会话失败", "loading": "加载中...", - "pay": "支付", - "notConfigured": "未配置" + "pay": "支付" }, "tabs": { "balance": "积分余额", diff --git a/src/components/settings/credits/credit-checkout-button.tsx b/src/components/settings/credits/credit-checkout-button.tsx index 4bc7a42..ad55cbf 100644 --- a/src/components/settings/credits/credit-checkout-button.tsx +++ b/src/components/settings/credits/credit-checkout-button.tsx @@ -53,20 +53,6 @@ export function CreditCheckoutButton({ try { setIsLoading(true); - // Get the credit package to find the priceId - // const creditPackages = getCreditPackages(); - // const creditPackage = creditPackages[packageId]; - - // if (!creditPackage) { - // toast.error('Invalid credit package'); - // return; - // } - - // if (!creditPackage.price.priceId) { - // toast.error(t('notConfigured')); - // return; - // } - const mergedMetadata = metadata ? { ...metadata } : {}; // add promotekit_referral to metadata if enabled promotekit affiliate diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index bf793b5..826b8b5 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -35,7 +35,10 @@ export function CreditPackages() { const searchParams = useSearchParams(); const router = useLocaleRouter(); - const creditPackages = Object.values(getCreditPackages()); + // show only enabled packages + const creditPackages = Object.values(getCreditPackages()).filter( + (pkg) => !pkg.disabled && pkg.price.priceId + ); const fetchCredits = async () => { try { @@ -168,9 +171,7 @@ export function CreditPackages() { variant={creditPackage.popular ? 'default' : 'outline'} disabled={!creditPackage.price.priceId} > - {!creditPackage.price.priceId - ? t('notConfigured') - : t('purchase')} + {t('purchase')} diff --git a/src/credits/types.ts b/src/credits/types.ts index 9641f54..59c3f6a 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -32,4 +32,5 @@ export interface CreditPackage { 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 } From 95bd256bc7556da3ed4ed693553fb29c00bf8e8d Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 16:31:57 +0800 Subject: [PATCH 45/87] chore: remove onPaymentIntentSucceeded in stripe --- src/payment/provider/stripe.ts | 55 ---------------------------------- 1 file changed, 55 deletions(-) diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index e3508ec..bc76455 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -499,12 +499,6 @@ export class StripeProvider implements PaymentProvider { } } } - } else if (eventType.startsWith('payment_intent.')) { - // Handle payment intent events - if (eventType === 'payment_intent.succeeded') { - const paymentIntent = event.data.object as Stripe.PaymentIntent; - await this.onPaymentIntentSucceeded(paymentIntent); - } } } catch (error) { console.error('handle webhook event error:', error); @@ -851,55 +845,6 @@ export class StripeProvider implements PaymentProvider { } } - /** - * Handle payment intent succeeded event - * @param paymentIntent Stripe payment intent - */ - private async onPaymentIntentSucceeded( - paymentIntent: Stripe.PaymentIntent - ): Promise { - console.log(`>> Handle payment intent succeeded: ${paymentIntent.id}`); - - // Get metadata from payment intent - const { packageId, userId, credits } = paymentIntent.metadata; - - if (!packageId || !userId || !credits) { - console.warn( - `<< Missing metadata for payment intent ${paymentIntent.id}: packageId=${packageId}, userId=${userId}, credits=${credits}` - ); - return; - } - - try { - // Get credit package to get expiration info - const creditPackage = getCreditPackageById(packageId); - if (!creditPackage) { - console.warn(`<< Credit package ${packageId} not found`); - return; - } - - // Add credits to user account using existing addCredits method - await addCredits({ - userId, - amount: Number.parseInt(credits), - type: CREDIT_TRANSACTION_TYPE.PURCHASE, - description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`, - paymentId: paymentIntent.id, - expireDays: creditPackage.expireDays, - }); - - console.log( - `<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}${creditPackage.expireDays ? ` (expires in ${creditPackage.expireDays} days)` : ' (no expiration)'}` - ); - } catch (error) { - console.error( - `<< Error processing payment intent ${paymentIntent.id}:`, - error - ); - throw error; - } - } - /** * Map Stripe subscription interval to our own interval types * @param subscription Stripe subscription From 2e0a195a2a47967c29f8bad54773927b592f4084 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 16:48:29 +0800 Subject: [PATCH 46/87] refactor: update URL handling in CreditPackages component --- src/components/settings/credits/credit-packages.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 826b8b5..8df1fa3 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -14,6 +14,7 @@ import { useCurrentUser } from '@/hooks/use-current-user'; import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; +import { Routes } from '@/routes'; import { useTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -33,7 +34,7 @@ export function CreditPackages() { const { refreshTrigger, triggerRefresh } = useTransactionStore(); const currentUser = useCurrentUser(); const searchParams = useSearchParams(); - const router = useLocaleRouter(); + const localeRouter = useLocaleRouter(); // show only enabled packages const creditPackages = Object.values(getCreditPackages()).filter( @@ -74,10 +75,9 @@ export function CreditPackages() { // Clean up URL parameters const url = new URL(window.location.href); - url.searchParams.delete('session_id'); - router.replace(url.pathname + url.search); + localeRouter.replace(Routes.SettingsCredits + url.search); } - }, [searchParams, router]); + }, [searchParams, localeRouter]); // Initial fetch and listen for transaction updates useEffect(() => { From 3872a9d4227da5821b77fb833d9a4c7b79780e21 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 19:20:11 +0800 Subject: [PATCH 47/87] refactor: remove session_id from URL parameters in CreditPackages component --- src/components/settings/credits/credit-packages.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 8df1fa3..e13f733 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -75,6 +75,8 @@ export function CreditPackages() { // Clean up URL parameters const url = new URL(window.location.href); + url.searchParams.delete('session_id'); + // Use Routes.SettingsCredits + url.search to properly handle locale routing localeRouter.replace(Routes.SettingsCredits + url.search); } }, [searchParams, localeRouter]); From 05006178038a49de32867595ad3e70f919bdd80e Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 19:21:09 +0800 Subject: [PATCH 48/87] style: adjust padding in dashboard header actions --- src/components/dashboard/dashboard-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 629e518..cb7d855 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -70,7 +70,7 @@ export function DashboardHeader({ {/* dashboard header actions on the right side */} -

+
{actions} From 2aeb027e2f7296bdf3750fa880b9fb45e384473b Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 19:34:06 +0800 Subject: [PATCH 49/87] feat: add subscription renewal and lifetime monthly messages in English and Chinese --- messages/en.json | 4 +- messages/zh.json | 4 +- .../credits/credit-transactions-table.tsx | 117 +++++++++++------- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/messages/en.json b/messages/en.json index 3bcaa8c..6e1d5be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -633,7 +633,9 @@ "REGISTER_GIFT": "Register Gift", "PURCHASE": "Purchase", "USAGE": "Usage", - "EXPIRE": "Expire" + "EXPIRE": "Expire", + "SUBSCRIPTION_RENEWAL": "Subscription Renewal", + "LIFETIME_MONTHLY": "Lifetime Monthly" }, "expired": "Expired", "never": "Never" diff --git a/messages/zh.json b/messages/zh.json index 61e67fa..6ad555f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -634,7 +634,9 @@ "REGISTER_GIFT": "注册礼品", "PURCHASE": "购买", "USAGE": "使用", - "EXPIRE": "过期" + "EXPIRE": "过期", + "SUBSCRIPTION_RENEWAL": "订阅续费", + "LIFETIME_MONTHLY": "终身月度" }, "expired": "已过期", "never": "永不" diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index f9da1ad..392e7cb 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -23,6 +23,13 @@ import { 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 { type ColumnDef, @@ -43,18 +50,17 @@ import { ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, - RefreshCwIcon, - GiftIcon, - ShoppingCartIcon, - MinusCircleIcon, ClockIcon, + GiftIcon, + MinusCircleIcon, + RefreshCwIcon, + 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'; -import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; // Define the credit transaction interface interface CreditTransaction { @@ -109,8 +115,6 @@ interface CreditTransactionsTableProps { onSortingChange?: (sorting: SortingState) => void; } -export { type CreditTransaction }; - export function CreditTransactionsTable({ data, total, @@ -126,7 +130,7 @@ export function CreditTransactionsTable({ const t = useTranslations('Dashboard.settings.credits.transactions'); const tTable = useTranslations('Common.table'); const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true } + { id: 'createdAt', desc: true }, ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -147,39 +151,42 @@ export function CreditTransactionsTable({ updatedAt: 'columns.updatedAt' as const, } as const; - // Get transaction type icon and color + // Get transaction type icon const getTransactionTypeIcon = (type: string) => { switch (type) { case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - return ; + return ; case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: - return ; + return ; case CREDIT_TRANSACTION_TYPE.PURCHASE: - return ; + return ; case CREDIT_TRANSACTION_TYPE.USAGE: - return ; + return ; case CREDIT_TRANSACTION_TYPE.EXPIRE: - return ; + return ; + case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: + return ; + case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: + return ; default: return null; } }; - // Get transaction type color - const getTransactionTypeColor = (type: string) => { + // Get transaction type badge variant + const getTransactionTypeBadgeVariant = (type: string) => { switch (type) { - case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - return 'bg-blue-100 text-blue-800 border-blue-200'; case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: - return 'bg-green-100 text-green-800 border-green-200'; case CREDIT_TRANSACTION_TYPE.PURCHASE: - return 'bg-purple-100 text-purple-800 border-purple-200'; + case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: + case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: + case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: + return 'outline' as const; case CREDIT_TRANSACTION_TYPE.USAGE: - return 'bg-red-100 text-red-800 border-red-200'; case CREDIT_TRANSACTION_TYPE.EXPIRE: - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'destructive' as const; default: - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'outline' as const; } }; @@ -196,6 +203,10 @@ export function CreditTransactionsTable({ 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; } @@ -212,10 +223,7 @@ export function CreditTransactionsTable({ const transaction = row.original; return (
- + {getTransactionTypeIcon(transaction.type)} {getTransactionTypeDisplayName(transaction.type)} @@ -247,7 +255,10 @@ export function CreditTransactionsTable({ { accessorKey: 'remainingAmount', header: ({ column }) => ( - + ), cell: ({ row }) => { const transaction = row.original; @@ -267,15 +278,33 @@ export function CreditTransactionsTable({ { accessorKey: 'description', header: ({ column }) => ( - + ), cell: ({ row }) => { const transaction = row.original; return (
- - {transaction.description || '-'} - + {transaction.description ? ( + + + + + {transaction.description} + + + +

+ {transaction.description} +

+
+
+
+ ) : ( + - + )}
); }, @@ -310,7 +339,10 @@ export function CreditTransactionsTable({ { accessorKey: 'expirationDate', header: ({ column }) => ( - + ), cell: ({ row }) => { const transaction = row.original; @@ -330,7 +362,10 @@ export function CreditTransactionsTable({ { accessorKey: 'expirationDateProcessedAt', header: ({ column }) => ( - + ), cell: ({ row }) => { const transaction = row.original; @@ -356,9 +391,7 @@ export function CreditTransactionsTable({ const transaction = row.original; return (
- - {formatDate(transaction.createdAt)} - + {formatDate(transaction.createdAt)}
); }, @@ -372,9 +405,7 @@ export function CreditTransactionsTable({ const transaction = row.original; return (
- - {formatDate(transaction.updatedAt)} - + {formatDate(transaction.updatedAt)}
); }, @@ -514,11 +545,7 @@ export function CreditTransactionsTable({
- {total > 0 && ( - - {tTable('totalRecords', { count: total })} - - )} + {total > 0 && {tTable('totalRecords', { count: total })}}
From b75e9eb2824ed2cc417c611840deafff95ed5501 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 19:40:08 +0800 Subject: [PATCH 50/87] refactor: initialize sorting state with default value in UsersPage and CreditTransactionsPage components --- src/components/admin/users-page.tsx | 4 +++- .../settings/credits/credit-transactions-page.tsx | 9 +++++++-- .../settings/credits/credit-transactions-table.tsx | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/admin/users-page.tsx b/src/components/admin/users-page.tsx index ca49b4b..6704d5f 100644 --- a/src/components/admin/users-page.tsx +++ b/src/components/admin/users-page.tsx @@ -17,7 +17,9 @@ export function UsersPageClient() { const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true }, + ]); const refreshTrigger = useUsersStore((state) => state.refreshTrigger); useEffect(() => { diff --git a/src/components/settings/credits/credit-transactions-page.tsx b/src/components/settings/credits/credit-transactions-page.tsx index 4f18953..d0a2da7 100644 --- a/src/components/settings/credits/credit-transactions-page.tsx +++ b/src/components/settings/credits/credit-transactions-page.tsx @@ -16,7 +16,9 @@ export function CreditTransactionsPageClient() { const [search, setSearch] = useState(''); const [data, setData] = useState([]); const [total, setTotal] = useState(0); - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true }, + ]); const [loading, setLoading] = useState(false); const { refreshTrigger } = useTransactionStore(); @@ -40,7 +42,10 @@ export function CreditTransactionsPageClient() { setTotal(0); } } catch (error) { - console.error('CreditTransactions, fetch credit transactions error:', error); + console.error( + 'CreditTransactions, fetch credit transactions error:', + error + ); toast.error(t('error')); setData([]); setTotal(0); diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 392e7cb..9a5fe0f 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -63,7 +63,7 @@ import { Badge } from '../../ui/badge'; import { Label } from '../../ui/label'; // Define the credit transaction interface -interface CreditTransaction { +export interface CreditTransaction { id: string; userId: string; type: string; @@ -265,7 +265,7 @@ export function CreditTransactionsTable({ return (
{transaction.remainingAmount !== null ? ( - + {transaction.remainingAmount.toLocaleString()} ) : ( From 263440742a5d5a4e3a31003f7a5d063ac1e15eff Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 21:43:43 +0800 Subject: [PATCH 51/87] feat: add CreditDetailViewer component and enhance credit transaction details in English and Chinese --- messages/en.json | 4 + messages/zh.json | 4 + src/actions/get-credit-transactions.ts | 8 +- .../credits/credit-transactions-table.tsx | 21 +- src/credits/credit-detail-viewer.tsx | 250 ++++++++++++++++++ src/payment/provider/stripe.ts | 2 +- 6 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 src/credits/credit-detail-viewer.tsx diff --git a/messages/en.json b/messages/en.json index 6e1d5be..f7b9c34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -637,6 +637,10 @@ "SUBSCRIPTION_RENEWAL": "Subscription Renewal", "LIFETIME_MONTHLY": "Lifetime Monthly" }, + "detailViewer": { + "title": "Credit Transaction Detail", + "close": "Close" + }, "expired": "Expired", "never": "Never" } diff --git a/messages/zh.json b/messages/zh.json index 6ad555f..b0d2cd9 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -638,6 +638,10 @@ "SUBSCRIPTION_RENEWAL": "订阅续费", "LIFETIME_MONTHLY": "终身月度" }, + "detailViewer": { + "title": "积分交易详情", + "close": "关闭" + }, "expired": "已过期", "never": "永不" } diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index be36f23..65dd504 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -74,7 +74,8 @@ export const getCreditTransactionsAction = actionClient remainingAmount: creditTransaction.remainingAmount, paymentId: creditTransaction.paymentId, expirationDate: creditTransaction.expirationDate, - expirationDateProcessedAt: creditTransaction.expirationDateProcessedAt, + expirationDateProcessedAt: + creditTransaction.expirationDateProcessedAt, createdAt: creditTransaction.createdAt, updatedAt: creditTransaction.updatedAt, }) @@ -108,7 +109,10 @@ export const getCreditTransactionsAction = actionClient console.error('get credit transactions error:', error); return { success: false, - error: error instanceof Error ? error.message : 'Failed to fetch credit transactions', + error: + error instanceof Error + ? error.message + : 'Failed to fetch credit transactions', }; } }); diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 9a5fe0f..64e0b31 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -29,6 +29,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { CreditDetailViewer } from '@/credits/credit-detail-viewer'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { formatDate } from '@/lib/formatter'; import { @@ -223,7 +224,10 @@ export function CreditTransactionsTable({ const transaction = row.original; return (
- + {getTransactionTypeIcon(transaction.type)} {getTransactionTypeDisplayName(transaction.type)} @@ -238,18 +242,7 @@ export function CreditTransactionsTable({ ), cell: ({ row }) => { const transaction = row.original; - return ( -
- 0 ? 'text-green-600' : 'text-red-600' - }`} - > - {transaction.amount > 0 ? '+' : ''} - {transaction.amount.toLocaleString()} - -
- ); + return ; }, }, { @@ -543,7 +536,7 @@ export function CreditTransactionsTable({
-
+
{total > 0 && {tTable('totalRecords', { count: total })}}
diff --git a/src/credits/credit-detail-viewer.tsx b/src/credits/credit-detail-viewer.tsx new file mode 100644 index 0000000..4105f9d --- /dev/null +++ b/src/credits/credit-detail-viewer.tsx @@ -0,0 +1,250 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + 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 { + ClockIcon, + GiftIcon, + MinusCircleIcon, + RefreshCwIcon, + ShoppingCartIcon, +} from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { toast } from 'sonner'; +import { CREDIT_TRANSACTION_TYPE } from './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 ; + case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: + return ; + case CREDIT_TRANSACTION_TYPE.PURCHASE: + return ; + case CREDIT_TRANSACTION_TYPE.USAGE: + return ; + case CREDIT_TRANSACTION_TYPE.EXPIRE: + return ; + case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: + return ; + case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: + return ; + default: + return null; + } + }; + + // Get transaction type badge variant + const getTransactionTypeBadgeVariant = (type: string) => { + switch (type) { + case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: + case CREDIT_TRANSACTION_TYPE.PURCHASE: + case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: + case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: + case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: + return 'outline' as const; + case CREDIT_TRANSACTION_TYPE.USAGE: + case CREDIT_TRANSACTION_TYPE.EXPIRE: + return 'destructive' as const; + default: + return 'outline' as const; + } + }; + + // 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: + 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 ( + + + + + + + {t('detailViewer.title')} + +
+
+
+ {/* Transaction Type Badge */} + + {getTransactionTypeIcon(transaction.type)} + {getTransactionTypeDisplayName(transaction.type)} + +
+ + {/* Basic Information */} +
+
+ + {t('columns.amount')}: + + 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {transaction.amount > 0 ? '+' : ''} + {transaction.amount.toLocaleString()} + +
+ + {transaction.remainingAmount !== null && ( +
+ + {t('columns.remainingAmount')}: + + + {transaction.remainingAmount.toLocaleString()} + +
+ )} + + {transaction.description && ( +
+ + {t('columns.description')}: + + {transaction.description} +
+ )} + + {transaction.paymentId && ( +
+ + {t('columns.paymentId')}: + + { + navigator.clipboard.writeText(transaction.paymentId!); + toast.success(t('paymentIdCopied')); + }} + > + {transaction.paymentId} + +
+ )} + + {transaction.expirationDate && ( +
+ + {t('columns.expirationDate')}: + + {formatDate(transaction.expirationDate)} +
+ )} + + {transaction.expirationDateProcessedAt && ( +
+ + {t('columns.expirationDateProcessedAt')}: + + + {formatDate(transaction.expirationDateProcessedAt)} + +
+ )} +
+
+ + + + {/* Timestamps */} +
+
+ + {t('columns.createdAt')}: + + {formatDate(transaction.createdAt)} +
+
+ + {t('columns.updatedAt')}: + + {formatDate(transaction.updatedAt)} +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index bc76455..d364ee7 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -828,7 +828,7 @@ export class StripeProvider implements PaymentProvider { userId, amount: Number.parseInt(credits), type: CREDIT_TRANSACTION_TYPE.PURCHASE, - description: `Credit package purchase: ${packageId} - ${credits} credits for $${amount}`, + description: `+${credits} credits for package ${packageId} (${amount})`, paymentId: session.id, expireDays: creditPackage.expireDays, }); From de1ccca27ba06e0e48a95d78629d102479dff6eb Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 22:00:33 +0800 Subject: [PATCH 52/87] feat: implement consume credits action and get credit balance action, update credits balance references --- messages/zh.json | 2 +- src/actions/consume-credits.ts | 43 +++++++++++++ src/actions/credits.action.ts | 61 ------------------- src/actions/get-credit-balance.ts | 21 +++++++ src/actions/get-credit-transactions.ts | 6 +- .../layout/credits-balance-button.tsx | 4 +- .../layout/credits-balance-menu.tsx | 4 +- .../settings/credits/credit-packages.tsx | 4 +- src/payment/provider/stripe.ts | 2 +- 9 files changed, 76 insertions(+), 71 deletions(-) create mode 100644 src/actions/consume-credits.ts delete mode 100644 src/actions/credits.action.ts create mode 100644 src/actions/get-credit-balance.ts diff --git a/messages/zh.json b/messages/zh.json index b0d2cd9..886a5ea 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -624,7 +624,7 @@ "remainingAmount": "剩余金额", "paymentId": "支付编号", "expirationDate": "过期日期", - "expirationDateProcessedAt": "过期日期处理时间", + "expirationDateProcessedAt": "过期处理时间", "createdAt": "创建时间", "updatedAt": "更新时间" }, diff --git a/src/actions/consume-credits.ts b/src/actions/consume-credits.ts new file mode 100644 index 0000000..b9a7210 --- /dev/null +++ b/src/actions/consume-credits.ts @@ -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', + }; + } + }); diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts deleted file mode 100644 index 6b68cb4..0000000 --- a/src/actions/credits.action.ts +++ /dev/null @@ -1,61 +0,0 @@ -'use server'; - -import { - addMonthlyFreeCredits, - addRegisterGiftCredits, - consumeCredits, - getUserCredits, -} from '@/credits/credits'; -import { getSession } from '@/lib/server'; -import { createSafeActionClient } from 'next-safe-action'; -import { z } from 'zod'; - -const actionClient = createSafeActionClient(); - -// get current user's credits -export const getCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - const credits = await getUserCredits(session.user.id); - return { success: true, credits }; -}); - -// add register gift credits (for testing) -export const addRegisterCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addRegisterGiftCredits(session.user.id); - return { success: true }; -}); - -// add monthly free credits (for testing) -export const addMonthlyCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addMonthlyFreeCredits(session.user.id); - return { success: true }; -}); - -// consume credits (simulate button) -const consumeSchema = z.object({ - amount: z.number().min(1), - description: z.string().optional(), -}); - -export const consumeCreditsAction = actionClient - .schema(consumeSchema) - .action(async ({ parsedInput }) => { - const session = await getSession(); - if (!session) 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 (e) { - return { success: false, error: (e as Error).message }; - } - }); diff --git a/src/actions/get-credit-balance.ts b/src/actions/get-credit-balance.ts new file mode 100644 index 0000000..1e0b6ba --- /dev/null +++ b/src/actions/get-credit-balance.ts @@ -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 }; +}); diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index 65dd504..61d9b82 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -45,11 +45,13 @@ export const getCreditTransactionsAction = actionClient try { const { pageIndex, pageSize, search, sorting } = parsedInput; + // search by type, amount, paymentId, description const where = search ? or( - ilike(creditTransaction.description, `%${search}%`), ilike(creditTransaction.type, `%${search}%`), - ilike(creditTransaction.paymentId, `%${search}%`) + ilike(creditTransaction.amount, `%${search}%`), + ilike(creditTransaction.paymentId, `%${search}%`), + ilike(creditTransaction.description, `%${search}%`) ) : undefined; diff --git a/src/components/layout/credits-balance-button.tsx b/src/components/layout/credits-balance-button.tsx index 03cb9da..07f6763 100644 --- a/src/components/layout/credits-balance-button.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getCreditsAction } from '@/actions/credits.action'; +import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { Button } from '@/components/ui/button'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; @@ -17,7 +17,7 @@ export function CreditsBalanceButton() { useEffect(() => { const fetchCredits = async () => { try { - const result = await getCreditsAction(); + const result = await getCreditBalanceAction(); if (result?.data?.success && result.data.credits !== undefined) { setCredits(result.data.credits); } diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index 160cd37..ad0c4a1 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getCreditsAction } from '@/actions/credits.action'; +import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; import { useTransactionStore } from '@/stores/transaction-store'; @@ -18,7 +18,7 @@ export function CreditsBalanceMenu() { useEffect(() => { const fetchCredits = async () => { try { - const result = await getCreditsAction(); + const result = await getCreditBalanceAction(); if (result?.data?.success && result.data.credits !== undefined) { setCredits(result.data.credits); } diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index e13f733..f7e31c0 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getCreditsAction } from '@/actions/credits.action'; +import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { Badge } from '@/components/ui/badge'; import { Card, @@ -44,7 +44,7 @@ export function CreditPackages() { const fetchCredits = async () => { try { setLoadingCredits(true); - const result = await getCreditsAction(); + const result = await getCreditBalanceAction(); if (result?.data?.success) { console.log('CreditPackages, fetched credits:', result.data.credits); setCredits(result.data.credits || 0); diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index d364ee7..fb4f490 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -828,7 +828,7 @@ export class StripeProvider implements PaymentProvider { userId, amount: Number.parseInt(credits), type: CREDIT_TRANSACTION_TYPE.PURCHASE, - description: `+${credits} credits for package ${packageId} (${amount})`, + description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`, paymentId: session.id, expireDays: creditPackage.expireDays, }); From 59c7c807db3009174301ffb1bdf6f01a47954aa5 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 22:52:26 +0800 Subject: [PATCH 53/87] refactor: rename PURCHASE to PURCHASE_PACKAGE in transaction types --- .../credits/credit-transactions-table.tsx | 30 ++++++++----------- src/credits/credit-detail-viewer.tsx | 25 +++++++--------- src/credits/types.ts | 2 +- src/payment/provider/stripe.ts | 2 +- 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 64e0b31..022a475 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -46,15 +46,17 @@ import { } from '@tanstack/react-table'; import { ArrowUpDownIcon, + BanknoteIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, ClockIcon, + CoinsIcon, + GemIcon, GiftIcon, - MinusCircleIcon, - RefreshCwIcon, + HandCoinsIcon, ShoppingCartIcon, } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -156,19 +158,19 @@ export function CreditTransactionsTable({ const getTransactionTypeIcon = (type: string) => { switch (type) { case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - return ; + return ; case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: - return ; - case CREDIT_TRANSACTION_TYPE.PURCHASE: - return ; + return ; + case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE: + return ; case CREDIT_TRANSACTION_TYPE.USAGE: - return ; + return ; case CREDIT_TRANSACTION_TYPE.EXPIRE: - return ; + return ; case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: - return ; + return ; case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: - return ; + return ; default: return null; } @@ -177,12 +179,6 @@ export function CreditTransactionsTable({ // Get transaction type badge variant const getTransactionTypeBadgeVariant = (type: string) => { switch (type) { - case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: - case CREDIT_TRANSACTION_TYPE.PURCHASE: - case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: - case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: - return 'outline' as const; case CREDIT_TRANSACTION_TYPE.USAGE: case CREDIT_TRANSACTION_TYPE.EXPIRE: return 'destructive' as const; @@ -198,7 +194,7 @@ export function CreditTransactionsTable({ return t('types.MONTHLY_REFRESH'); case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: return t('types.REGISTER_GIFT'); - case CREDIT_TRANSACTION_TYPE.PURCHASE: + case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE: return t('types.PURCHASE'); case CREDIT_TRANSACTION_TYPE.USAGE: return t('types.USAGE'); diff --git a/src/credits/credit-detail-viewer.tsx b/src/credits/credit-detail-viewer.tsx index 4105f9d..7fd36ba 100644 --- a/src/credits/credit-detail-viewer.tsx +++ b/src/credits/credit-detail-viewer.tsx @@ -4,7 +4,6 @@ import { Drawer, DrawerClose, DrawerContent, - DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, @@ -14,10 +13,12 @@ import { Separator } from '@/components/ui/separator'; import { useIsMobile } from '@/hooks/use-mobile'; import { formatDate } from '@/lib/formatter'; import { + BanknoteIcon, ClockIcon, + CoinsIcon, + GemIcon, GiftIcon, - MinusCircleIcon, - RefreshCwIcon, + HandCoinsIcon, ShoppingCartIcon, } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -51,19 +52,19 @@ export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) { const getTransactionTypeIcon = (type: string) => { switch (type) { case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - return ; + return ; case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: return ; - case CREDIT_TRANSACTION_TYPE.PURCHASE: + case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE: return ; case CREDIT_TRANSACTION_TYPE.USAGE: - return ; + return ; case CREDIT_TRANSACTION_TYPE.EXPIRE: return ; case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: - return ; + return ; case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: - return ; + return ; default: return null; } @@ -72,12 +73,6 @@ export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) { // Get transaction type badge variant const getTransactionTypeBadgeVariant = (type: string) => { switch (type) { - case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: - case CREDIT_TRANSACTION_TYPE.PURCHASE: - case CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH: - case CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL: - case CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY: - return 'outline' as const; case CREDIT_TRANSACTION_TYPE.USAGE: case CREDIT_TRANSACTION_TYPE.EXPIRE: return 'destructive' as const; @@ -93,7 +88,7 @@ export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) { return t('types.MONTHLY_REFRESH'); case CREDIT_TRANSACTION_TYPE.REGISTER_GIFT: return t('types.REGISTER_GIFT'); - case CREDIT_TRANSACTION_TYPE.PURCHASE: + case CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE: return t('types.PURCHASE'); case CREDIT_TRANSACTION_TYPE.USAGE: return t('types.USAGE'); diff --git a/src/credits/types.ts b/src/credits/types.ts index 59c3f6a..c79a1ba 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -4,7 +4,7 @@ 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 = 'PURCHASE', // Credits earned by purchase + 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 diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index fb4f490..cd39852 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -827,7 +827,7 @@ export class StripeProvider implements PaymentProvider { await addCredits({ userId, amount: Number.parseInt(credits), - type: CREDIT_TRANSACTION_TYPE.PURCHASE, + type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE, description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`, paymentId: session.id, expireDays: creditPackage.expireDays, From 6cf9d4db9c2528d0a43875c6c23d1bf7ec6aac89 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 22:53:04 +0800 Subject: [PATCH 54/87] refactor: improve credit transaction filtering in processExpiredCredits and consumeCredits functions --- src/credits/credits.ts | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 24c3a5b..35af7f5 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -4,7 +4,7 @@ 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, or } from 'drizzle-orm'; +import { and, asc, desc, eq, gt, isNull, not, or } from 'drizzle-orm'; import { CREDIT_TRANSACTION_TYPE } from './types'; /** @@ -143,7 +143,7 @@ export async function addCredits({ // const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { const newBalance = (current[0]?.currentCredits || 0) + amount; - console.log('update user credit', userId, newBalance); + console.log('addCredits, update user credit', userId, newBalance); await db .update(userCredit) .set({ @@ -154,7 +154,7 @@ export async function addCredits({ .where(eq(userCredit.userId, userId)); } else { const newBalance = amount; - console.log('insert user credit', userId, newBalance); + console.log('addCredits, insert user credit', userId, newBalance); await db.insert(userCredit).values({ id: randomUUID(), userId, @@ -171,11 +171,7 @@ export async function addCredits({ amount, description, paymentId, - // NOTE: there is no expiration date for PURCHASE type - expirationDate: - type === CREDIT_TRANSACTION_TYPE.PURCHASE || expireDays === undefined - ? undefined - : addDays(new Date(), expireDays), + expirationDate: expireDays ? addDays(new Date(), expireDays) : undefined, }); } @@ -216,22 +212,28 @@ export async function consumeCredits({ // Check balance if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) { console.error( - `Insufficient credits for user ${userId}, required: ${amount}` + `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( - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + isNull(creditTransaction.expirationDate), + gt(creditTransaction.expirationDate, now) ) ) ) @@ -282,7 +284,7 @@ export async function consumeCredits({ */ export async function processExpiredCredits(userId: string) { const now = new Date(); - // Get all credit transactions without type EXPIRE + // Get all credit transactions that can expire (have expirationDate and not yet processed) const db = await getDb(); const transactions = await db .select() @@ -290,12 +292,15 @@ export async function processExpiredCredits(userId: string) { .where( and( eq(creditTransaction.userId, userId), - or( - // NOTE: credits with PURCHASE type can not be expired - // eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) - ) + // 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; From 0b6f81aca64ea157cf08386d60c5b8feb597548d Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 10 Jul 2025 23:27:43 +0800 Subject: [PATCH 55/87] refactor: replace useTransactionStore with useCreditTransactionStore in credit-related components --- src/components/layout/credits-balance-button.tsx | 4 ++-- src/components/layout/credits-balance-menu.tsx | 4 ++-- .../settings/credits/credit-packages.tsx | 4 ++-- .../settings/credits/credit-transactions-page.tsx | 4 ++-- src/stores/transaction-store.ts | 15 +++++++++------ 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/layout/credits-balance-button.tsx b/src/components/layout/credits-balance-button.tsx index 07f6763..04650c4 100644 --- a/src/components/layout/credits-balance-button.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -4,13 +4,13 @@ import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { Button } from '@/components/ui/button'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; -import { useTransactionStore } from '@/stores/transaction-store'; +import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; import { useEffect, useState } from 'react'; export function CreditsBalanceButton() { const router = useLocaleRouter(); - const { refreshTrigger } = useTransactionStore(); + const { refreshTrigger } = useCreditTransactionStore(); const [credits, setCredits] = useState(0); const [loading, setLoading] = useState(true); diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index ad0c4a1..8e559c0 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -3,7 +3,7 @@ import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; -import { useTransactionStore } from '@/stores/transaction-store'; +import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; @@ -11,7 +11,7 @@ import { useEffect, useState } from 'react'; export function CreditsBalanceMenu() { const t = useTranslations('Marketing.avatar'); const router = useLocaleRouter(); - const { refreshTrigger } = useTransactionStore(); + const { refreshTrigger } = useCreditTransactionStore(); const [credits, setCredits] = useState(0); const [loading, setLoading] = useState(true); diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index f7e31c0..7126b93 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -15,7 +15,7 @@ import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { Routes } from '@/routes'; -import { useTransactionStore } from '@/stores/transaction-store'; +import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; @@ -31,7 +31,7 @@ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); const [loadingCredits, setLoadingCredits] = useState(true); const [credits, setCredits] = useState(null); - const { refreshTrigger, triggerRefresh } = useTransactionStore(); + const { refreshTrigger, triggerRefresh } = useCreditTransactionStore(); const currentUser = useCurrentUser(); const searchParams = useSearchParams(); const localeRouter = useLocaleRouter(); diff --git a/src/components/settings/credits/credit-transactions-page.tsx b/src/components/settings/credits/credit-transactions-page.tsx index d0a2da7..e6e786a 100644 --- a/src/components/settings/credits/credit-transactions-page.tsx +++ b/src/components/settings/credits/credit-transactions-page.tsx @@ -3,7 +3,7 @@ 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 { useTransactionStore } from '@/stores/transaction-store'; +import { useCreditTransactionStore } from '@/stores/transaction-store'; import type { SortingState } from '@tanstack/react-table'; import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; @@ -20,7 +20,7 @@ export function CreditTransactionsPageClient() { { id: 'createdAt', desc: true }, ]); const [loading, setLoading] = useState(false); - const { refreshTrigger } = useTransactionStore(); + const { refreshTrigger } = useCreditTransactionStore(); const fetchData = async () => { setLoading(true); diff --git a/src/stores/transaction-store.ts b/src/stores/transaction-store.ts index 47107da..311b6d8 100644 --- a/src/stores/transaction-store.ts +++ b/src/stores/transaction-store.ts @@ -1,11 +1,14 @@ -import { create } from "zustand"; +import { create } from 'zustand'; -interface TransactionStore { +interface CreditTransactionStore { refreshTrigger: number; triggerRefresh: () => void; } -export const useTransactionStore = create((set) => ({ - refreshTrigger: 0, - triggerRefresh: () => set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })), -})); +export const useCreditTransactionStore = create( + (set) => ({ + refreshTrigger: 0, + triggerRefresh: () => + set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })), + }) +); From 5cb8b0048d0953f88e5fd35ba3eb56ecaf63d6f2 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 00:19:20 +0800 Subject: [PATCH 56/87] feat: add CreditsProvider and credits store for managing user credits --- src/app/[locale]/providers.tsx | 6 +- src/providers/credits-provider.tsx | 28 ++++ src/stores/credits-store.ts | 212 +++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/providers/credits-provider.tsx create mode 100644 src/stores/credits-store.ts diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx index 82192f0..40f290e 100644 --- a/src/app/[locale]/providers.tsx +++ b/src/app/[locale]/providers.tsx @@ -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) { - {children} + + {children} + diff --git a/src/providers/credits-provider.tsx b/src/providers/credits-provider.tsx new file mode 100644 index 0000000..3ac8ca5 --- /dev/null +++ b/src/providers/credits-provider.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useCreditsStore } from '@/stores/credits-store'; +import { useEffect } from 'react'; + +/** + * Credits Provider Component + * + * This component initializes the credits store when the user is authenticated + * and handles cleanup when the user logs out. + */ +export function CreditsProvider({ children }: { children: React.ReactNode }) { + const user = useCurrentUser(); + const { fetchCredits, resetState } = useCreditsStore(); + + useEffect(() => { + if (user) { + // User is logged in, fetch their credits + fetchCredits(user); + } else { + // User is logged out, reset the credits state + resetState(); + } + }, [user, fetchCredits, resetState]); + + return <>{children}; +} diff --git a/src/stores/credits-store.ts b/src/stores/credits-store.ts new file mode 100644 index 0000000..7c39180 --- /dev/null +++ b/src/stores/credits-store.ts @@ -0,0 +1,212 @@ +import { consumeCreditsAction } from '@/actions/consume-credits'; +import { getCreditBalanceAction } from '@/actions/get-credit-balance'; +import type { Session } from '@/lib/auth-types'; +import { create } from 'zustand'; + +/** + * Credits state interface + */ +export interface CreditsState { + // Current credit balance + balance: number; + // Loading state + isLoading: boolean; + // Error state + error: string | null; + // Last fetch timestamp to avoid frequent requests + lastFetchTime: number | null; + + // Actions + fetchCredits: (user: Session['user'] | null | undefined) => Promise; + consumeCredits: (amount: number, description: string) => Promise; + refreshCredits: (user: Session['user'] | null | undefined) => Promise; + resetState: () => void; + // For optimistic updates + updateBalanceOptimistically: (amount: number) => void; +} + +// Cache duration: 30 seconds +const CACHE_DURATION = 30 * 1000; + +/** + * Credits store using Zustand + * Manages the user's credit balance globally with caching and optimistic updates + */ +export const useCreditsStore = create((set, get) => ({ + // Initial state + balance: 0, + isLoading: false, + error: null, + lastFetchTime: null, + + /** + * Fetch credit balance for the current user with caching + * @param user Current user from auth session + */ + fetchCredits: async (user) => { + // Skip if already loading + if (get().isLoading) return; + + // Skip if no user is provided + if (!user) { + set({ + balance: 0, + error: null, + lastFetchTime: null, + }); + return; + } + + // Check if we have recent data (within cache duration) + const { lastFetchTime } = get(); + const now = Date.now(); + if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) { + return; // Use cached data + } + + set({ isLoading: true, error: null }); + + try { + const result = await getCreditBalanceAction(); + + if (result?.data?.success) { + set({ + balance: result.data.credits || 0, + isLoading: false, + error: null, + lastFetchTime: now, + }); + } else { + set({ + error: result?.data?.error || 'Failed to fetch credit balance', + isLoading: false, + }); + } + } catch (error) { + console.error('fetch credits error:', error); + set({ + error: 'Failed to fetch credit balance', + isLoading: false, + }); + } + }, + + /** + * Consume credits with optimistic updates + * @param amount Amount of credits to consume + * @param description Description for the transaction + * @returns Promise Success status + */ + consumeCredits: async (amount: number, description: string) => { + const { balance } = get(); + + // Check if we have enough credits + if (balance < amount) { + set({ + error: `Insufficient credits. You need ${amount} credits but only have ${balance}.`, + }); + return false; + } + + // Optimistically update the balance + set({ + balance: balance - amount, + error: null, + isLoading: true, + }); + + try { + const result = await consumeCreditsAction({ + amount, + description, + }); + + if (result?.data?.success) { + set({ + isLoading: false, + error: null, + }); + return true; + } + + // Revert optimistic update on failure + set({ + balance: balance, // Revert to original balance + error: result?.data?.error || 'Failed to consume credits', + isLoading: false, + }); + return false; + } catch (error) { + console.error('consume credits error:', error); + // Revert optimistic update on error + set({ + balance: balance, // Revert to original balance + error: 'Failed to consume credits', + isLoading: false, + }); + return false; + } + }, + + /** + * Force refresh credit balance (ignores cache) + * @param user Current user from auth session + */ + refreshCredits: async (user) => { + if (!user) return; + + set({ + isLoading: true, + error: null, + lastFetchTime: null, // Clear cache to force refresh + }); + + try { + const result = await getCreditBalanceAction(); + + if (result?.data?.success) { + set({ + balance: result.data.credits || 0, + isLoading: false, + error: null, + lastFetchTime: Date.now(), + }); + } else { + set({ + error: result?.data?.error || 'Failed to fetch credit balance', + isLoading: false, + }); + } + } catch (error) { + console.error('refresh credits error:', error); + set({ + error: 'Failed to fetch credit balance', + isLoading: false, + }); + } + }, + + /** + * Update balance optimistically (for external credit additions) + * @param amount Amount to add to current balance + */ + updateBalanceOptimistically: (amount: number) => { + const { balance } = get(); + set({ + balance: balance + amount, + lastFetchTime: null, // Clear cache to fetch fresh data next time + }); + }, + + /** + * Reset credits state + */ + resetState: () => { + set({ + balance: 0, + isLoading: false, + error: null, + lastFetchTime: null, + }); + }, +})); From e6663b013daa7725834696a6eb1534acff24db2f Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 01:10:43 +0800 Subject: [PATCH 57/87] refactor: replace getCreditBalanceAction with useCredits hook in credits-related components --- .../layout/credits-balance-button.tsx | 33 ++++------ .../layout/credits-balance-menu.tsx | 33 ++++------ .../settings/credits/credit-packages.tsx | 51 ++++++--------- src/hooks/use-credits.ts | 62 +++++++++++++++++++ 4 files changed, 105 insertions(+), 74 deletions(-) create mode 100644 src/hooks/use-credits.ts diff --git a/src/components/layout/credits-balance-button.tsx b/src/components/layout/credits-balance-button.tsx index 04650c4..1613c0e 100644 --- a/src/components/layout/credits-balance-button.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -1,35 +1,26 @@ 'use client'; -import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { Button } from '@/components/ui/button'; +import { useCredits } from '@/hooks/use-credits'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; export function CreditsBalanceButton() { const router = useLocaleRouter(); const { refreshTrigger } = useCreditTransactionStore(); - const [credits, setCredits] = useState(0); - const [loading, setLoading] = useState(true); + + // Use the new useCredits hook + const { balance, isLoading, refresh } = useCredits(); useEffect(() => { - const fetchCredits = async () => { - try { - const result = await getCreditBalanceAction(); - if (result?.data?.success && result.data.credits !== undefined) { - setCredits(result.data.credits); - } - } catch (error) { - console.error('CreditsBalanceButton, fetch credits error:', error); - } finally { - setLoading(false); - } - }; - - fetchCredits(); - }, [refreshTrigger]); + // Refresh credits when transaction refresh is triggered + if (refreshTrigger) { + refresh(); + } + }, [refreshTrigger, refresh]); const handleClick = () => { router.push(Routes.SettingsCredits); @@ -44,10 +35,10 @@ export function CreditsBalanceButton() { > - {loading ? ( + {isLoading ? ( ) : ( - credits.toLocaleString() + balance.toLocaleString() )} diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index 8e559c0..286ff4d 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -1,36 +1,27 @@ 'use client'; -import { getCreditBalanceAction } from '@/actions/get-credit-balance'; +import { useCredits } from '@/hooks/use-credits'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; export function CreditsBalanceMenu() { const t = useTranslations('Marketing.avatar'); const router = useLocaleRouter(); const { refreshTrigger } = useCreditTransactionStore(); - const [credits, setCredits] = useState(0); - const [loading, setLoading] = useState(true); + + // Use the new useCredits hook + const { balance, isLoading, refresh } = useCredits(); useEffect(() => { - const fetchCredits = async () => { - try { - const result = await getCreditBalanceAction(); - if (result?.data?.success && result.data.credits !== undefined) { - setCredits(result.data.credits); - } - } catch (error) { - console.error('CreditsBalanceMenu, fetch credits error:', error); - } finally { - setLoading(false); - } - }; - - fetchCredits(); - }, [refreshTrigger]); + // Refresh credits when transaction refresh is triggered + if (refreshTrigger) { + refresh(); + } + }, [refreshTrigger, refresh]); const handleClick = () => { router.push(Routes.SettingsCredits); @@ -47,10 +38,10 @@ export function CreditsBalanceMenu() {

- {loading ? ( + {isLoading ? ( ) : ( - credits.toLocaleString() + balance.toLocaleString() )}

diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 7126b93..d9e899c 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -1,6 +1,5 @@ 'use client'; -import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { Badge } from '@/components/ui/badge'; import { Card, @@ -10,6 +9,7 @@ import { CardTitle, } from '@/components/ui/card'; import { getCreditPackages } from '@/config/credits-config'; +import { useCredits } from '@/hooks/use-credits'; import { useCurrentUser } from '@/hooks/use-current-user'; import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; @@ -19,7 +19,7 @@ import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { toast } from 'sonner'; import { CreditCheckoutButton } from './credit-checkout-button'; @@ -29,38 +29,21 @@ import { CreditCheckoutButton } from './credit-checkout-button'; */ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); - const [loadingCredits, setLoadingCredits] = useState(true); - const [credits, setCredits] = useState(null); - const { refreshTrigger, triggerRefresh } = useCreditTransactionStore(); - const currentUser = useCurrentUser(); const searchParams = useSearchParams(); const localeRouter = useLocaleRouter(); + // Use the new useCredits hook + const { balance, isLoading, refresh } = useCredits(); + const { refreshTrigger, triggerRefresh } = useCreditTransactionStore(); + + // Get current user + const currentUser = useCurrentUser(); + // show only enabled packages const creditPackages = Object.values(getCreditPackages()).filter( (pkg) => !pkg.disabled && pkg.price.priceId ); - const fetchCredits = async () => { - try { - setLoadingCredits(true); - const result = await getCreditBalanceAction(); - if (result?.data?.success) { - console.log('CreditPackages, fetched credits:', result.data.credits); - setCredits(result.data.credits || 0); - } else { - const errorMessage = result?.data?.error || t('failedToFetchCredits'); - console.error('CreditPackages, failed to fetch credits:', errorMessage); - toast.error(errorMessage); - } - } catch (error) { - console.error('CreditPackages, failed to fetch credits:', error); - toast.error(t('failedToFetchCredits')); - } finally { - setLoadingCredits(false); - } - }; - // Check for payment success and show success message useEffect(() => { const sessionId = searchParams.get('session_id'); @@ -72,6 +55,8 @@ export function CreditPackages() { // Refresh credits data to show updated balance triggerRefresh(); + // Also refresh the credits store + refresh(); // Clean up URL parameters const url = new URL(window.location.href); @@ -79,12 +64,14 @@ export function CreditPackages() { // Use Routes.SettingsCredits + url.search to properly handle locale routing localeRouter.replace(Routes.SettingsCredits + url.search); } - }, [searchParams, localeRouter]); + }, [searchParams, localeRouter, refresh, triggerRefresh, t]); - // Initial fetch and listen for transaction updates + // Listen for transaction updates and refresh credits useEffect(() => { - fetchCredits(); - }, [refreshTrigger]); + if (refreshTrigger) { + refresh(); + } + }, [refreshTrigger, refresh]); return (
@@ -99,11 +86,11 @@ export function CreditPackages() {
{/* */}
- {loadingCredits ? ( + {isLoading ? ( ) : (
- {credits?.toLocaleString() || 0} + {balance.toLocaleString()}
)}
diff --git a/src/hooks/use-credits.ts b/src/hooks/use-credits.ts new file mode 100644 index 0000000..6cad0ef --- /dev/null +++ b/src/hooks/use-credits.ts @@ -0,0 +1,62 @@ +import { authClient } from '@/lib/auth-client'; +import { useCreditsStore } from '@/stores/credits-store'; +import { useEffect } from 'react'; + +/** + * Hook for accessing and managing credits state + * + * This hook provides access to the credits state and methods to manage it. + * It also automatically fetches credits information when the user changes. + */ +export function useCredits() { + const { + balance, + isLoading, + error, + fetchCredits, + consumeCredits, + refreshCredits, + updateBalanceOptimistically, + } = useCreditsStore(); + + const { data: session } = authClient.useSession(); + + useEffect(() => { + const currentUser = session?.user; + // Fetch credits data whenever the user session changes + if (currentUser) { + console.log('fetching credits info for user', currentUser.id); + fetchCredits(currentUser); + } + }, [session, fetchCredits]); + + return { + // State + balance, + isLoading, + error, + + // Methods + consumeCredits, + updateBalanceOptimistically, + + // Utility methods + refetch: () => { + const currentUser = session?.user; + if (currentUser) { + console.log('refetching credits info for user', currentUser.id); + fetchCredits(currentUser); + } + }, + refresh: () => { + const currentUser = session?.user; + if (currentUser) { + console.log('refreshing credits info for user', currentUser.id); + refreshCredits(currentUser); + } + }, + + // Helper methods + hasEnoughCredits: (amount: number) => balance >= amount, + }; +} From 9d4fcbe36dcf4e2557d8a2779ec1e87f0c136bdc Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 01:35:31 +0800 Subject: [PATCH 58/87] refactor: remove useCreditTransactionStore and related logic from credits components, streamline useCredits integration --- .../layout/credits-balance-button.tsx | 12 +----- .../layout/credits-balance-menu.tsx | 12 +----- .../settings/credits/credit-packages.tsx | 13 +------ .../credits/credit-transactions-page.tsx | 10 ++--- src/hooks/use-credits.ts | 38 +++++++++++-------- src/stores/transaction-store.ts | 14 ------- 6 files changed, 29 insertions(+), 70 deletions(-) delete mode 100644 src/stores/transaction-store.ts diff --git a/src/components/layout/credits-balance-button.tsx b/src/components/layout/credits-balance-button.tsx index 1613c0e..1077b85 100644 --- a/src/components/layout/credits-balance-button.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -4,23 +4,13 @@ import { Button } from '@/components/ui/button'; import { useCredits } from '@/hooks/use-credits'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; -import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; -import { useEffect } from 'react'; export function CreditsBalanceButton() { const router = useLocaleRouter(); - const { refreshTrigger } = useCreditTransactionStore(); // Use the new useCredits hook - const { balance, isLoading, refresh } = useCredits(); - - useEffect(() => { - // Refresh credits when transaction refresh is triggered - if (refreshTrigger) { - refresh(); - } - }, [refreshTrigger, refresh]); + const { balance, isLoading } = useCredits(); const handleClick = () => { router.push(Routes.SettingsCredits); diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index 286ff4d..1b386ea 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -3,25 +3,15 @@ import { useCredits } from '@/hooks/use-credits'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; -import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect } from 'react'; export function CreditsBalanceMenu() { const t = useTranslations('Marketing.avatar'); const router = useLocaleRouter(); - const { refreshTrigger } = useCreditTransactionStore(); // Use the new useCredits hook - const { balance, isLoading, refresh } = useCredits(); - - useEffect(() => { - // Refresh credits when transaction refresh is triggered - if (refreshTrigger) { - refresh(); - } - }, [refreshTrigger, refresh]); + const { balance, isLoading } = useCredits(); const handleClick = () => { router.push(Routes.SettingsCredits); diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index d9e899c..527b23a 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -15,7 +15,6 @@ import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { Routes } from '@/routes'; -import { useCreditTransactionStore } from '@/stores/transaction-store'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; @@ -34,7 +33,6 @@ export function CreditPackages() { // Use the new useCredits hook const { balance, isLoading, refresh } = useCredits(); - const { refreshTrigger, triggerRefresh } = useCreditTransactionStore(); // Get current user const currentUser = useCurrentUser(); @@ -54,8 +52,6 @@ export function CreditPackages() { }, 0); // Refresh credits data to show updated balance - triggerRefresh(); - // Also refresh the credits store refresh(); // Clean up URL parameters @@ -64,14 +60,7 @@ export function CreditPackages() { // Use Routes.SettingsCredits + url.search to properly handle locale routing localeRouter.replace(Routes.SettingsCredits + url.search); } - }, [searchParams, localeRouter, refresh, triggerRefresh, t]); - - // Listen for transaction updates and refresh credits - useEffect(() => { - if (refreshTrigger) { - refresh(); - } - }, [refreshTrigger, refresh]); + }, [searchParams, localeRouter, refresh]); return (
diff --git a/src/components/settings/credits/credit-transactions-page.tsx b/src/components/settings/credits/credit-transactions-page.tsx index e6e786a..c548913 100644 --- a/src/components/settings/credits/credit-transactions-page.tsx +++ b/src/components/settings/credits/credit-transactions-page.tsx @@ -3,10 +3,9 @@ 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 { useCreditTransactionStore } from '@/stores/transaction-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 CreditTransactionsPageClient() { @@ -20,9 +19,8 @@ export function CreditTransactionsPageClient() { { id: 'createdAt', desc: true }, ]); const [loading, setLoading] = useState(false); - const { refreshTrigger } = useCreditTransactionStore(); - const fetchData = async () => { + const fetchData = useCallback(async () => { setLoading(true); try { const result = await getCreditTransactionsAction({ @@ -52,11 +50,11 @@ export function CreditTransactionsPageClient() { } finally { setLoading(false); } - }; + }, [pageIndex, pageSize, search, sorting]); useEffect(() => { fetchData(); - }, [pageIndex, pageSize, search, sorting, refreshTrigger]); + }, [fetchData]); return ( { + const currentUser = session?.user; + if (currentUser) { + console.log('refetching credits info for user', currentUser.id); + fetchCredits(currentUser); + } + }, [session?.user, fetchCredits]); + + // Stable refresh function using useCallback + const refresh = useCallback(() => { + const currentUser = session?.user; + if (currentUser) { + console.log('refreshing credits info for user', currentUser.id); + refreshCredits(currentUser); + } + }, [session?.user, refreshCredits]); + useEffect(() => { const currentUser = session?.user; // Fetch credits data whenever the user session changes @@ -28,7 +46,7 @@ export function useCredits() { console.log('fetching credits info for user', currentUser.id); fetchCredits(currentUser); } - }, [session, fetchCredits]); + }, [session?.user, fetchCredits]); return { // State @@ -41,20 +59,8 @@ export function useCredits() { updateBalanceOptimistically, // Utility methods - refetch: () => { - const currentUser = session?.user; - if (currentUser) { - console.log('refetching credits info for user', currentUser.id); - fetchCredits(currentUser); - } - }, - refresh: () => { - const currentUser = session?.user; - if (currentUser) { - console.log('refreshing credits info for user', currentUser.id); - refreshCredits(currentUser); - } - }, + refetch, + refresh, // Helper methods hasEnoughCredits: (amount: number) => balance >= amount, diff --git a/src/stores/transaction-store.ts b/src/stores/transaction-store.ts deleted file mode 100644 index 311b6d8..0000000 --- a/src/stores/transaction-store.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { create } from 'zustand'; - -interface CreditTransactionStore { - refreshTrigger: number; - triggerRefresh: () => void; -} - -export const useCreditTransactionStore = create( - (set) => ({ - refreshTrigger: 0, - triggerRefresh: () => - set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })), - }) -); From 9711d13804c778c05907475964fba5ec95cb8a27 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 01:38:54 +0800 Subject: [PATCH 59/87] refactor: optimize user fetching logic in UsersPageClient component by using useCallback for fetchUsers function --- src/components/admin/users-page.tsx | 56 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/admin/users-page.tsx b/src/components/admin/users-page.tsx index 6704d5f..23fb7d3 100644 --- a/src/components/admin/users-page.tsx +++ b/src/components/admin/users-page.tsx @@ -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() { @@ -22,39 +22,39 @@ export function UsersPageClient() { ]); 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 ( <> Date: Fri, 11 Jul 2025 22:10:04 +0800 Subject: [PATCH 60/87] feat: enhance sorting functionality in tables by implementing dropdown menus --- messages/en.json | 4 +- messages/zh.json | 4 +- src/components/admin/users-table.tsx | 49 ++++++++++++--- .../credits/credit-transactions-table.tsx | 63 ++++++++++++++++--- 4 files changed, 100 insertions(+), 20 deletions(-) diff --git a/messages/en.json b/messages/en.json index f7b9c34..3e22e9c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -39,7 +39,9 @@ "firstPage": "First Page", "lastPage": "Last Page", "nextPage": "Next Page", - "previousPage": "Previous Page" + "previousPage": "Previous Page", + "ascending": "Asc", + "descending": "Desc" } }, "PricingPage": { diff --git a/messages/zh.json b/messages/zh.json index 886a5ea..3b85703 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -39,7 +39,9 @@ "firstPage": "第一页", "lastPage": "最后一页", "nextPage": "下一页", - "previousPage": "上一页" + "previousPage": "上一页", + "ascending": "升序", + "descending": "降序" } }, "PricingPage": { diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index 3423be9..581659b 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -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({ title, className, }: DataTableColumnHeaderProps) { + const tTable = useTranslations('Common.table'); if (!column.getCanSort()) { return
{title}
; } + const isSorted = column.getIsSorted(); // 'asc' | 'desc' | false + return (
- + + + + + + { + if (value === 'asc') column.toggleSorting(false); + else if (value === 'desc') column.toggleSorting(true); + }} + > + + + {tTable('ascending')} + + + + + {tTable('descending')} + + + + +
); } @@ -117,7 +146,7 @@ export function UsersTable({ const t = useTranslations('Dashboard.admin.users'); const tTable = useTranslations('Common.table'); const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true } + { id: 'createdAt', desc: true }, ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 022a475..d9a3d89 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -5,6 +5,8 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; @@ -32,6 +34,12 @@ import { import { CreditDetailViewer } from '@/credits/credit-detail-viewer'; 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, @@ -45,13 +53,17 @@ import { useReactTable, } from '@tanstack/react-table'; import { + ArrowDownIcon, ArrowUpDownIcon, + ArrowUpIcon, BanknoteIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, + ChevronUpIcon, ChevronsLeftIcon, ChevronsRightIcon, + ChevronsUpDownIcon, ClockIcon, CoinsIcon, GemIcon, @@ -91,16 +103,51 @@ function DataTableColumnHeader({ title, className, }: DataTableColumnHeaderProps) { + const tTable = useTranslations('Common.table'); + + // Only show dropdown for sortable columns + if (!column.getCanSort()) { + return
{title}
; + } + + // Determine current sort state + const isSorted = column.getIsSorted(); // 'asc' | 'desc' | false + return (
- + + + + + + { + if (value === 'asc') column.toggleSorting(false); + else if (value === 'desc') column.toggleSorting(true); + }} + > + + + {tTable('ascending')} + + + + + {tTable('descending')} + + + + +
); } From f45bcad1109a23a3da9d025ba0efa49d850fd431 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 22:24:02 +0800 Subject: [PATCH 61/87] feat: update UsersTable component to enhance column sizing and loading state handling --- src/components/admin/users-table.tsx | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index 581659b..7fd0bc3 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -177,6 +177,8 @@ export function UsersTable({ const user = row.original; return ; }, + minSize: 120, + size: 140, }, { accessorKey: 'email', @@ -205,6 +207,8 @@ export function UsersTable({
); }, + minSize: 180, + size: 200, }, { accessorKey: 'role', @@ -225,6 +229,8 @@ export function UsersTable({
); }, + minSize: 100, + size: 120, }, { accessorKey: 'createdAt', @@ -239,6 +245,8 @@ export function UsersTable({
); }, + minSize: 140, + size: 160, }, { accessorKey: 'customerId', @@ -267,6 +275,8 @@ export function UsersTable({
); }, + minSize: 120, + size: 140, }, { accessorKey: 'banned', @@ -288,6 +298,8 @@ export function UsersTable({
); }, + minSize: 100, + size: 120, }, { accessorKey: 'banReason', @@ -302,6 +314,8 @@ export function UsersTable({
); }, + minSize: 120, + size: 140, }, { accessorKey: 'banExpires', @@ -319,6 +333,8 @@ export function UsersTable({
); }, + minSize: 140, + size: 160, }, ]; @@ -427,16 +443,7 @@ export function UsersTable({ ))} - {loading ? ( - - - {tTable('loading')} - - - ) : table.getRowModel().rows?.length ? ( + {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {tTable('noResults')} + {loading ? tTable('loading') : tTable('noResults')} )} From 788fbe2f18f0848c02c1c6856ba312e9138a9481 Mon Sep 17 00:00:00 2001 From: javayhu Date: Tue, 24 Jun 2025 22:53:40 +0800 Subject: [PATCH 62/87] chore: inngest explore --- src/app/api/inngest/route.ts | 10 ++++++++++ src/inngest/client.ts | 4 ++++ src/inngest/functions.ts | 10 ++++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/app/api/inngest/route.ts create mode 100644 src/inngest/client.ts create mode 100644 src/inngest/functions.ts diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts new file mode 100644 index 0000000..db915a9 --- /dev/null +++ b/src/app/api/inngest/route.ts @@ -0,0 +1,10 @@ +import { serve } from 'inngest/next'; +import { inngest } from '../../../inngest/client'; +import { helloWorld } from '../../../inngest/functions'; + +export const { GET, POST, PUT } = serve({ + client: inngest, + functions: [ + helloWorld, // <-- This is where you'll always add all your functions + ], +}); diff --git a/src/inngest/client.ts b/src/inngest/client.ts new file mode 100644 index 0000000..904a97b --- /dev/null +++ b/src/inngest/client.ts @@ -0,0 +1,4 @@ +import { Inngest } from 'inngest'; + +// Create a client to send and receive events +export const inngest = new Inngest({ id: 'my-app' }); diff --git a/src/inngest/functions.ts b/src/inngest/functions.ts new file mode 100644 index 0000000..c8fb847 --- /dev/null +++ b/src/inngest/functions.ts @@ -0,0 +1,10 @@ +import { inngest } from './client'; + +export const helloWorld = inngest.createFunction( + { id: 'hello-world' }, + { event: 'test/hello.world' }, + async ({ event, step }) => { + await step.sleep('wait-a-moment', '1s'); + return { message: `Hello ${event.data.email}!` }; + } +); From bda2571a78fd1acf66f497c0057ca19b7e258e24 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 25 Jun 2025 00:29:28 +0800 Subject: [PATCH 63/87] chore: invoke function from code in inngest --- src/app/api/hello/route.ts | 20 ++++++++++++++++++++ src/inngest/functions.ts | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 src/app/api/hello/route.ts diff --git a/src/app/api/hello/route.ts b/src/app/api/hello/route.ts new file mode 100644 index 0000000..557479c --- /dev/null +++ b/src/app/api/hello/route.ts @@ -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!' }); +} diff --git a/src/inngest/functions.ts b/src/inngest/functions.ts index c8fb847..8b61b95 100644 --- a/src/inngest/functions.ts +++ b/src/inngest/functions.ts @@ -4,7 +4,9 @@ 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}!` }; } ); From 997c362ac9cb573392a3c1bfaf11e5a69bbc80ac Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 11 Jul 2025 23:53:46 +0800 Subject: [PATCH 64/87] chore: add inngest package and update client initialization --- package.json | 1 + pnpm-lock.yaml | 1600 +++++++++++++++++++++++++++++++++- src/app/api/inngest/route.ts | 9 +- src/inngest/client.ts | 8 +- 4 files changed, 1599 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index d0fe6b3..7aa5760 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb6cf86..e60a6d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: fumadocs-ui: specifier: ^15.5.3 version: 15.5.3(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@4.0.14) + inngest: + specifier: ^3.40.1 + version: 3.40.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.8.3) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -622,6 +625,9 @@ packages: cpu: [x64] os: [win32] + '@bufbuild/protobuf@2.6.0': + resolution: {integrity: sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1435,6 +1441,15 @@ packages: '@formatjs/intl-localematcher@0.6.1': resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@grpc/grpc-js@1.13.4': + resolution: {integrity: sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} @@ -1560,10 +1575,16 @@ packages: cpu: [x64] os: [win32] + '@inngest/ai@0.1.5': + resolution: {integrity: sha512-Nj+Ee/O3EYmPIw+2eGryRh+b2TcqaZyL52RaO1/Cz707R/HrJVVDx8uRQo93gSeN4lMlaOluNrdleyM5M5wcQA==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jpwilliams/waitgroup@2.1.1': + resolution: {integrity: sha512-0CxRhNfkvFCTLZBKGvKxY2FYtYW1yWhO2McLqBL0X5UWvYjIf9suH8anKW/DNutl369A75Ewyoh2iJMwBZ2tRg==} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1582,6 +1603,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} @@ -1748,10 +1772,458 @@ packages: '@openpanel/web@1.0.1': resolution: {integrity: sha512-cVZ7Kr9SicczJ/RDIfEtZs8+1iGDzwkabVA/j3NqSl8VSucsC8m1+LVbjmCDzCJNnK4yVn6tEcc9PJRi2rtllw==} + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/auto-instrumentations-node@0.56.1': + resolution: {integrity: sha512-4cK0+unfkXRRbQQg2r9K3ki8JlE0j9Iw8+4DZEkChShAnmviiE+/JMgHGvK+VVcLrSlgV6BBHv4+ZTLukQwhkA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2': + resolution: {integrity: sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.57.2': + resolution: {integrity: sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2': + resolution: {integrity: sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2': + resolution: {integrity: sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2': + resolution: {integrity: sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2': + resolution: {integrity: sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.57.2': + resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2': + resolution: {integrity: sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.57.2': + resolution: {integrity: sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2': + resolution: {integrity: sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@1.30.1': + resolution: {integrity: sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.46.1': + resolution: {integrity: sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.50.3': + resolution: {integrity: sha512-kotm/mRvSWUauudxcylc5YCDei+G/r+jnOH6q5S99aPLQ/Ms8D2yonMIxEJUILIPlthEmwLYxkw3ualWzMjm/A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.49.1': + resolution: {integrity: sha512-Vbj4BYeV/1K4Pbbfk+gQ8gwYL0w+tBeUwG88cOxnF7CLPO1XnskGV8Q3Gzut2Ah/6Dg17dBtlzEqL3UiFP2Z6A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.45.1': + resolution: {integrity: sha512-T9POV9ccS41UjpsjLrJ4i0m8LfplBiN3dMeH9XZ2btiDrjoaWtDrst6tNb1avetBjkeshOuBp1EWKP22EVSr0g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.45.1': + resolution: {integrity: sha512-RqnP0rK2hcKK1AKcmYvedLiL6G5TvFGiSUt2vI9wN0cCBdTt9Y9+wxxY19KoGxq7e9T/aHow6P5SUhCVI1sHvQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.43.1': + resolution: {integrity: sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.14.1': + resolution: {integrity: sha512-ybO+tmH85pDO0ywTskmrMtZcccKyQr7Eb7wHy1keR2HFfx46SzZbjHo1AuGAX//Hook3gjM7+w211gJ2bwKe1Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.16.1': + resolution: {integrity: sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.43.1': + resolution: {integrity: sha512-e/tMZYU1nc+k+J3259CQtqVZIPsPRSLNoAQbGEmSKrjLEY/KJSbpBZ17lu4dFVBzqoF1cZYIZxn9WPQxy4V9ng==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.47.1': + resolution: {integrity: sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.44.2': + resolution: {integrity: sha512-arSp97Y4D2NWogoXRb8CzFK3W2ooVdvqRRtQDljFt9uC3zI6OuShgey6CVFC0JxT1iGjkAr1r4PDz23mWrFULQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.19.1': + resolution: {integrity: sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.43.1': + resolution: {integrity: sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.47.1': + resolution: {integrity: sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.57.2': + resolution: {integrity: sha512-TR6YQA67cLSZzdxbf2SrbADJy2Y8eBW1+9mF15P0VK2MYcpdoUSmQTF1oMkBwa3B9NwqDFA2fq7wYTTutFQqaQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.45.2': + resolution: {integrity: sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.57.2': + resolution: {integrity: sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.47.1': + resolution: {integrity: sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.7.1': + resolution: {integrity: sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.44.1': + resolution: {integrity: sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.47.1': + resolution: {integrity: sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.44.1': + resolution: {integrity: sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.43.1': + resolution: {integrity: sha512-rK5YWC22gmsLp2aEbaPk5F+9r6BFFZuc9GTnW/ErrWpz2XNHUgeFInoPDg4t+Trs8OttIfn8XwkfFkSKqhxanw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.52.0': + resolution: {integrity: sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.46.1': + resolution: {integrity: sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.45.2': + resolution: {integrity: sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.45.1': + resolution: {integrity: sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.44.1': + resolution: {integrity: sha512-4TXaqJK27QXoMqrt4+hcQ6rKFd8B6V4JfrTJKnqBmWR1cbaqd/uwyl9yxhNH1JEkyo8GaBfdpBC4ZE4FuUhPmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.43.1': + resolution: {integrity: sha512-TaMqP6tVx9/SxlY81dHlSyP5bWJIKq+K7vKfk4naB/LX4LBePPY3++1s0edpzH+RfwN+tEGVW9zTb9ci0up/lQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.51.1': + resolution: {integrity: sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.46.1': + resolution: {integrity: sha512-HB8gD/9CNAKlTV+mdZehnFC4tLUtQ7e+729oGq88e4WipxzZxmMYuRwZ2vzOA9/APtq+MRkERJ9PcoDqSIjZ+g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis-4@0.46.1': + resolution: {integrity: sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.46.1': + resolution: {integrity: sha512-AN7OvlGlXmlvsgbLHs6dS1bggp6Fcki+GxgYZdSrb/DB692TyfjR7sVILaCe0crnP66aJuXsg9cge3hptHs9UA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.45.1': + resolution: {integrity: sha512-Zd6Go9iEa+0zcoA2vDka9r/plYKaT3BhD3ESIy4JNIzFWXeQBGbH3zZxQIsz0jbNTMEtonlymU7eTLeaGWiApA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.44.1': + resolution: {integrity: sha512-l4T/S7ByjpY5TCUPeDe1GPns02/5BpR0jroSMexyH3ZnXJt9PtYqx1IKAlOjaFEGEOQF2tGDsMi4PY5l+fSniQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.46.1': + resolution: {integrity: sha512-9AsCVUAHOqvfe2RM/2I0DsDnx2ihw1d5jIN4+Bly1YPFTJIbk4+bXjAkr9+X6PUfhiV5urQHZkiYYPU1Q4yzPA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.18.1': + resolution: {integrity: sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.10.1': + resolution: {integrity: sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.44.1': + resolution: {integrity: sha512-iexblTsT3fP0hHUz/M1mWr+Ylg3bsYN2En/jvKXZtboW3Qkvt17HrQJYTF9leVIkXAfN97QxAcTE99YGbQW7vQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.57.2': + resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2': + resolution: {integrity: sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagation-utils@0.30.16': + resolution: {integrity: sha512-ZVQ3Z/PQ+2GQlrBfbMMMT0U7MzvYZLCPP800+ooyaBqm4hMvuQHfP028gB9/db0mwkmyEAMad9houukUVxhwcw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/propagator-b3@1.30.1': + resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.36.2': + resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==} + engines: {node: '>=14'} + + '@opentelemetry/resource-detector-alibaba-cloud@0.30.1': + resolution: {integrity: sha512-9l0FVP3F4+Z6ax27vMzkmhZdNtxAbDqEfy7rduzya3xFLaRiJSvOpw6cru6Edl5LwO+WvgNui+VzHa9ViE8wCg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@1.12.0': + resolution: {integrity: sha512-Cvi7ckOqiiuWlHBdA1IjS0ufr3sltex2Uws2RK6loVp4gzIJyOijsddAI6IZ5kiO8h/LgCWe8gxPmwkTKImd+Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.6.1': + resolution: {integrity: sha512-Djr31QCExVfWViaf9cGJnH+bUInD72p0GEfgDGgjCAztyvyji6WJvKjs6qmkpPN+Ig6KLk0ho2VgzT5Kdl4L2Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.6.1': + resolution: {integrity: sha512-o4sLzx149DQXDmVa8pgjBDEEKOj9SuQnkSLbjUVOpQNnn10v0WNR6wLwh30mFsK26xOJ6SpqZBGKZiT7i5MjlA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.33.1': + resolution: {integrity: sha512-/aZJXI1rU6Eus04ih2vU0hxXAibXXMzH1WlDZ8bXcTJmhwmTY8cP392+6l7cWeMnTQOibBUz8UKV3nhcCBAefw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-node@0.57.2': + resolution: {integrity: sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.30.1': + resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.36.0': + resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.40.1': + resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@orama/orama@3.1.4': resolution: {integrity: sha512-7tTuIdkzgRscJ7sGHVsoK9GtXSpwbfrj3WYnuSu/SepXHhshYiQaOeXc/aeLh4MfgIre6tEs/caIop8wrhMi3g==} engines: {node: '>= 16.0.0'} @@ -1854,6 +2326,36 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -3483,9 +3985,18 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/aws-lambda@8.10.147': + resolution: {integrity: sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} @@ -3537,18 +4048,33 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.26': + resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@20.19.0': resolution: {integrity: sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==} + '@types/node@22.16.3': + resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==} + + '@types/pg-pool@2.0.6': + resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + '@types/pg@8.11.11': resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/pg@8.6.1': + resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + '@types/react-dom@19.0.3': resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==} peerDependencies: @@ -3557,6 +4083,12 @@ packages: '@types/react@19.0.9': resolution: {integrity: sha512-FedNTYgmMwSZmD1Sru/W1gJKuiYCN/3SuBkmZkcxX+FpO5zL76B22A9YNfAKg4HQO3Neh/30AiynP6BELdU0qQ==} + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3627,6 +4159,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3653,6 +4190,10 @@ packages: zod: optional: true + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3748,6 +4289,9 @@ packages: caniuse-lite@1.0.30001699: resolution: {integrity: sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==} + canonicalize@1.0.8: + resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==} + canvas-confetti@1.9.3: resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} @@ -3778,6 +4322,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3792,6 +4339,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -3854,6 +4405,9 @@ packages: cross-domain-utils@2.0.38: resolution: {integrity: sha512-zZfi3+2EIR9l4chrEiXI2xFleyacsJf8YMLR1eJ0Veb5FTMXeJ3DpxDjZkto2FhL/g717WSELqbptNSo85UJDw==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4266,6 +4820,9 @@ packages: engines: {node: '>=18.3.0'} hasBin: true + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + framer-motion@12.4.7: resolution: {integrity: sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==} peerDependencies: @@ -4350,6 +4907,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.2.7: resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} engines: {node: '>= 0.4'} @@ -4408,6 +4969,9 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4455,12 +5019,51 @@ packages: engines: {node: '>=16.x'} hasBin: true + import-in-the-middle@1.14.2: + resolution: {integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inngest@3.40.1: + resolution: {integrity: sha512-SC9Ly28i8NI+WymttE8Jk41L9r/wHXWOnlQoy7e7yoQyZI+R2C4S77DpFwzgEaqGT/H8puc1VDli84RoaffXBg==} + engines: {node: '>=14'} + peerDependencies: + '@sveltejs/kit': '>=1.27.3' + '@vercel/node': '>=2.15.9' + aws-lambda: '>=1.0.7' + express: '>=4.19.2' + fastify: '>=4.21.0' + h3: '>=1.8.1' + hono: '>=4.2.7' + koa: '>=2.14.2' + next: '>=12.0.0' + typescript: '>=4.7.2' + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + '@vercel/node': + optional: true + aws-lambda: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true + koa: + optional: true + next: + optional: true + typescript: + optional: true + input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -4483,6 +5086,10 @@ packages: is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -4553,6 +5160,9 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4655,6 +5265,9 @@ packages: resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} engines: {node: '>= 12.0.0'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -4665,6 +5278,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4884,6 +5500,9 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -4895,6 +5514,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.4.5: resolution: {integrity: sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==} @@ -5080,6 +5702,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5226,6 +5851,10 @@ packages: property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + protobufjs@7.5.3: + resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==} + engines: {node: '>=12.0.0'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -5433,6 +6062,14 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + resend@4.4.1: resolution: {integrity: sha512-FR22bzMW3VfoyZSBc8ScGo8ShrMWHmWB0G3FrispzWCnYSEEK5M7pyRvZtInKmM/09lsJETKc2q66mX+dXPSmg==} engines: {node: '>=18'} @@ -5440,6 +6077,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -5482,6 +6124,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error-cjs@0.1.4: + resolution: {integrity: sha512-6a6dNqipzbCPlTFgztfNP2oG+IGcflMe/01zSzGrQcxGMKbIjOemBBD85pH92klWaJavAUWxAh9Z0aU28zxW6A==} + deprecated: Rolling release, please update to 0.2.0 + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -5500,6 +6146,9 @@ packages: shiki@3.6.0: resolution: {integrity: sha512-tKn/Y0MGBTffQoklaATXmTqDU02zx8NYBGQ+F6gy87/YjKbizcLd+Cybh/0ZtOBX9r1NEnAy/GTRDKtOsc1L9w==} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -5587,6 +6236,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5626,6 +6279,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + swiper@11.2.5: resolution: {integrity: sha512-nG0kbIyBfeE2BPFt9nPUX03qUBF75o6+enzjIT/DfCmbh8ORlwhc4eZz1+4H/yseAgb3H+OoEYzmb64i0tYNnQ==} engines: {node: '>= 4.7.0'} @@ -5656,6 +6313,12 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + temporal-polyfill@0.2.5: + resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} + + temporal-spec@0.2.4: + resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==} + third-party-capital@1.0.20: resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} @@ -5851,9 +6514,21 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + zalgo-promise@1.0.48: resolution: {integrity: sha512-LLHANmdm53+MucY9aOFIggzYtUdkSBFxUsy4glTTQYNyK6B3uCPWTbfiGvSrEvLojw0mSzyFJ1/RRLv+QMNdzQ==} @@ -5868,6 +6543,9 @@ packages: peerDependencies: zod: ^3.25.0 + zod@3.22.5: + resolution: {integrity: sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==} + zod@3.25.64: resolution: {integrity: sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==} @@ -6160,6 +6838,8 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@bufbuild/protobuf@2.6.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.0.0)': dependencies: react: 19.0.0 @@ -6647,6 +7327,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@grpc/grpc-js@1.13.4': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.3 + yargs: 17.7.2 + '@hexagon/base64@1.1.28': {} '@hookform/resolvers@4.1.0(react-hook-form@7.54.2(react@19.0.0))': @@ -6729,6 +7421,11 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inngest/ai@0.1.5': + dependencies: + '@types/node': 22.16.3 + typescript: 5.8.3 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -6738,6 +7435,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jpwilliams/waitgroup@2.1.1': {} + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -6755,6 +7454,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@js-sdsl/ordered-map@4.4.2': {} + '@levischuck/tiny-cbor@0.2.11': {} '@marsidev/react-turnstile@1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -6891,8 +7592,678 @@ snapshots: dependencies: '@openpanel/sdk': 1.0.0 + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/auto-instrumentations-node@0.56.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-lambda': 0.50.3(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-sdk': 0.49.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-bunyan': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cassandra-driver': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cucumber': 0.14.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.16.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dns': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fastify': 0.44.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.19.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-grpc': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.45.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.47.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-memcached': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.52.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.45.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-net': 0.43.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.51.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pino': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis-4': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-restify': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-router': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-socket.io': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.18.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.10.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-winston': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-alibaba-cloud': 0.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-aws': 1.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-azure': 0.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-container': 0.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.33.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-prometheus@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation-amqplib@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.50.3(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/aws-lambda': 8.10.147 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.49.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/propagation-utils': 0.30.16(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.45.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.45.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.43.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.14.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.16.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.43.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.47.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.44.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.19.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.43.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.47.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.45.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + forwarded-parse: 2.1.2 + semver: 7.7.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.47.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.47.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.43.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.52.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.45.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.45.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/mysql': 2.15.26 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.43.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.51.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + '@types/pg': 8.6.1 + '@types/pg-pool': 2.0.6 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis-4@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.45.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.18.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.10.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.14.2 + require-in-the-middle: 7.5.2 + semver: 7.7.1 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + protobufjs: 7.5.3 + + '@opentelemetry/propagation-utils@0.30.16(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/redis-common@0.36.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/resource-detector-aws@1.12.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/resource-detector-azure@0.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/resource-detector-container@0.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/resource-detector-gcp@0.33.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + gcp-metadata: 6.1.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + semver: 7.7.1 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.36.0': {} + + '@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@orama/orama@3.1.4': {} '@orama/orama@3.1.7': {} @@ -6978,6 +8349,29 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.0': {} '@radix-ui/number@1.1.1': {} @@ -8721,8 +10115,18 @@ snapshots: dependencies: '@types/estree': 1.0.6 + '@types/aws-lambda@8.10.147': {} + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 20.19.0 + '@types/canvas-confetti@1.9.0': {} + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.0 + '@types/cors@2.8.17': dependencies: '@types/node': 20.19.0 @@ -8773,12 +10177,28 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/memcached@2.2.10': + dependencies: + '@types/node': 20.19.0 + '@types/ms@2.1.0': {} + '@types/mysql@2.15.26': + dependencies: + '@types/node': 20.19.0 + '@types/node@20.19.0': dependencies: undici-types: 6.21.0 + '@types/node@22.16.3': + dependencies: + undici-types: 6.21.0 + + '@types/pg-pool@2.0.6': + dependencies: + '@types/pg': 8.11.11 + '@types/pg@8.11.11': dependencies: '@types/node': 20.19.0 @@ -8792,6 +10212,12 @@ snapshots: pg-types: 4.0.2 optional: true + '@types/pg@8.6.1': + dependencies: + '@types/node': 20.19.0 + pg-protocol: 1.9.5 + pg-types: 2.2.0 + '@types/react-dom@19.0.3(@types/react@19.0.9)': dependencies: '@types/react': 19.0.9 @@ -8800,6 +10226,12 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/shimmer@1.2.0': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 20.19.0 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8830,6 +10262,10 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -8850,6 +10286,8 @@ snapshots: react: 19.0.0 zod: 3.25.64 + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -8957,6 +10395,8 @@ snapshots: caniuse-lite@1.0.30001699: {} + canonicalize@1.0.8: {} + canvas-confetti@1.9.3: {} ccount@2.0.1: {} @@ -8980,6 +10420,8 @@ snapshots: dependencies: readdirp: 4.1.2 + cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8992,6 +10434,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} clsx@2.1.1: {} @@ -9053,6 +10501,12 @@ snapshots: dependencies: zalgo-promise: 1.0.48 + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9496,6 +10950,8 @@ snapshots: dependencies: fd-package-json: 2.0.0 + forwarded-parse@2.1.2: {} + framer-motion@12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: motion-dom: 12.4.5 @@ -9610,6 +11066,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.2.7: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9680,6 +11138,11 @@ snapshots: has-symbols@1.1.0: {} + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -9816,10 +11279,48 @@ snapshots: image-size@2.0.2: {} + import-in-the-middle@1.14.2: + dependencies: + acorn: 8.14.0 + acorn-import-attributes: 1.9.5(acorn@8.14.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + inherits@2.0.4: {} inline-style-parser@0.2.4: {} + inngest@3.40.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.8.3): + dependencies: + '@bufbuild/protobuf': 2.6.0 + '@inngest/ai': 0.1.5 + '@jpwilliams/waitgroup': 2.1.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/auto-instrumentations-node': 0.56.1(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@types/debug': 4.1.12 + canonicalize: 1.0.8 + chalk: 4.1.2 + cross-fetch: 4.1.0 + debug: 4.4.0 + hash.js: 1.1.7 + json-stringify-safe: 5.0.1 + ms: 2.1.3 + serialize-error-cjs: 0.1.4 + strip-ansi: 5.2.0 + temporal-polyfill: 0.2.5 + zod: 3.22.5 + optionalDependencies: + next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + typescript: 5.8.3 + transitivePeerDependencies: + - encoding + - supports-color + input-otp@1.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 @@ -9844,6 +11345,10 @@ snapshots: is-arrayish@0.3.2: optional: true + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-decimal@2.0.1: {} is-extglob@2.1.1: {} @@ -9892,6 +11397,8 @@ snapshots: json-schema@0.4.0: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsondiffpatch@0.6.0: @@ -9984,6 +11491,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.29.2 lightningcss-win32-x64-msvc: 1.29.2 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -9993,6 +11502,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -10470,6 +11981,8 @@ snapshots: mimic-fn@2.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -10478,6 +11991,8 @@ snapshots: minipass@7.1.2: {} + module-details-from-path@1.0.4: {} + motion-dom@12.4.5: dependencies: motion-utils: 12.0.0 @@ -10662,6 +12177,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -10692,8 +12209,7 @@ snapshots: pg-protocol@1.7.1: {} - pg-protocol@1.9.5: - optional: true + pg-protocol@1.9.5: {} pg-types@2.2.0: dependencies: @@ -10702,7 +12218,6 @@ snapshots: postgres-bytea: 1.0.0 postgres-date: 1.0.7 postgres-interval: 1.2.0 - optional: true pg-types@4.0.2: dependencies: @@ -10760,27 +12275,23 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: - optional: true + postgres-array@2.0.0: {} postgres-array@3.0.2: {} - postgres-bytea@1.0.0: - optional: true + postgres-bytea@1.0.0: {} postgres-bytea@3.0.0: dependencies: obuf: 1.1.2 - postgres-date@1.0.7: - optional: true + postgres-date@1.0.7: {} postgres-date@2.1.0: {} postgres-interval@1.2.0: dependencies: xtend: 4.0.2 - optional: true postgres-interval@3.0.0: {} @@ -10809,6 +12320,21 @@ snapshots: property-information@7.0.0: {} + protobufjs@7.5.3: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.19.0 + long: 5.3.2 + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -11148,6 +12674,16 @@ snapshots: transitivePeerDependencies: - supports-color + require-directory@2.1.1: {} + + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.0 + module-details-from-path: 1.0.4 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + resend@4.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@react-email/render': 1.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -11157,6 +12693,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -11188,8 +12730,9 @@ snapshots: semver@6.3.1: {} - semver@7.7.1: - optional: true + semver@7.7.1: {} + + serialize-error-cjs@0.1.4: {} sharp@0.33.5: dependencies: @@ -11246,6 +12789,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shimmer@1.2.1: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -11359,6 +12904,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -11393,6 +12942,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + swiper@11.2.5: {} swr@2.3.2(react@19.0.0): @@ -11415,6 +12966,12 @@ snapshots: tapable@2.2.1: {} + temporal-polyfill@0.2.5: + dependencies: + temporal-spec: 0.2.4 + + temporal-spec@0.2.4: {} + third-party-capital@1.0.20: {} throttleit@2.1.0: {} @@ -11613,11 +13170,24 @@ snapshots: ws@8.17.1: {} - xtend@4.0.2: - optional: true + xtend@4.0.2: {} + + y18n@5.0.8: {} yallist@3.1.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + zalgo-promise@1.0.48: {} zod-to-json-schema@3.24.2(zod@3.25.64): @@ -11628,6 +13198,8 @@ snapshots: dependencies: zod: 3.25.64 + zod@3.22.5: {} + zod@3.25.64: {} zustand@5.0.3(@types/react@19.0.9)(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)): diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index db915a9..bad822d 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -2,9 +2,12 @@ import { serve } from 'inngest/next'; import { inngest } from '../../../inngest/client'; import { helloWorld } from '../../../inngest/functions'; +/** + * Inngest route + * + * https://www.inngest.com/docs/getting-started/nextjs-quick-start + */ export const { GET, POST, PUT } = serve({ client: inngest, - functions: [ - helloWorld, // <-- This is where you'll always add all your functions - ], + functions: [helloWorld], }); diff --git a/src/inngest/client.ts b/src/inngest/client.ts index 904a97b..6164102 100644 --- a/src/inngest/client.ts +++ b/src/inngest/client.ts @@ -1,4 +1,8 @@ import { Inngest } from 'inngest'; -// Create a client to send and receive events -export const inngest = new Inngest({ id: 'my-app' }); +/** + * 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' }); From 9f3c5e80c24589b33242d176d9897a2a55ab344f Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 00:37:49 +0800 Subject: [PATCH 65/87] feat: add daily credit distribution function and integrate with existing credits logic --- src/app/api/inngest/route.ts | 4 +- src/credits/credits.ts | 113 ++++++++++++++++++++--------------- src/inngest/functions.ts | 34 +++++++++++ 3 files changed, 102 insertions(+), 49 deletions(-) diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index bad822d..6819038 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -1,6 +1,6 @@ import { serve } from 'inngest/next'; import { inngest } from '../../../inngest/client'; -import { helloWorld } from '../../../inngest/functions'; +import { distributeCreditsDaily, helloWorld } from '../../../inngest/functions'; /** * Inngest route @@ -9,5 +9,5 @@ import { helloWorld } from '../../../inngest/functions'; */ export const { GET, POST, PUT } = serve({ client: inngest, - functions: [helloWorld], + functions: [helloWorld, distributeCreditsDaily], }); diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 35af7f5..9a4fb92 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -347,9 +347,52 @@ export async function processExpiredCredits(userId: string) { amount: -expiredTotal, description: `Expire credits: ${expiredTotal}`, }); + + console.log( + `processExpiredCredits, ${expiredTotal} credits expired for user ${userId}` + ); } } +/** + * Add subscription renewal credits + * @param userId - User ID + * @param priceId - Price ID + */ +export async function addSubscriptionRenewalCredits( + userId: string, + priceId: string +) { + const pricePlan = findPlanByPriceId(priceId); + if ( + !pricePlan || + pricePlan.isFree || + !pricePlan.credits || + !pricePlan.credits.enable + ) { + console.log( + `addSubscriptionRenewalCredits, no credits configured for plan ${priceId}` + ); + return; + } + + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; + const now = new Date(); + + await addCredits({ + userId, + amount: credits, + type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, + description: `Subscription renewal credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expireDays, + }); + + console.log( + `addSubscriptionRenewalCredits, ${credits} credits for user ${userId}, priceId: ${priceId}` + ); +} + /** * Add register gift credits * @param userId - User ID @@ -382,6 +425,10 @@ export async function addRegisterGiftCredits(userId: string) { description: `Register gift credits: ${credits}`, expireDays, }); + + console.log( + `addRegisterGiftCredits, ${credits} credits for user ${userId}` + ); } } @@ -389,17 +436,21 @@ export async function addRegisterGiftCredits(userId: string) { * Add free monthly credits * @param userId - User ID */ -export async function addMonthlyFreeCredits(userId: string) { +export async function addMonthlyFreeCreditsIfNeed(userId: string) { const freePlan = Object.values(websiteConfig.price.plans).find( (plan) => plan.isFree ); if (!freePlan) { - console.log('addMonthlyFreeCredits, no free plan found'); + console.log('addMonthlyFreeCreditsIfNeed, no free plan found'); return; } - if (freePlan.disabled || !freePlan.credits?.enable) { + if ( + freePlan.disabled || + !freePlan.credits?.enable || + !freePlan.credits?.amount + ) { console.log( - 'addMonthlyFreeCredits, plan disabled or credits disabled', + 'addMonthlyFreeCreditsIfNeed, plan disabled or credits disabled', freePlan.id ); return; @@ -434,52 +485,18 @@ export async function addMonthlyFreeCredits(userId: string) { description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, expireDays, }); - } -} -/** - * Add subscription renewal credits - * @param userId - User ID - * @param priceId - Price ID - */ -export async function addSubscriptionRenewalCredits( - userId: string, - priceId: string -) { - const pricePlan = findPlanByPriceId(priceId); - if ( - !pricePlan || - pricePlan.isFree || - !pricePlan.credits || - !pricePlan.credits.enable - ) { console.log( - `addSubscriptionRenewalCredits, no credits configured for plan ${priceId}` + `addMonthlyFreeCreditsIfNeed, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` ); - return; } - - const credits = pricePlan.credits.amount; - const expireDays = pricePlan.credits.expireDays; - - await addCredits({ - userId, - amount: credits, - type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, - description: `Subscription renewal credits for ${priceId}: ${credits}`, - expireDays, - }); - - console.log( - `Added ${credits} subscription renewal credits for user ${userId}, priceId: ${priceId}` - ); } /** * Add lifetime monthly credits * @param userId - User ID */ -export async function addLifetimeMonthlyCredits(userId: string) { +export async function addLifetimeMonthlyCreditsIfNeed(userId: string) { const lifetimePlan = Object.values(websiteConfig.price.plans).find( (plan) => plan.isLifetime ); @@ -534,7 +551,9 @@ export async function addLifetimeMonthlyCredits(userId: string) { // Update last refresh time for lifetime credits await updateUserLastRefreshAt(userId, now); - console.log(`Added ${credits} lifetime monthly credits for user ${userId}`); + console.log( + `addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); } } @@ -543,7 +562,7 @@ export async function addLifetimeMonthlyCredits(userId: string) { * This function is designed to be called by a cron job */ export async function distributeCreditsToAllUsers() { - console.log('Starting credit distribution to all users...'); + console.log('distributing credits to all users start'); const db = await getDb(); @@ -569,7 +588,7 @@ export async function distributeCreditsToAllUsers() { .where( and( eq(payment.userId, userRecord.userId), - eq(payment.status, 'active') + or(eq(payment.status, 'active'), eq(payment.status, 'trialing')) ) ) .orderBy(desc(payment.createdAt)); @@ -581,18 +600,18 @@ export async function distributeCreditsToAllUsers() { if (pricePlan?.isLifetime) { // Lifetime user - add monthly credits - await addLifetimeMonthlyCredits(userRecord.userId); + await addLifetimeMonthlyCreditsIfNeed(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); + await addMonthlyFreeCreditsIfNeed(userRecord.userId); } processedCount++; } catch (error) { console.error( - `Error processing credits for user ${userRecord.userId}:`, + `distributing credits to all users error, user: ${userRecord.userId}, error:`, error ); errorCount++; @@ -600,7 +619,7 @@ export async function distributeCreditsToAllUsers() { } console.log( - `Credit distribution completed. Processed: ${processedCount}, Errors: ${errorCount}` + `distributing credits to all users end, processed: ${processedCount}, errors: ${errorCount}` ); return { processedCount, errorCount }; } diff --git a/src/inngest/functions.ts b/src/inngest/functions.ts index 8b61b95..c8dabe3 100644 --- a/src/inngest/functions.ts +++ b/src/inngest/functions.ts @@ -1,5 +1,39 @@ +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' }, From 765f5e1e391f9804ebb9c6db2e58730c451bd21f Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 00:55:27 +0800 Subject: [PATCH 66/87] feat: add register gift and monthly refresh credits to new user --- messages/zh.json | 14 ++++---- src/lib/auth.ts | 94 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/messages/zh.json b/messages/zh.json index 3b85703..94db36a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -632,13 +632,13 @@ }, "paymentIdCopied": "支付ID已复制到剪贴板", "types": { - "MONTHLY_REFRESH": "每月刷新", - "REGISTER_GIFT": "注册礼品", - "PURCHASE": "购买", - "USAGE": "使用", - "EXPIRE": "过期", - "SUBSCRIPTION_RENEWAL": "订阅续费", - "LIFETIME_MONTHLY": "终身月度" + "MONTHLY_REFRESH": "每月赠送", + "REGISTER_GIFT": "注册赠送", + "PURCHASE": "购买积分", + "USAGE": "使用积分", + "EXPIRE": "过期积分", + "SUBSCRIPTION_RENEWAL": "订阅月度积分", + "LIFETIME_MONTHLY": "终身月度积分" }, "detailViewer": { "title": "积分交易详情", diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bab07b8..da6e297 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,16 +1,19 @@ import { websiteConfig } from '@/config/website'; -import { addCredits } from '@/credits/credits'; -import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; +import { + addMonthlyFreeCreditsIfNeed, + 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'; /** @@ -116,34 +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); - } - } - // Add register gift credits to the user if enabled in website config - if ( - websiteConfig.credits.registerGiftCredits.enable && - websiteConfig.credits.registerGiftCredits.credits > 0 - ) { - await addCredits({ - userId: user.id, - amount: websiteConfig.credits.registerGiftCredits.credits, - type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, - description: 'Register gift credits', - expireDays: websiteConfig.credits.registerGiftCredits.expireDays, - }); - } + await onCreateUser(user); }, }, }, @@ -179,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 addMonthlyFreeCreditsIfNeed(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); + } + } +} From 4abca022aab3fdb6c01d13f88e1bd1d9434dbd6c Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 00:59:06 +0800 Subject: [PATCH 67/87] feat: restrict access to current user's transactions --- src/actions/get-credit-transactions.ts | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index 61d9b82..f346b9e 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -2,7 +2,8 @@ import { getDb } from '@/db'; import { creditTransaction, user } from '@/db/schema'; -import { asc, desc, eq, ilike, or, sql } from 'drizzle-orm'; +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'; @@ -43,17 +44,27 @@ 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 + // search by type, amount, paymentId, description, and restrict to current user const where = search - ? or( - ilike(creditTransaction.type, `%${search}%`), - ilike(creditTransaction.amount, `%${search}%`), - ilike(creditTransaction.paymentId, `%${search}%`), - ilike(creditTransaction.description, `%${search}%`) + ? and( + eq(creditTransaction.userId, session.user.id), + or( + ilike(creditTransaction.type, `%${search}%`), + ilike(creditTransaction.amount, `%${search}%`), + ilike(creditTransaction.paymentId, `%${search}%`), + ilike(creditTransaction.description, `%${search}%`) + ) ) - : undefined; + : eq(creditTransaction.userId, session.user.id); const offset = pageIndex * pageSize; From a5c6c8b493bf535d44b8e1d9e17d596ad34bec6f Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 08:10:31 +0800 Subject: [PATCH 68/87] feat: display user email and customer ID in UserDetailViewer --- src/components/admin/user-detail-viewer.tsx | 52 ++++++++++++++++++--- src/credits/credits.ts | 1 + 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/components/admin/user-detail-viewer.tsx b/src/components/admin/user-detail-viewer.tsx index f10cc0e..392ccf9 100644 --- a/src/components/admin/user-detail-viewer.tsx +++ b/src/components/admin/user-detail-viewer.tsx @@ -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) { />
{user.name} - {user.email} + {/* {user.email} */}
@@ -188,12 +189,51 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
- {/* information */} -
- {t('joined')}: {formatDate(user.createdAt)} + {/* email */} + {user.email && ( +
+ + {t('columns.email')}: + + { + navigator.clipboard.writeText(user.email!); + toast.success(t('emailCopied')); + }} + > + {user.email} + +
+ )} + + {/* customerId */} + {user.customerId && ( +
+ + {t('columns.customerId')}: + + + {user.customerId} + +
+ )} +
+ + {/* Timestamps */} +
+
+ {t('joined')}: + {formatDate(user.createdAt)}
-
- {t('updated')}: {formatDate(user.updatedAt)} +
+ {t('updated')}: + {formatDate(user.updatedAt)}
diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 9a4fb92..8f96a6b 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -575,6 +575,7 @@ export async function distributeCreditsToAllUsers() { }) .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; From 4160305a67cb47a79e7538ff3578d8878e485f06 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 09:59:30 +0800 Subject: [PATCH 69/87] chore: update env.example with Inngest keys --- env.example | 7 +++++++ messages/en.json | 4 ++-- src/app/api/inngest/route.ts | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/env.example b/env.example index 572508a..901f1ea 100644 --- a/env.example +++ b/env.example @@ -162,6 +162,13 @@ NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID="" NEXT_PUBLIC_TURNSTILE_SITE_KEY="" TURNSTILE_SECRET_KEY="" +# ----------------------------------------------------------------------------- +# Inngest +# https://mksaas.com/docs/jobs#setup +# ----------------------------------------------------------------------------- +INNGEST_SIGNING_KEY="" +INNGEST_EVENT_KEY="" + # ----------------------------------------------------------------------------- # AI # https://mksaas.com/docs/ai diff --git a/messages/en.json b/messages/en.json index 3e22e9c..d7c5b57 100644 --- a/messages/en.json +++ b/messages/en.json @@ -499,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", diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index 6819038..607e9d5 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -6,6 +6,12 @@ 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, From c7a1ec69bb1211c293dbf917087079347ad26e7e Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 11:08:28 +0800 Subject: [PATCH 70/87] feat: add ConsumeCreditCard component for credit consumption --- messages/en.json | 6 +-- .../text/components/consume-credit-card.tsx | 51 +++++++++++++++++++ src/app/[locale]/(marketing)/ai/text/page.tsx | 6 ++- .../credits/credit-transactions-table.tsx | 15 +----- src/credits/credit-detail-viewer.tsx | 15 +----- 5 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 src/ai/text/components/consume-credit-card.tsx diff --git a/messages/en.json b/messages/en.json index d7c5b57..25d07b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -633,9 +633,9 @@ "types": { "MONTHLY_REFRESH": "Monthly Refresh", "REGISTER_GIFT": "Register Gift", - "PURCHASE": "Purchase", - "USAGE": "Usage", - "EXPIRE": "Expire", + "PURCHASE": "Purchased Credits", + "USAGE": "Consumed Credits", + "EXPIRE": "Expired Credits", "SUBSCRIPTION_RENEWAL": "Subscription Renewal", "LIFETIME_MONTHLY": "Lifetime Monthly" }, diff --git a/src/ai/text/components/consume-credit-card.tsx b/src/ai/text/components/consume-credit-card.tsx new file mode 100644 index 0000000..478bf76 --- /dev/null +++ b/src/ai/text/components/consume-credit-card.tsx @@ -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 ( +
+
+ +
+ +
+ ); +} diff --git a/src/app/[locale]/(marketing)/ai/text/page.tsx b/src/app/[locale]/(marketing)/ai/text/page.tsx index 95982bb..5702bfc 100644 --- a/src/app/[locale]/(marketing)/ai/text/page.tsx +++ b/src/app/[locale]/(marketing)/ai/text/page.tsx @@ -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() {
{/* about section */}
-
+
{/* avatar and name */}
@@ -48,6 +49,9 @@ export default async function AITextPage() {
+ + {/* simulate consume credits */} +
diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index d9a3d89..9136e5a 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -223,17 +223,6 @@ export function CreditTransactionsTable({ } }; - // Get transaction type badge variant - const getTransactionTypeBadgeVariant = (type: string) => { - switch (type) { - case CREDIT_TRANSACTION_TYPE.USAGE: - case CREDIT_TRANSACTION_TYPE.EXPIRE: - return 'destructive' as const; - default: - return 'outline' as const; - } - }; - // Get transaction type display name const getTransactionTypeDisplayName = (type: string) => { switch (type) { @@ -268,8 +257,8 @@ export function CreditTransactionsTable({ return (
{getTransactionTypeIcon(transaction.type)} {getTransactionTypeDisplayName(transaction.type)} diff --git a/src/credits/credit-detail-viewer.tsx b/src/credits/credit-detail-viewer.tsx index 7fd36ba..3842313 100644 --- a/src/credits/credit-detail-viewer.tsx +++ b/src/credits/credit-detail-viewer.tsx @@ -70,17 +70,6 @@ export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) { } }; - // Get transaction type badge variant - const getTransactionTypeBadgeVariant = (type: string) => { - switch (type) { - case CREDIT_TRANSACTION_TYPE.USAGE: - case CREDIT_TRANSACTION_TYPE.EXPIRE: - return 'destructive' as const; - default: - return 'outline' as const; - } - }; - // Get transaction type display name const getTransactionTypeDisplayName = (type: string) => { switch (type) { @@ -131,8 +120,8 @@ export function CreditDetailViewer({ transaction }: CreditDetailViewerProps) {
{/* Transaction Type Badge */} {getTransactionTypeIcon(transaction.type)} {getTransactionTypeDisplayName(transaction.type)} From 367965e41f5b716b31e7908163910970c05d9d93 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 12:46:57 +0800 Subject: [PATCH 71/87] feat: ensure handler session id only once in credit package --- src/components/settings/credits/credit-packages.tsx | 6 ++++-- src/stores/credits-store.ts | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 527b23a..4bf9b37 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -18,7 +18,7 @@ import { Routes } from '@/routes'; import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { toast } from 'sonner'; import { CreditCheckoutButton } from './credit-checkout-button'; @@ -30,6 +30,7 @@ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); const searchParams = useSearchParams(); const localeRouter = useLocaleRouter(); + const hasHandledSession = useRef(false); // Use the new useCredits hook const { balance, isLoading, refresh } = useCredits(); @@ -45,7 +46,8 @@ export function CreditPackages() { // Check for payment success and show success message useEffect(() => { const sessionId = searchParams.get('session_id'); - if (sessionId) { + if (sessionId && !hasHandledSession.current) { + hasHandledSession.current = true; // Show success toast (delayed to avoid React lifecycle conflicts) setTimeout(() => { toast.success(t('creditsAdded')); diff --git a/src/stores/credits-store.ts b/src/stores/credits-store.ts index 7c39180..8421c1e 100644 --- a/src/stores/credits-store.ts +++ b/src/stores/credits-store.ts @@ -153,7 +153,13 @@ export const useCreditsStore = create((set, get) => ({ * @param user Current user from auth session */ refreshCredits: async (user) => { - if (!user) return; + if (!user) { + set({ + error: 'No user found', + isLoading: false, + }); + return; + } set({ isLoading: true, From b5997ded4c1f8c54fd10223c0a2429eae80251bc Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 17:03:16 +0800 Subject: [PATCH 72/87] feat: add payment success message and improve billing card layout --- messages/en.json | 3 +- messages/zh.json | 3 +- .../settings/billing/billing-card.tsx | 57 ++++++++++++++----- .../settings/credits/credit-packages.tsx | 4 +- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/messages/en.json b/messages/en.json index 25d07b9..4c2f480 100644 --- a/messages/en.json +++ b/messages/en.json @@ -579,7 +579,8 @@ "manageBilling": "Manage Billing", "upgradePlan": "Upgrade Plan", "retry": "Retry", - "errorMessage": "Failed to get data" + "errorMessage": "Failed to get data", + "paymentSuccess": "Payment successful" }, "credits": { "title": "Credits", diff --git a/messages/zh.json b/messages/zh.json index 94db36a..94199fe 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -580,7 +580,8 @@ "manageBilling": "管理账单", "upgradePlan": "升级方案", "retry": "重试", - "errorMessage": "获取数据失败" + "errorMessage": "获取数据失败", + "paymentSuccess": "支付成功" }, "credits": { "title": "积分", diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index 2321271..9275d34 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -14,7 +14,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { getPricePlans } from '@/config/price-config'; import { usePayment } from '@/hooks/use-payment'; -import { LocaleLink } from '@/i18n/navigation'; +import { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; import { authClient } from '@/lib/auth-client'; import { formatDate, formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; @@ -22,9 +22,15 @@ import { PlanIntervals } from '@/payment/types'; import { Routes } from '@/routes'; import { RefreshCwIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; export default function BillingCard() { const t = useTranslations('Dashboard.settings.billing'); + const searchParams = useSearchParams(); + const localeRouter = useLocaleRouter(); + const hasHandledSession = useRef(false); const { isLoading: isLoadingPayment, @@ -66,24 +72,43 @@ export default function BillingCard() { const isPageLoading = isLoadingPayment || isLoadingSession; // console.log('billing card, isLoadingPayment', isLoadingPayment, 'isLoadingSession', isLoadingSession); + // Check for payment success and show success message + useEffect(() => { + const sessionId = searchParams.get('session_id'); + if (sessionId && !hasHandledSession.current) { + hasHandledSession.current = true; + setTimeout(() => { + toast.success(t('paymentSuccess')); + }, 0); + + const url = new URL(window.location.href); + url.searchParams.delete('session_id'); + localeRouter.replace(Routes.SettingsBilling + url.search); + } + }, [searchParams, localeRouter]); + // Render loading skeleton if (isPageLoading) { return ( -
- +
+ {t('currentPlan.title')} {t('currentPlan.description')} - +
- - + +
- - + +
@@ -93,19 +118,23 @@ export default function BillingCard() { // Render error state if (loadPaymentError) { return ( -
- +
+ {t('currentPlan.title')} {t('currentPlan.description')} - +
{loadPaymentError}
- + - -
-
+ + + {t('currentPlan.title')} + {t('currentPlan.description')} + + +
{loadPaymentError}
+
+ + + +
); } // currentPlanFromStore maybe null, so we need to check if it is null if (!currentPlanFromStore) { return ( -
- - - {t('currentPlan.title')} - {t('currentPlan.description')} - - -
- {t('currentPlan.noPlan')} -
-
- - - -
-
+ + + {t('currentPlan.title')} + {t('currentPlan.description')} + + +
+ {t('currentPlan.noPlan')} +
+
+ + + +
); } @@ -179,98 +173,96 @@ export default function BillingCard() { // console.log('billing card, currentUser', currentUser); return ( -
- - - - {t('currentPlan.title')} - - {t('currentPlan.description')} - - - {/* Plan name and status */} -
-
{currentPlan?.name}
- {subscription && ( - - {subscription?.status === 'trialing' - ? t('status.trial') - : subscription?.status === 'active' - ? t('status.active') - : ''} - - )} + + + + {t('currentPlan.title')} + + {t('currentPlan.description')} + + + {/* Plan name and status */} +
+
{currentPlan?.name}
+ {subscription && ( + + {subscription?.status === 'trialing' + ? t('status.trial') + : subscription?.status === 'active' + ? t('status.active') + : ''} + + )} +
+ + {/* Free plan message */} + {isFreePlan && ( +
+ {t('freePlanMessage')}
+ )} - {/* Free plan message */} - {isFreePlan && ( -
- {t('freePlanMessage')} + {/* Lifetime plan message */} + {isLifetimeMember && ( +
+ {t('lifetimeMessage')} +
+ )} + + {/* Subscription plan message */} + {subscription && currentPrice && ( +
+
+ {t('price')}{' '} + {formatPrice(currentPrice.amount, currentPrice.currency)} /{' '} + {currentPrice.interval === PlanIntervals.MONTH + ? t('interval.month') + : currentPrice.interval === PlanIntervals.YEAR + ? t('interval.year') + : t('interval.oneTime')}
- )} - {/* Lifetime plan message */} - {isLifetimeMember && ( -
- {t('lifetimeMessage')} -
- )} - - {/* Subscription plan message */} - {subscription && currentPrice && ( -
+ {nextBillingDate && (
- {t('price')}{' '} - {formatPrice(currentPrice.amount, currentPrice.currency)} /{' '} - {currentPrice.interval === PlanIntervals.MONTH - ? t('interval.month') - : currentPrice.interval === PlanIntervals.YEAR - ? t('interval.year') - : t('interval.oneTime')} + {t('nextBillingDate')} {nextBillingDate}
+ )} - {nextBillingDate && ( -
- {t('nextBillingDate')} {nextBillingDate} + {subscription.status === 'trialing' && + subscription.currentPeriodEnd && ( +
+ {t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
)} +
+ )} + + + {/* user is on free plan, show upgrade plan button */} + {isFreePlan && ( + + )} - {subscription.status === 'trialing' && - subscription.currentPeriodEnd && ( -
- {t('trialEnds')} {formatDate(subscription.currentPeriodEnd)} -
- )} -
- )} - - - {/* user is on free plan, show upgrade plan button */} - {isFreePlan && ( - - )} + {/* user is lifetime member, show manage billing button */} + {isLifetimeMember && currentUser && ( + + {t('manageBilling')} + + )} - {/* user is lifetime member, show manage billing button */} - {isLifetimeMember && currentUser && ( - - {t('manageBilling')} - - )} - - {/* user has subscription, show manage subscription button */} - {subscription && currentUser && ( - - {t('manageSubscription')} - - )} - - -
+ {/* user has subscription, show manage subscription button */} + {subscription && currentUser && ( + + {t('manageSubscription')} + + )} + + ); } diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx new file mode 100644 index 0000000..4483a6e --- /dev/null +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -0,0 +1,143 @@ +'use client'; + +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 { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; +import { cn } from '@/lib/utils'; +import { Routes } from '@/routes'; +import { CoinsIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +export default function CreditsBalanceCard() { + const t = useTranslations('Dashboard.settings.credits.balance'); + const searchParams = useSearchParams(); + const localeRouter = useLocaleRouter(); + const hasHandledSession = useRef(false); + + // Use the credits hook to get balance + const { balance, isLoading, error, refresh } = useCredits(); + + // Don't render if credits are disabled + if (!websiteConfig.credits.enableCredits) { + return null; + } + + // Check for payment success and show success message + useEffect(() => { + const sessionId = searchParams.get('session_id'); + if (sessionId && !hasHandledSession.current) { + hasHandledSession.current = true; + // Show success toast (delayed to avoid React lifecycle conflicts) + setTimeout(() => { + toast.success(t('creditsAdded')); + }, 0); + + // Refresh credits data to show updated balance + refresh(); + + // Clean up URL parameters + const url = new URL(window.location.href); + url.searchParams.delete('session_id'); + localeRouter.replace(Routes.SettingsBilling + url.search); + } + }, [searchParams, localeRouter, refresh]); + + // Render loading skeleton + if (isLoading) { + return ( + + + {t('title')} + {t('description')} + + +
+ + +
+
+ + + +
+ ); + } + + // Render error state + if (error) { + return ( + + + {t('title')} + {t('description')} + + +
{error}
+
+ + + +
+ ); + } + + return ( + + + {t('title')} + {t('description')} + + + {/* Credits balance display */} +
+
+ +
+ {balance.toLocaleString()} +
+
+ {/* {t('available')} */} +
+ + {/* Balance information */} + {/*
{t('message')}
*/} +
+ + + +
+ ); +} diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index f293186..8aedc1b 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -9,17 +9,12 @@ import { CardTitle, } from '@/components/ui/card'; import { getCreditPackages } from '@/config/credits-config'; -import { useCredits } from '@/hooks/use-credits'; +import { websiteConfig } from '@/config/website'; import { useCurrentUser } from '@/hooks/use-current-user'; -import { useLocaleRouter } from '@/i18n/navigation'; import { formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; -import { Routes } from '@/routes'; -import { CircleCheckBigIcon, CoinsIcon, Loader2Icon } from 'lucide-react'; +import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useSearchParams } from 'next/navigation'; -import { useEffect, useRef } from 'react'; -import { toast } from 'sonner'; import { CreditCheckoutButton } from './credit-checkout-button'; /** @@ -28,137 +23,89 @@ import { CreditCheckoutButton } from './credit-checkout-button'; */ export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); - const searchParams = useSearchParams(); - const localeRouter = useLocaleRouter(); - const hasHandledSession = useRef(false); - - // Use the new useCredits hook - const { balance, isLoading, refresh } = useCredits(); // Get current user const currentUser = useCurrentUser(); + // Don't render if credits are disabled + if (!websiteConfig.credits.enableCredits) { + return null; + } + // show only enabled packages const creditPackages = Object.values(getCreditPackages()).filter( (pkg) => !pkg.disabled && pkg.price.priceId ); - // Check for payment success and show success message - useEffect(() => { - const sessionId = searchParams.get('session_id'); - if (sessionId && !hasHandledSession.current) { - hasHandledSession.current = true; - // Show success toast (delayed to avoid React lifecycle conflicts) - setTimeout(() => { - toast.success(t('creditsAdded')); - }, 0); - - // Refresh credits data to show updated balance - refresh(); - - // Clean up URL parameters - const url = new URL(window.location.href); - url.searchParams.delete('session_id'); - // Use Routes.SettingsCredits + url.search to properly handle locale routing - localeRouter.replace(Routes.SettingsCredits + url.search); - } - }, [searchParams, localeRouter, refresh]); - return ( -
- - - - {t('balance')} - - - -
-
- {/* */} -
- {isLoading ? ( - - ) : ( -
- {balance.toLocaleString()} -
- )} -
-
-
-
-
- - - - {t('title')} - - {t('description')} - - - -
- {creditPackages.map((creditPackage) => ( - - {creditPackage.popular && ( -
- - {t('popular')} - -
- )} - - - {/* Price and Credits - Left/Right Layout */} -
-
-
- - {creditPackage.credits.toLocaleString()} -
-
-
-
- {formatPrice( - creditPackage.price.amount, - creditPackage.price.currency - )} -
-
-
- -
- - {creditPackage.description} -
- - {/* purchase button using checkout */} - + + {t('title')} + + {t('description')} + + + +
+ {creditPackages.map((creditPackage) => ( + + {creditPackage.popular && ( +
+ - {t('purchase')} - - - - ))} -
- -
-
+ {t('popular')} + +
+ )} + + + {/* Price and Credits - Left/Right Layout */} +
+
+
+ + {creditPackage.credits.toLocaleString()} +
+
+
+
+ {formatPrice( + creditPackage.price.amount, + creditPackage.price.currency + )} +
+
+
+ +
+ + {creditPackage.description} +
+ + {/* purchase button using checkout */} + + {t('purchase')} + +
+
+ ))} +
+ + ); } From 0f79ed14f01d62df8abb7107d40d83a5ac4d6abb Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 19:29:47 +0800 Subject: [PATCH 75/87] refactor: standardize string quotes and improve formatting in components --- src/ai/image/components/QualityModeToggle.tsx | 18 +++++++++--------- src/ai/image/components/Stopwatch.tsx | 6 ++++-- src/ai/image/lib/image-helpers.ts | 14 +++++++------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/ai/image/components/QualityModeToggle.tsx b/src/ai/image/components/QualityModeToggle.tsx index ce44cd0..d9abde0 100644 --- a/src/ai/image/components/QualityModeToggle.tsx +++ b/src/ai/image/components/QualityModeToggle.tsx @@ -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, }); }} diff --git a/src/ai/image/components/Stopwatch.tsx b/src/ai/image/components/Stopwatch.tsx index f4a1a82..e567796 100644 --- a/src/ai/image/components/Stopwatch.tsx +++ b/src/ai/image/components/Stopwatch.tsx @@ -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 ( -
{(elapsed / 1000).toFixed(1)}s
+
+ {(elapsed / 1000).toFixed(1)}s +
); } diff --git a/src/ai/image/lib/image-helpers.ts b/src/ai/image/lib/image-helpers.ts index 677ef69..dde39b5 100644 --- a/src/ai/image/lib/image-helpers.ts +++ b/src/ai/image/lib/image-helpers.ts @@ -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 => { 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; }, }; From 72e0a14fc908f4fb66c4d45a236947c0ee86bb0c Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 19:46:27 +0800 Subject: [PATCH 76/87] refactor: improve layout consistency in settings pages by adjusting grid structures --- .../settings/notifications/page.tsx | 6 +++-- .../(protected)/settings/profile/page.tsx | 8 +++++-- .../(protected)/settings/security/page.tsx | 8 +++++-- .../settings/billing/billing-card.tsx | 24 ++++--------------- .../settings/billing/credits-balance-card.tsx | 18 +++----------- .../notification/newsletter-form-card.tsx | 7 +----- .../settings/profile/update-avatar-card.tsx | 2 +- .../settings/profile/update-name-card.tsx | 2 +- .../settings/security/delete-account-card.tsx | 2 +- .../security/password-card-wrapper.tsx | 6 +---- .../settings/security/reset-password-card.tsx | 2 +- .../security/update-password-card.tsx | 2 +- 12 files changed, 30 insertions(+), 57 deletions(-) diff --git a/src/app/[locale]/(protected)/settings/notifications/page.tsx b/src/app/[locale]/(protected)/settings/notifications/page.tsx index 18d2ddb..faaff59 100644 --- a/src/app/[locale]/(protected)/settings/notifications/page.tsx +++ b/src/app/[locale]/(protected)/settings/notifications/page.tsx @@ -2,8 +2,10 @@ import { NewsletterFormCard } from '@/components/settings/notification/newslette export default function NotificationPage() { return ( -
- +
+
+ +
); } diff --git a/src/app/[locale]/(protected)/settings/profile/page.tsx b/src/app/[locale]/(protected)/settings/profile/page.tsx index f6915df..9fafa5f 100644 --- a/src/app/[locale]/(protected)/settings/profile/page.tsx +++ b/src/app/[locale]/(protected)/settings/profile/page.tsx @@ -4,8 +4,12 @@ import { UpdateNameCard } from '@/components/settings/profile/update-name-card'; export default function ProfilePage() { return (
- - +
+ +
+
+ +
); } diff --git a/src/app/[locale]/(protected)/settings/security/page.tsx b/src/app/[locale]/(protected)/settings/security/page.tsx index 7574f3c..bab5416 100644 --- a/src/app/[locale]/(protected)/settings/security/page.tsx +++ b/src/app/[locale]/(protected)/settings/security/page.tsx @@ -4,8 +4,12 @@ import { PasswordCardWrapper } from '@/components/settings/security/password-car export default function SecurityPage() { return (
- - +
+ +
+
+ +
); } diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index fd2d2af..a969701 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -90,11 +90,7 @@ export default function BillingCard() { // Render loading skeleton if (isPageLoading) { return ( - + {t('currentPlan.title')} {t('currentPlan.description')} @@ -116,11 +112,7 @@ export default function BillingCard() { // Render error state if (loadPaymentError) { return ( - + {t('currentPlan.title')} {t('currentPlan.description')} @@ -145,11 +137,7 @@ export default function BillingCard() { // currentPlanFromStore maybe null, so we need to check if it is null if (!currentPlanFromStore) { return ( - + {t('currentPlan.title')} {t('currentPlan.description')} @@ -173,11 +161,7 @@ export default function BillingCard() { // console.log('billing card, currentUser', currentUser); return ( - + {t('currentPlan.title')} diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx index 4483a6e..9e8813d 100644 --- a/src/components/settings/billing/credits-balance-card.tsx +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -58,11 +58,7 @@ export default function CreditsBalanceCard() { // Render loading skeleton if (isLoading) { return ( - + {t('title')} {t('description')} @@ -83,11 +79,7 @@ export default function CreditsBalanceCard() { // Render error state if (error) { return ( - + {t('title')} {t('description')} @@ -107,11 +99,7 @@ export default function CreditsBalanceCard() { } return ( - + {t('title')} {t('description')} diff --git a/src/components/settings/notification/newsletter-form-card.tsx b/src/components/settings/notification/newsletter-form-card.tsx index cb1bd9b..43387b5 100644 --- a/src/components/settings/notification/newsletter-form-card.tsx +++ b/src/components/settings/notification/newsletter-form-card.tsx @@ -165,12 +165,7 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) { }; return ( - + {t('newsletter.title')} diff --git a/src/components/settings/profile/update-avatar-card.tsx b/src/components/settings/profile/update-avatar-card.tsx index 8b4d514..2da97f4 100644 --- a/src/components/settings/profile/update-avatar-card.tsx +++ b/src/components/settings/profile/update-avatar-card.tsx @@ -128,7 +128,7 @@ export function UpdateAvatarCard({ className }: UpdateAvatarCardProps) { return ( diff --git a/src/components/settings/profile/update-name-card.tsx b/src/components/settings/profile/update-name-card.tsx index 5421872..b4d3abf 100644 --- a/src/components/settings/profile/update-name-card.tsx +++ b/src/components/settings/profile/update-name-card.tsx @@ -110,7 +110,7 @@ export function UpdateNameCard({ className }: UpdateNameCardProps) { return ( diff --git a/src/components/settings/security/delete-account-card.tsx b/src/components/settings/security/delete-account-card.tsx index c5bdf2c..0bc6edf 100644 --- a/src/components/settings/security/delete-account-card.tsx +++ b/src/components/settings/security/delete-account-card.tsx @@ -79,7 +79,7 @@ export function DeleteAccountCard() { return ( diff --git a/src/components/settings/security/password-card-wrapper.tsx b/src/components/settings/security/password-card-wrapper.tsx index 4949c96..497de96 100644 --- a/src/components/settings/security/password-card-wrapper.tsx +++ b/src/components/settings/security/password-card-wrapper.tsx @@ -80,11 +80,7 @@ export function PasswordCardWrapper() { function PasswordSkeletonCard() { const t = useTranslations('Dashboard.settings.security.updatePassword'); return ( - + {t('title')} {t('description')} diff --git a/src/components/settings/security/reset-password-card.tsx b/src/components/settings/security/reset-password-card.tsx index 274d09f..69d621d 100644 --- a/src/components/settings/security/reset-password-card.tsx +++ b/src/components/settings/security/reset-password-card.tsx @@ -55,7 +55,7 @@ export function ResetPasswordCard({ className }: ResetPasswordCardProps) { return ( diff --git a/src/components/settings/security/update-password-card.tsx b/src/components/settings/security/update-password-card.tsx index 54979d9..5b4588f 100644 --- a/src/components/settings/security/update-password-card.tsx +++ b/src/components/settings/security/update-password-card.tsx @@ -114,7 +114,7 @@ export function UpdatePasswordCard({ className }: UpdatePasswordCardProps) { return ( From e3aa8eab55e4f6d99681d72a02529668d1d99a11 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 20:23:23 +0800 Subject: [PATCH 77/87] feat: add support for free plan users in credit packages and update billing card layout --- src/components/settings/billing/billing-card.tsx | 2 +- src/components/settings/credits/credit-packages.tsx | 10 +++++++++- src/config/website.tsx | 2 +- src/types/index.d.ts | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index a969701..737f4d0 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -170,7 +170,7 @@ export default function BillingCard() { {/* Plan name and status */} -
+
{currentPlan?.name}
{subscription && ( diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 8aedc1b..3c539be 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -11,6 +11,7 @@ import { 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'; @@ -24,14 +25,21 @@ import { CreditCheckoutButton } from './credit-checkout-button'; export function CreditPackages() { const t = useTranslations('Dashboard.settings.credits.packages'); - // Get current user + // Get current user and payment info const currentUser = useCurrentUser(); + const { currentPlan } = usePayment(); // Don't render if credits are disabled if (!websiteConfig.credits.enableCredits) { return null; } + // 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 diff --git a/src/config/website.tsx b/src/config/website.tsx index 4cdefa2..6d820a1 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -146,12 +146,12 @@ export const websiteConfig: WebsiteConfig = { }, credits: { enableCredits: true, + enableForFreePlan: false, registerGiftCredits: { enable: true, credits: 100, expireDays: 30, }, - packages: { basic: { id: 'basic', diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 39fd6d3..dc291f7 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -155,6 +155,7 @@ export interface PriceConfig { */ 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 From ac02ea780a1452b1892975be18794070661d2078 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 21:29:28 +0800 Subject: [PATCH 78/87] feat: add credit statistics in credits balance card --- .gitignore | 3 + messages/en.json | 7 +- messages/zh.json | 10 +- src/actions/get-credit-stats.ts | 106 ++++++++++++++++++ .../settings/billing/credits-balance-card.tsx | 91 ++++++++++++++- src/config/website.tsx | 4 +- 6 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 src/actions/get-credit-stats.ts diff --git a/.gitignore b/.gitignore index 98722b3..db544e5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ certificates # vercel .vercel +# claude code +.claude + # typescript *.tsbuildinfo next-env.d.ts diff --git a/messages/en.json b/messages/en.json index 70fab14..028aefd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -584,7 +584,7 @@ }, "credits": { "title": "Credits", - "description": "Manage your credits", + "description": "Manage your credit transactions", "balance": { "title": "Credit Balance", "description": "Your credit balance", @@ -592,7 +592,10 @@ "creditsDescription": "You have {credits} credits", "creditsExpired": "Credits expired", "creditsAdded": "Credits have been added to your account", - "viewTransactions": "View Transactions" + "viewTransactions": "View Credit Transactions", + "subscriptionCredits": "{credits} credits from subscription this month", + "lifetimeCredits": "{credits} credits from lifetime plan this month", + "expiringCredits": "{credits} credits expiring on {date}" }, "packages": { "balance": "Credit Balance", diff --git a/messages/zh.json b/messages/zh.json index 94199fe..86bbabe 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -585,12 +585,18 @@ }, "credits": { "title": "积分", - "description": "管理您的积分", + "description": "管理您的积分和消费记录", "balance": { "title": "积分余额", + "description": "您的积分余额", "credits": "积分", "creditsDescription": "您有 {credits} 积分", - "creditsExpired": "积分已过期" + "creditsExpired": "积分已过期", + "creditsAdded": "积分已添加到您的账户", + "viewTransactions": "查看积分记录", + "subscriptionCredits": "本月订阅获得 {credits} 积分", + "lifetimeCredits": "本月终身会员获得 {credits} 积分", + "expiringCredits": "{credits} 积分将在 {date} 过期" }, "packages": { "balance": "积分余额", diff --git a/src/actions/get-credit-stats.ts b/src/actions/get-credit-stats.ts new file mode 100644 index 0000000..2c6f652 --- /dev/null +++ b/src/actions/get-credit-stats.ts @@ -0,0 +1,106 @@ +'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'; + +// 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) { + return { + success: false, + error: 'Unauthorized', + }; + } + + const db = await getDb(); + const userId = session.user.id; + + // Get credits expiring in the next 30 days + const thirtyDaysFromNow = addDays(new Date(), 30); + const expiringCredits = await db + .select({ + amount: sum(creditTransaction.remainingAmount), + earliestExpiration: sql`MIN(${creditTransaction.expirationDate})`, + }) + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + isNotNull(creditTransaction.expirationDate), + isNotNull(creditTransaction.remainingAmount), + gte(creditTransaction.remainingAmount, 1), + lte(creditTransaction.expirationDate, thirtyDaysFromNow), + gte(creditTransaction.expirationDate, new Date()) + ) + ); + + // Get credits from subscription renewals (recent 30 days) + const thirtyDaysAgo = addDays(new Date(), -30); + 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, thirtyDaysAgo) + ) + ); + + // Get credits from monthly lifetime distribution (recent 30 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, thirtyDaysAgo) + ) + ); + + 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', + }; + } +}); diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx index 9e8813d..a64ee4c 100644 --- a/src/components/settings/billing/credits-balance-card.tsx +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -1,5 +1,6 @@ 'use client'; +import { getCreditStatsAction } from '@/actions/get-credit-stats'; import { Button } from '@/components/ui/button'; import { Card, @@ -12,13 +13,14 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { websiteConfig } from '@/config/website'; import { useCredits } from '@/hooks/use-credits'; +import { usePayment } from '@/hooks/use-payment'; import { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; +import { formatDate } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { Routes } from '@/routes'; -import { CoinsIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; export default function CreditsBalanceCard() { @@ -30,11 +32,47 @@ export default function CreditsBalanceCard() { // Use the credits hook to get balance const { balance, isLoading, error, refresh } = 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 [statsLoading, setStatsLoading] = useState(false); + // Don't render if credits are disabled if (!websiteConfig.credits.enableCredits) { return null; } + // Function to fetch credit statistics + const fetchCreditStats = async () => { + setStatsLoading(true); + try { + const result = await getCreditStatsAction(); + if (result?.data?.success && result.data.data) { + setCreditStats(result.data.data); + } else { + console.error('Failed to fetch credit stats:', result?.data?.error); + } + } catch (error) { + console.error('Failed to fetch credit stats:', error); + } finally { + setStatsLoading(false); + } + }; + + // Fetch stats on component mount + useEffect(() => { + fetchCreditStats(); + }, []); + // Check for payment success and show success message useEffect(() => { const sessionId = searchParams.get('session_id'); @@ -47,6 +85,8 @@ export default function CreditsBalanceCard() { // Refresh credits data to show updated balance refresh(); + // Refresh credit stats + fetchCreditStats(); // Clean up URL parameters const url = new URL(window.location.href); @@ -106,18 +146,57 @@ export default function CreditsBalanceCard() { {/* Credits balance display */} -
+
- + {/* */}
{balance.toLocaleString()}
- {/* {t('available')} */} + {/* available */}
{/* Balance information */} - {/*
{t('message')}
*/} +
+ {/* Plan-based credits info */} + {!statsLoading && creditStats && ( + <> + {/* Subscription credits (for paid plans) */} + {!currentPlan?.isFree && + (creditStats.subscriptionCredits.amount > 0 || + creditStats.lifetimeCredits.amount > 0) && ( +
+ + {currentPlan?.isLifetime + ? t('lifetimeCredits', { + credits: creditStats.lifetimeCredits.amount, + }) + : t('subscriptionCredits', { + credits: creditStats.subscriptionCredits.amount, + })} + +
+ )} + + {/* Expiring credits warning */} + {creditStats.expiringCredits.amount > 0 && + creditStats.expiringCredits.earliestExpiration && ( +
+ + {t('expiringCredits', { + credits: creditStats.expiringCredits.amount, + date: formatDate( + new Date( + creditStats.expiringCredits.earliestExpiration + ) + ), + })} + +
+ )} + + )} +
{nextBillingDate && ( -
+
{t('nextBillingDate')} {nextBillingDate}
)} {subscription.status === 'trialing' && subscription.currentPeriodEnd && ( -
+
{t('trialEnds')} {formatDate(subscription.currentPeriodEnd)}
)} diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx index a64ee4c..a5e9f1b 100644 --- a/src/components/settings/billing/credits-balance-card.tsx +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -181,7 +181,7 @@ export default function CreditsBalanceCard() { {/* Expiring credits warning */} {creditStats.expiringCredits.amount > 0 && creditStats.expiringCredits.earliestExpiration && ( -
+
{t('expiringCredits', { credits: creditStats.expiringCredits.amount, From 9fcfb3bdf7d470565f3b72f6b120b6a6aa081f0a Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 22:14:04 +0800 Subject: [PATCH 80/87] refactor: update credit and transaction messages --- messages/en.json | 10 ++-------- messages/zh.json | 14 ++++---------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/messages/en.json b/messages/en.json index 028aefd..6d36c79 100644 --- a/messages/en.json +++ b/messages/en.json @@ -598,7 +598,6 @@ "expiringCredits": "{credits} credits expiring on {date}" }, "packages": { - "balance": "Credit Balance", "title": "Credit Packages", "description": "Purchase additional credits to use our services", "purchase": "Purchase", @@ -608,21 +607,17 @@ "failedToFetchCredits": "Failed to fetch credits", "failedToCreatePaymentIntent": "Failed to create payment intent", "failedToInitiatePayment": "Failed to initiate payment", - "creditsAdded": "Credits have been added to your account", "cancel": "Cancel", "purchaseFailed": "Purchase credits failed", "checkoutFailed": "Failed to create checkout session", "loading": "Loading...", "pay": "Pay" }, - "tabs": { - "balance": "Balance", - "transactions": "Transactions" - }, "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", @@ -636,11 +631,10 @@ "createdAt": "Created At", "updatedAt": "Updated At" }, - "paymentIdCopied": "Payment ID copied to clipboard", "types": { "MONTHLY_REFRESH": "Monthly Refresh", "REGISTER_GIFT": "Register Gift", - "PURCHASE": "Purchased Credits", + "PURCHASE_PACKAGE": "Purchased Credits", "USAGE": "Consumed Credits", "EXPIRE": "Expired Credits", "SUBSCRIPTION_RENEWAL": "Subscription Renewal", diff --git a/messages/zh.json b/messages/zh.json index 86bbabe..674010e 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -585,7 +585,7 @@ }, "credits": { "title": "积分", - "description": "管理您的积分和消费记录", + "description": "管理您的积分交易", "balance": { "title": "积分余额", "description": "您的积分余额", @@ -599,7 +599,6 @@ "expiringCredits": "{credits} 积分将在 {date} 过期" }, "packages": { - "balance": "积分余额", "title": "积分套餐", "description": "购买积分以使用我们的更多服务", "purchase": "购买", @@ -609,21 +608,17 @@ "failedToFetchCredits": "获取积分失败", "failedToCreatePaymentIntent": "创建付款意向失败", "failedToInitiatePayment": "发起付款失败", - "creditsAdded": "积分已添加到您的账户", "cancel": "取消", "purchaseFailed": "购买积分失败", "checkoutFailed": "创建支付会话失败", "loading": "加载中...", "pay": "支付" }, - "tabs": { - "balance": "积分余额", - "transactions": "积分记录" - }, "transactions": { "title": "积分记录", "error": "获取积分交易记录失败", "search": "搜索积分交易记录...", + "paymentIdCopied": "支付ID已复制到剪贴板", "columns": { "columns": "列", "id": "ID", @@ -637,11 +632,10 @@ "createdAt": "创建时间", "updatedAt": "更新时间" }, - "paymentIdCopied": "支付ID已复制到剪贴板", "types": { "MONTHLY_REFRESH": "每月赠送", "REGISTER_GIFT": "注册赠送", - "PURCHASE": "购买积分", + "PURCHASE_PACKAGE": "购买积分", "USAGE": "使用积分", "EXPIRE": "过期积分", "SUBSCRIPTION_RENEWAL": "订阅月度积分", @@ -652,7 +646,7 @@ "close": "关闭" }, "expired": "已过期", - "never": "永不" + "never": "永不过期" } }, "notification": { From 141b5623070ecbfdfcc3c2a485e26d34db5b16a6 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 22:36:17 +0800 Subject: [PATCH 81/87] refactor: improve loading state management in billing page --- messages/en.json | 2 +- messages/zh.json | 2 +- src/actions/get-credit-stats.ts | 7 +++- .../settings/billing/billing-card.tsx | 14 +++++-- .../settings/billing/credits-balance-card.tsx | 38 ++++++++++++------- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/messages/en.json b/messages/en.json index 6d36c79..df82b0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -634,7 +634,7 @@ "types": { "MONTHLY_REFRESH": "Monthly Refresh", "REGISTER_GIFT": "Register Gift", - "PURCHASE_PACKAGE": "Purchased Credits", + "PURCHASE": "Purchased Credits", "USAGE": "Consumed Credits", "EXPIRE": "Expired Credits", "SUBSCRIPTION_RENEWAL": "Subscription Renewal", diff --git a/messages/zh.json b/messages/zh.json index 674010e..1e4ba2a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -635,7 +635,7 @@ "types": { "MONTHLY_REFRESH": "每月赠送", "REGISTER_GIFT": "注册赠送", - "PURCHASE_PACKAGE": "购买积分", + "PURCHASE": "购买积分", "USAGE": "使用积分", "EXPIRE": "过期积分", "SUBSCRIPTION_RENEWAL": "订阅月度积分", diff --git a/src/actions/get-credit-stats.ts b/src/actions/get-credit-stats.ts index 2c6f652..e5e2106 100644 --- a/src/actions/get-credit-stats.ts +++ b/src/actions/get-credit-stats.ts @@ -8,6 +8,9 @@ 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 = 30; +const CREDITS_MONTHLY_DAYS = 30; + // Create a safe action client const actionClient = createSafeActionClient(); @@ -28,7 +31,7 @@ export const getCreditStatsAction = actionClient.action(async () => { const userId = session.user.id; // Get credits expiring in the next 30 days - const thirtyDaysFromNow = addDays(new Date(), 30); + const thirtyDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS); const expiringCredits = await db .select({ amount: sum(creditTransaction.remainingAmount), @@ -47,7 +50,7 @@ export const getCreditStatsAction = actionClient.action(async () => { ); // Get credits from subscription renewals (recent 30 days) - const thirtyDaysAgo = addDays(new Date(), -30); + const thirtyDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS); const subscriptionCredits = await db .select({ amount: sum(creditTransaction.amount), diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index 51827b3..253fefb 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -92,7 +92,9 @@ export default function BillingCard() { return ( - {t('currentPlan.title')} + + {t('currentPlan.title')} + {t('currentPlan.description')} @@ -103,7 +105,7 @@ export default function BillingCard() {
- + ); @@ -114,7 +116,9 @@ export default function BillingCard() { return ( - {t('currentPlan.title')} + + {t('currentPlan.title')} + {t('currentPlan.description')} @@ -139,7 +143,9 @@ export default function BillingCard() { return ( - {t('currentPlan.title')} + + {t('currentPlan.title')} + {t('currentPlan.description')} diff --git a/src/components/settings/billing/credits-balance-card.tsx b/src/components/settings/billing/credits-balance-card.tsx index a5e9f1b..504b1ee 100644 --- a/src/components/settings/billing/credits-balance-card.tsx +++ b/src/components/settings/billing/credits-balance-card.tsx @@ -18,6 +18,7 @@ import { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; import { formatDate } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { Routes } from '@/routes'; +import { Loader2Icon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; @@ -30,7 +31,12 @@ export default function CreditsBalanceCard() { const hasHandledSession = useRef(false); // Use the credits hook to get balance - const { balance, isLoading, error, refresh } = useCredits(); + const { + balance, + isLoading: isLoadingBalance, + error, + refresh: refreshBalance, + } = useCredits(); // Get payment info to check plan type const { currentPlan } = usePayment(); @@ -44,7 +50,7 @@ export default function CreditsBalanceCard() { subscriptionCredits: { amount: number }; lifetimeCredits: { amount: number }; } | null>(null); - const [statsLoading, setStatsLoading] = useState(false); + const [isLoadingStats, setIsLoadingStats] = useState(true); // Don't render if credits are disabled if (!websiteConfig.credits.enableCredits) { @@ -53,7 +59,7 @@ export default function CreditsBalanceCard() { // Function to fetch credit statistics const fetchCreditStats = async () => { - setStatsLoading(true); + setIsLoadingStats(true); try { const result = await getCreditStatsAction(); if (result?.data?.success && result.data.data) { @@ -64,7 +70,7 @@ export default function CreditsBalanceCard() { } catch (error) { console.error('Failed to fetch credit stats:', error); } finally { - setStatsLoading(false); + setIsLoadingStats(false); } }; @@ -84,7 +90,7 @@ export default function CreditsBalanceCard() { }, 0); // Refresh credits data to show updated balance - refresh(); + refreshBalance(); // Refresh credit stats fetchCreditStats(); @@ -93,24 +99,28 @@ export default function CreditsBalanceCard() { url.searchParams.delete('session_id'); localeRouter.replace(Routes.SettingsBilling + url.search); } - }, [searchParams, localeRouter, refresh]); + }, [searchParams, localeRouter, refreshBalance, fetchCreditStats]); // Render loading skeleton - if (isLoading) { + const isPageLoading = isLoadingBalance || isLoadingStats; + if (isPageLoading) { return ( - {t('title')} + {t('title')} {t('description')} -
- - +
+ +
+
+ +
- + ); @@ -121,7 +131,7 @@ export default function CreditsBalanceCard() { return ( - {t('title')} + {t('title')} {t('description')} @@ -159,7 +169,7 @@ export default function CreditsBalanceCard() { {/* Balance information */}
{/* Plan-based credits info */} - {!statsLoading && creditStats && ( + {!isLoadingStats && creditStats && ( <> {/* Subscription credits (for paid plans) */} {!currentPlan?.isFree && From 8a08dfdf3ba071ad005336d08135d3110f5962b0 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 22:42:03 +0800 Subject: [PATCH 82/87] refactor: update feature labels and standardize FAQ component naming in zh.json --- messages/zh.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/messages/zh.json b/messages/zh.json index 1e4ba2a..eba0948 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -108,9 +108,8 @@ "feature-3": "专属支持", "feature-4": "企业级安全", "feature-5": "高级集成", - "feature-6": "自定义域名", - "feature-7": "自定义品牌", - "feature-8": "终身更新" + "feature-6": "自定义品牌", + "feature-7": "终身更新" } } }, @@ -408,13 +407,13 @@ "comparator": { "title": "Comparator 组件" }, - "faqs": { - "title": "FAQs 组件" + "faq": { + "title": "FAQ 组件" }, "login": { "title": "Login 组件" }, - "sign-up": { + "signup": { "title": "Signup 组件" }, "forgot-password": { From 31116cbf8ba0b087a0a1e91d91f6c2ecdc8e0e81 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sat, 12 Jul 2025 23:41:50 +0800 Subject: [PATCH 83/87] refactor: remove unused Stripe dependency and update credit expiration logic --- messages/zh.json | 6 +++--- package.json | 2 -- pnpm-lock.yaml | 17 --------------- src/actions/get-credit-stats.ts | 21 ++++++++++--------- .../credits/credit-detail-viewer.tsx | 2 +- .../credits/credit-transactions-table.tsx | 2 +- src/credits/credits.ts | 10 ++++----- 7 files changed, 21 insertions(+), 39 deletions(-) rename src/{ => components/settings}/credits/credit-detail-viewer.tsx (99%) diff --git a/messages/zh.json b/messages/zh.json index eba0948..d28a31d 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -11,9 +11,9 @@ "language": "切换语言", "mode": { "label": "切换模式", - "light": "浅色", - "dark": "深色", - "system": "系统" + "light": "浅色模式", + "dark": "深色模式", + "system": "跟随系统" }, "theme": { "label": "切换主题", diff --git a/package.json b/package.json index 7aa5760..642592b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "private": true, "scripts": { "dev": "next dev", - "dev-https": "next dev --experimental-https", "build": "next build", "start": "next start", "postinstall": "fumadocs-mdx", @@ -73,7 +72,6 @@ "@radix-ui/react-tooltip": "^1.1.8", "@react-email/components": "0.0.33", "@react-email/render": "1.0.5", - "@stripe/react-stripe-js": "^3.7.0", "@stripe/stripe-js": "^5.6.0", "@tabler/icons-react": "^3.31.0", "@tanstack/react-table": "^8.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e60a6d3..5a22915 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,9 +152,6 @@ importers: '@react-email/render': specifier: 1.0.5 version: 1.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@stripe/react-stripe-js': - specifier: ^3.7.0 - version: 3.7.0(@stripe/stripe-js@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@stripe/stripe-js': specifier: ^5.6.0 version: 5.6.0 @@ -3854,13 +3851,6 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@stripe/react-stripe-js@3.7.0': - resolution: {integrity: sha512-PYls/2S9l0FF+2n0wHaEJsEU8x7CmBagiH7zYOsxbBlLIHEsqUIQ4MlIAbV9Zg6xwT8jlYdlRIyBTHmO3yM7kQ==} - peerDependencies: - '@stripe/stripe-js': '>=1.44.1 <8.0.0' - react: '>=16.8.0 <20.0.0' - react-dom: '>=16.8.0 <20.0.0' - '@stripe/stripe-js@5.6.0': resolution: {integrity: sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==} engines: {node: '>=12.16'} @@ -10003,13 +9993,6 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@stripe/react-stripe-js@3.7.0(@stripe/stripe-js@5.6.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': - dependencies: - '@stripe/stripe-js': 5.6.0 - prop-types: 15.8.1 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - '@stripe/stripe-js@5.6.0': {} '@swc/counter@0.1.3': {} diff --git a/src/actions/get-credit-stats.ts b/src/actions/get-credit-stats.ts index e5e2106..d97cc94 100644 --- a/src/actions/get-credit-stats.ts +++ b/src/actions/get-credit-stats.ts @@ -8,8 +8,8 @@ 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 = 30; -const CREDITS_MONTHLY_DAYS = 30; +const CREDITS_EXPIRATION_DAYS = 31; +const CREDITS_MONTHLY_DAYS = 31; // Create a safe action client const actionClient = createSafeActionClient(); @@ -21,6 +21,7 @@ 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', @@ -30,8 +31,8 @@ export const getCreditStatsAction = actionClient.action(async () => { const db = await getDb(); const userId = session.user.id; - // Get credits expiring in the next 30 days - const thirtyDaysFromNow = addDays(new Date(), CREDITS_EXPIRATION_DAYS); + // 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), @@ -44,13 +45,13 @@ export const getCreditStatsAction = actionClient.action(async () => { isNotNull(creditTransaction.expirationDate), isNotNull(creditTransaction.remainingAmount), gte(creditTransaction.remainingAmount, 1), - lte(creditTransaction.expirationDate, thirtyDaysFromNow), + lte(creditTransaction.expirationDate, expirationDaysFromNow), gte(creditTransaction.expirationDate, new Date()) ) ); - // Get credits from subscription renewals (recent 30 days) - const thirtyDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS); + // 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), @@ -63,11 +64,11 @@ export const getCreditStatsAction = actionClient.action(async () => { creditTransaction.type, CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL ), - gte(creditTransaction.createdAt, thirtyDaysAgo) + gte(creditTransaction.createdAt, monthlyRefreshDaysAgo) ) ); - // Get credits from monthly lifetime distribution (recent 30 days) + // Get credits from monthly lifetime distribution (recent CREDITS_MONTHLY_DAYS days) const lifetimeCredits = await db .select({ amount: sum(creditTransaction.amount), @@ -77,7 +78,7 @@ export const getCreditStatsAction = actionClient.action(async () => { and( eq(creditTransaction.userId, userId), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY), - gte(creditTransaction.createdAt, thirtyDaysAgo) + gte(creditTransaction.createdAt, monthlyRefreshDaysAgo) ) ); diff --git a/src/credits/credit-detail-viewer.tsx b/src/components/settings/credits/credit-detail-viewer.tsx similarity index 99% rename from src/credits/credit-detail-viewer.tsx rename to src/components/settings/credits/credit-detail-viewer.tsx index 3842313..d1f33d9 100644 --- a/src/credits/credit-detail-viewer.tsx +++ b/src/components/settings/credits/credit-detail-viewer.tsx @@ -23,7 +23,7 @@ import { } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; -import { CREDIT_TRANSACTION_TYPE } from './types'; +import { CREDIT_TRANSACTION_TYPE } from '../../../credits/types'; // Define the credit transaction interface (matching the one in the table) export interface CreditTransaction { diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 9136e5a..0e1fd51 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CreditDetailViewer } from '@/components/settings/credits/credit-detail-viewer'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -31,7 +32,6 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { CreditDetailViewer } from '@/credits/credit-detail-viewer'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { formatDate } from '@/lib/formatter'; import { CaretDownIcon, CaretUpIcon } from '@radix-ui/react-icons'; diff --git a/src/credits/credits.ts b/src/credits/credits.ts index bc02471..b686961 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -118,18 +118,18 @@ export async function addCredits({ }) { if (!userId || !type || !description) { console.error('addCredits, invalid params', userId, type, description); - throw new Error('addCredits, invalid params'); + throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { console.error('addCredits, invalid amount', userId, amount); - throw new Error('addCredits, invalid 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('addCredits, invalid expire days'); + throw new Error('Invalid expire days'); } // Process expired credits first await processExpiredCredits(userId); @@ -201,11 +201,11 @@ export async function consumeCredits({ }) { if (!userId || !description) { console.error('consumeCredits, invalid params', userId, description); - throw new Error('consumeCredits, invalid params'); + throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { console.error('consumeCredits, invalid amount', userId, amount); - throw new Error('consumeCredits, invalid amount'); + throw new Error('Invalid amount'); } // Process expired credits first await processExpiredCredits(userId); From 4313e32471cc371effb5cd60b607f61bf2ffe912 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 13 Jul 2025 00:09:05 +0800 Subject: [PATCH 84/87] refactor: conditionally render credits-related components based on configuration --- messages/en.json | 1 + messages/zh.json | 1 + .../layout/credits-balance-button.tsx | 6 +++ .../layout/credits-balance-menu.tsx | 6 +++ .../settings/billing/billing-card.tsx | 45 +++++++++++++------ src/config/sidebar-config.tsx | 17 ++++--- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/messages/en.json b/messages/en.json index df82b0a..5b9d562 100644 --- a/messages/en.json +++ b/messages/en.json @@ -571,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", diff --git a/messages/zh.json b/messages/zh.json index d28a31d..73a7acc 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -571,6 +571,7 @@ "createCustomerPortalFailed": "打开Stripe客户界面失败" }, "price": "价格:", + "periodStartDate": "周期开始日期:", "nextBillingDate": "下次账单日期:", "trialEnds": "试用结束日期:", "freePlanMessage": "您当前使用的是功能有限的免费方案", diff --git a/src/components/layout/credits-balance-button.tsx b/src/components/layout/credits-balance-button.tsx index 1077b85..bf4c677 100644 --- a/src/components/layout/credits-balance-button.tsx +++ b/src/components/layout/credits-balance-button.tsx @@ -1,12 +1,18 @@ '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 diff --git a/src/components/layout/credits-balance-menu.tsx b/src/components/layout/credits-balance-menu.tsx index 1b386ea..0155099 100644 --- a/src/components/layout/credits-balance-menu.tsx +++ b/src/components/layout/credits-balance-menu.tsx @@ -1,5 +1,6 @@ 'use client'; +import { websiteConfig } from '@/config/website'; import { useCredits } from '@/hooks/use-credits'; import { useLocaleRouter } from '@/i18n/navigation'; import { Routes } from '@/routes'; @@ -7,6 +8,11 @@ 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(); diff --git a/src/components/settings/billing/billing-card.tsx b/src/components/settings/billing/billing-card.tsx index 253fefb..21bb902 100644 --- a/src/components/settings/billing/billing-card.tsx +++ b/src/components/settings/billing/billing-card.tsx @@ -20,7 +20,7 @@ import { formatDate, formatPrice } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { PlanIntervals } from '@/payment/types'; import { Routes } from '@/routes'; -import { RefreshCwIcon } from 'lucide-react'; +import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; import { useEffect, useRef } from 'react'; @@ -63,6 +63,11 @@ export default function BillingCard() { (price) => price.priceId === subscription?.priceId ); + // Get current period start date + const currentPeriodStart = subscription?.currentPeriodStart + ? formatDate(subscription.currentPeriodStart) + : null; + // Format next billing date if subscription is active const nextBillingDate = subscription?.currentPeriodEnd ? formatDate(subscription.currentPeriodEnd) @@ -178,15 +183,23 @@ export default function BillingCard() { {/* Plan name and status */}
{currentPlan?.name}
- {subscription && ( - - {subscription?.status === 'trialing' - ? t('status.trial') - : subscription?.status === 'active' - ? t('status.active') - : ''} - - )} + {subscription && + (subscription.status === 'trialing' || + subscription.status === 'active') && ( + + {subscription.status === 'trialing' ? ( +
+ + {t('status.trial')} +
+ ) : ( +
+ + {t('status.active')} +
+ )} +
+ )}
{/* Free plan message */} @@ -206,7 +219,7 @@ export default function BillingCard() { {/* Subscription plan message */} {subscription && currentPrice && (
-
+ {/*
{t('price')}{' '} {formatPrice(currentPrice.amount, currentPrice.currency)} /{' '} {currentPrice.interval === PlanIntervals.MONTH @@ -214,10 +227,16 @@ export default function BillingCard() { : currentPrice.interval === PlanIntervals.YEAR ? t('interval.year') : t('interval.oneTime')} -
+
*/} + + {currentPeriodStart && ( +
+ {t('periodStartDate')} {currentPeriodStart} +
+ )} {nextBillingDate && ( -
+
{t('nextBillingDate')} {nextBillingDate}
)} diff --git a/src/config/sidebar-config.tsx b/src/config/sidebar-config.tsx index 2dd17cf..316aaa6 100644 --- a/src/config/sidebar-config.tsx +++ b/src/config/sidebar-config.tsx @@ -14,6 +14,7 @@ import { UsersRoundIcon, } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { websiteConfig } from './website'; /** * Get sidebar config with translations @@ -67,12 +68,16 @@ export function getSidebarLinks(): NestedMenuItem[] { href: Routes.SettingsBilling, external: false, }, - { - title: t('settings.credits.title'), - icon: , - href: Routes.SettingsCredits, - external: false, - }, + ...(websiteConfig.credits.enableCredits + ? [ + { + title: t('settings.credits.title'), + icon: , + href: Routes.SettingsCredits, + external: false, + }, + ] + : []), { title: t('settings.security.title'), icon: , From 7af313868c727b5bf501c713e2d0f9ab03e0a732 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 13 Jul 2025 00:36:02 +0800 Subject: [PATCH 85/87] refactor: rename resetState to resetCreditsState and remove unused updateBalanceOptimistically method --- src/components/layout/user-button.tsx | 1 - .../settings/credits/credit-packages.tsx | 10 +++++----- src/hooks/use-credits.ts | 2 -- src/payment/types.ts | 2 +- src/providers/credits-provider.tsx | 6 +++--- src/stores/credits-store.ts | 18 ++---------------- 6 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/components/layout/user-button.tsx b/src/components/layout/user-button.tsx index b3d04a2..d48bc0c 100644 --- a/src/components/layout/user-button.tsx +++ b/src/components/layout/user-button.tsx @@ -18,7 +18,6 @@ import { LogOutIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { toast } from 'sonner'; -import { CreditsBalanceButton } from './credits-balance-button'; import { CreditsBalanceMenu } from './credits-balance-menu'; interface UserButtonProps { diff --git a/src/components/settings/credits/credit-packages.tsx b/src/components/settings/credits/credit-packages.tsx index 3c539be..6d983b8 100644 --- a/src/components/settings/credits/credit-packages.tsx +++ b/src/components/settings/credits/credit-packages.tsx @@ -23,17 +23,17 @@ import { CreditCheckoutButton } from './credit-checkout-button'; * @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(); - // Don't render if credits are disabled - if (!websiteConfig.credits.enableCredits) { - return null; - } - // Check if user is on free plan and enableForFreePlan is false const isFreePlan = currentPlan?.isFree === true; if (isFreePlan && !websiteConfig.credits.enableForFreePlan) { diff --git a/src/hooks/use-credits.ts b/src/hooks/use-credits.ts index dfb6373..933d1a6 100644 --- a/src/hooks/use-credits.ts +++ b/src/hooks/use-credits.ts @@ -16,7 +16,6 @@ export function useCredits() { fetchCredits, consumeCredits, refreshCredits, - updateBalanceOptimistically, } = useCreditsStore(); const { data: session } = authClient.useSession(); @@ -56,7 +55,6 @@ export function useCredits() { // Methods consumeCredits, - updateBalanceOptimistically, // Utility methods refetch, diff --git a/src/payment/types.ts b/src/payment/types.ts index e07f87c..ae087fe 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -55,7 +55,7 @@ export interface Price { */ export interface Credits { enable: boolean; // Whether to enable credits for this plan - amount: number; // Number of credits provided + amount: number; // Number of credits provided per month expireDays?: number; // Number of days until credits expire, undefined means no expiration } diff --git a/src/providers/credits-provider.tsx b/src/providers/credits-provider.tsx index 3ac8ca5..4f8232a 100644 --- a/src/providers/credits-provider.tsx +++ b/src/providers/credits-provider.tsx @@ -12,7 +12,7 @@ import { useEffect } from 'react'; */ export function CreditsProvider({ children }: { children: React.ReactNode }) { const user = useCurrentUser(); - const { fetchCredits, resetState } = useCreditsStore(); + const { fetchCredits, resetCreditsState } = useCreditsStore(); useEffect(() => { if (user) { @@ -20,9 +20,9 @@ export function CreditsProvider({ children }: { children: React.ReactNode }) { fetchCredits(user); } else { // User is logged out, reset the credits state - resetState(); + resetCreditsState(); } - }, [user, fetchCredits, resetState]); + }, [user, fetchCredits, resetCreditsState]); return <>{children}; } diff --git a/src/stores/credits-store.ts b/src/stores/credits-store.ts index 8421c1e..2723b5f 100644 --- a/src/stores/credits-store.ts +++ b/src/stores/credits-store.ts @@ -20,9 +20,7 @@ export interface CreditsState { fetchCredits: (user: Session['user'] | null | undefined) => Promise; consumeCredits: (amount: number, description: string) => Promise; refreshCredits: (user: Session['user'] | null | undefined) => Promise; - resetState: () => void; - // For optimistic updates - updateBalanceOptimistically: (amount: number) => void; + resetCreditsState: () => void; } // Cache duration: 30 seconds @@ -192,22 +190,10 @@ export const useCreditsStore = create((set, get) => ({ } }, - /** - * Update balance optimistically (for external credit additions) - * @param amount Amount to add to current balance - */ - updateBalanceOptimistically: (amount: number) => { - const { balance } = get(); - set({ - balance: balance + amount, - lastFetchTime: null, // Clear cache to fetch fresh data next time - }); - }, - /** * Reset credits state */ - resetState: () => { + resetCreditsState: () => { set({ balance: 0, isLoading: false, From 52aeb2d61c8ec21e3d6f1e714da981a9ed4383ff Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 13 Jul 2025 00:37:01 +0800 Subject: [PATCH 86/87] refactor: rename functions to remove 'IfNeed' suffix for clarity and consistency --- src/credits/credits.ts | 14 +++++++------- src/lib/auth.ts | 4 ++-- src/payment/provider/stripe.ts | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/credits/credits.ts b/src/credits/credits.ts index b686961..fb9cb86 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -397,12 +397,12 @@ export async function addRegisterGiftCredits(userId: string) { * Add free monthly credits * @param userId - User ID */ -export async function addMonthlyFreeCreditsIfNeed(userId: string) { +export async function addMonthlyFreeCredits(userId: string) { const freePlan = Object.values(websiteConfig.price.plans).find( (plan) => plan.isFree ); if (!freePlan) { - console.log('addMonthlyFreeCreditsIfNeed, no free plan found'); + console.log('addMonthlyFreeCredits, no free plan found'); return; } if ( @@ -411,7 +411,7 @@ export async function addMonthlyFreeCreditsIfNeed(userId: string) { !freePlan.credits?.amount ) { console.log( - 'addMonthlyFreeCreditsIfNeed, plan disabled or credits disabled', + 'addMonthlyFreeCredits, plan disabled or credits disabled', freePlan.id ); return; @@ -448,7 +448,7 @@ export async function addMonthlyFreeCreditsIfNeed(userId: string) { }); console.log( - `addMonthlyFreeCreditsIfNeed, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + `addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` ); } } @@ -496,7 +496,7 @@ export async function addSubscriptionRenewalCredits( * Add lifetime monthly credits * @param userId - User ID */ -export async function addLifetimeMonthlyCreditsIfNeed(userId: string) { +export async function addLifetimeMonthlyCredits(userId: string) { const lifetimePlan = Object.values(websiteConfig.price.plans).find( (plan) => plan.isLifetime ); @@ -601,12 +601,12 @@ export async function distributeCreditsToAllUsers() { if (pricePlan?.isLifetime) { // Lifetime user - add monthly credits - await addLifetimeMonthlyCreditsIfNeed(userRecord.userId); + 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 addMonthlyFreeCreditsIfNeed(userRecord.userId); + await addMonthlyFreeCredits(userRecord.userId); } processedCount++; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index da6e297..c034285 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,6 @@ import { websiteConfig } from '@/config/website'; import { - addMonthlyFreeCreditsIfNeed, + addMonthlyFreeCredits, addRegisterGiftCredits, } from '@/credits/credits'; import { getDb } from '@/db/index'; @@ -201,7 +201,7 @@ async function onCreateUser(user: User) { freePlan?.credits?.amount > 0 ) { try { - await addMonthlyFreeCreditsIfNeed(user.id); + await addMonthlyFreeCredits(user.id); const credits = freePlan.credits.amount; console.log( `added free monthly credits for user ${user.id}, credits: ${credits}` diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index ce01252..90cf270 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { websiteConfig } from '@/config/website'; import { addCredits, - addLifetimeMonthlyCreditsIfNeed, + addLifetimeMonthlyCredits, addSubscriptionRenewalCredits, } from '@/credits/credits'; import { getCreditPackageById } from '@/credits/server'; @@ -796,7 +796,7 @@ export class StripeProvider implements PaymentProvider { websiteConfig.price?.plans || {} ).find((plan) => plan.isLifetime && plan.credits?.enable); if (lifetimePlan?.prices?.some((p) => p.priceId === priceId)) { - await addLifetimeMonthlyCreditsIfNeed(userId); + await addLifetimeMonthlyCredits(userId); } } From f5e639bbc70750bc5b0b9013243be76e4c468831 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 13 Jul 2025 00:53:31 +0800 Subject: [PATCH 87/87] refactor: improve plan filtering in credits logic --- src/actions/get-credit-transactions.ts | 13 +++---------- src/actions/get-users.ts | 7 ++++++- src/credits/credits.ts | 4 ++-- src/payment/provider/stripe.ts | 4 +++- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index f346b9e..ded99f0 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -1,7 +1,7 @@ 'use server'; import { getDb } from '@/db'; -import { creditTransaction, user } from '@/db/schema'; +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'; @@ -60,6 +60,7 @@ export const getCreditTransactionsAction = actionClient or( ilike(creditTransaction.type, `%${search}%`), ilike(creditTransaction.amount, `%${search}%`), + ilike(creditTransaction.remainingAmount, `%${search}%`), ilike(creditTransaction.paymentId, `%${search}%`), ilike(creditTransaction.description, `%${search}%`) ) @@ -76,7 +77,7 @@ export const getCreditTransactionsAction = actionClient const sortDirection = sortConfig?.desc ? desc : asc; const db = await getDb(); - let [items, [{ count }]] = await Promise.all([ + const [items, [{ count }]] = await Promise.all([ db .select({ id: creditTransaction.id, @@ -103,14 +104,6 @@ export const getCreditTransactionsAction = actionClient .where(where), ]); - // hide user data in demo website - if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') { - items = items.map((item) => ({ - ...item, - paymentId: item.paymentId ? 'pi_demo123456' : null, - })); - } - return { success: true, data: { diff --git a/src/actions/get-users.ts b/src/actions/get-users.ts index f01702a..afd68c7 100644 --- a/src/actions/get-users.ts +++ b/src/actions/get-users.ts @@ -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; diff --git a/src/credits/credits.ts b/src/credits/credits.ts index fb9cb86..00be186 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -399,7 +399,7 @@ export async function addRegisterGiftCredits(userId: string) { */ export async function addMonthlyFreeCredits(userId: string) { const freePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isFree + (plan) => plan.isFree && !plan.disabled ); if (!freePlan) { console.log('addMonthlyFreeCredits, no free plan found'); @@ -498,7 +498,7 @@ export async function addSubscriptionRenewalCredits( */ export async function addLifetimeMonthlyCredits(userId: string) { const lifetimePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isLifetime + (plan) => plan.isLifetime && !plan.disabled ); if ( !lifetimePlan || diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 90cf270..c917527 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -794,7 +794,9 @@ export class StripeProvider implements PaymentProvider { // 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.credits?.enable); + ).find( + (plan) => plan.isLifetime && !plan.disabled && plan.credits?.enable + ); if (lifetimePlan?.prices?.some((p) => p.priceId === priceId)) { await addLifetimeMonthlyCredits(userId); }