From 797ee9b7e5d1d0ea21ba350e8fc47edeff9eb603 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 28 Aug 2025 00:47:37 +0800 Subject: [PATCH 1/2] feat: add nuqs package and integrate NuqsAdapter in layout and users page components --- package.json | 1 + pnpm-lock.yaml | 31 +++++++++++++++++ src/app/[locale]/layout.tsx | 19 ++++++----- src/components/admin/users-page.tsx | 51 +++++++++++++++++++++------- src/components/admin/users-table.tsx | 32 +++++++++++++---- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 247b99e..4e9ca5c 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "next-intl": "^4.0.0", "next-safe-action": "^7.10.4", "next-themes": "^0.4.4", + "nuqs": "^2.5.1", "postgres": "^3.4.5", "radix-ui": "^1.4.2", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af570d..0154037 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: next-themes: specifier: ^0.4.4 version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + nuqs: + specifier: ^2.5.1 + version: 2.5.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))(react@19.0.0) postgres: specifier: ^3.4.5 version: 3.4.5 @@ -5388,6 +5391,27 @@ packages: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + nuqs@2.5.1: + resolution: {integrity: sha512-YvAyI01gaEfS6U2iTcfffKccGkqYRnGmLoCHvDjK4ShgtB0tKmYgC7+ez9PmdaiDmrLR+y1qHzfQC66T0VFwWQ==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^6 || ^7 + react-router-dom: ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -11439,6 +11463,13 @@ snapshots: npm-to-yarn@3.0.1: {} + nuqs@2.5.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))(react@19.0.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.0.0 + 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) + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0932f26..611e83e 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -12,6 +12,7 @@ import { routing } from '@/i18n/routing'; import { cn } from '@/lib/utils'; import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl'; import { notFound } from 'next/navigation'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import type { ReactNode } from 'react'; import { Toaster } from 'sonner'; import { Providers } from './providers'; @@ -57,15 +58,17 @@ export default async function LocaleLayout({ fontBricolageGrotesque.variable )} > - - - {children} + + + + {children} - - - - - + + + + + + ); diff --git a/src/components/admin/users-page.tsx b/src/components/admin/users-page.tsx index dfd94b0..39f4a7f 100644 --- a/src/components/admin/users-page.tsx +++ b/src/components/admin/users-page.tsx @@ -4,31 +4,56 @@ import { UsersTable } from '@/components/admin/users-table'; import { useUsers } from '@/hooks/use-users'; import type { SortingState } from '@tanstack/react-table'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { + parseAsIndex, + parseAsInteger, + parseAsString, + useQueryStates, +} from 'nuqs'; +import { useMemo } from 'react'; export function UsersPageClient() { const t = useTranslations('Dashboard.admin.users'); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [search, setSearch] = useState(''); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); - const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting); + const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] = + useQueryStates({ + page: parseAsIndex.withDefault(0), // parseAsIndex adds +1 to URL, so 0-based internally, 1-based in URL + pageSize: parseAsInteger.withDefault(10), + search: parseAsString.withDefault(''), + sortId: parseAsString.withDefault('createdAt'), + sortDesc: parseAsInteger.withDefault(1), + }); + + const sorting: SortingState = useMemo( + () => [{ id: sortId, desc: Boolean(sortDesc) }], + [sortId, sortDesc] + ); + + // page is already 0-based internally thanks to parseAsIndex + const { data, isLoading } = useUsers(page, pageSize, search, sorting); return ( setQueryStates({ search: newSearch, page: 0 })} + onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })} + onPageSizeChange={(newPageSize) => + setQueryStates({ pageSize: newPageSize, page: 0 }) + } + onSortingChange={(newSorting) => { + if (newSorting.length > 0) { + setQueryStates({ + sortId: newSorting[0].id, + sortDesc: newSorting[0].desc ? 1 : 0, + }); + } + }} /> ); } diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index f9f54f6..baf21ee 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -59,6 +59,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '../ui/badge'; import { Label } from '../ui/label'; +import { Skeleton } from '../ui/skeleton'; interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -116,12 +117,27 @@ function DataTableColumnHeader({ ); } +function TableRowSkeleton({ columns }: { columns: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+ +
+
+ ))} +
+ ); +} + interface UsersTableProps { data: User[]; total: number; pageIndex: number; pageSize: number; search: string; + sorting?: SortingState; loading?: boolean; onSearch: (search: string) => void; onPageChange: (page: number) => void; @@ -138,6 +154,7 @@ export function UsersTable({ pageIndex, pageSize, search, + sorting = [{ id: 'createdAt', desc: true }], loading, onSearch, onPageChange, @@ -146,9 +163,6 @@ export function UsersTable({ }: UsersTableProps) { const t = useTranslations('Dashboard.admin.users'); const tTable = useTranslations('Common.table'); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -351,7 +365,6 @@ export function UsersTable({ }, onSortingChange: (updater) => { const next = typeof updater === 'function' ? updater(sorting) : updater; - setSorting(next); onSortingChange?.(next); }, onColumnFiltersChange: setColumnFilters, @@ -444,7 +457,12 @@ export function UsersTable({ ))} - {table.getRowModel().rows?.length ? ( + {loading ? ( + // Show skeleton rows while loading + Array.from({ length: pageSize }).map((_, index) => ( + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {loading ? tTable('loading') : tTable('noResults')} + {tTable('noResults')} )} @@ -497,7 +515,7 @@ export function UsersTable({ - {[10, 20, 30, 40, 50].map((pageSize) => ( + {[5, 10, 20, 30, 40, 50].map((pageSize) => ( {pageSize} From 6c584c75e230f9a75a2c16f6d3d1e3b57d1d398d Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 28 Aug 2025 01:08:48 +0800 Subject: [PATCH 2/2] feat: enhance CreditTransactions component with loading skeleton and refactor state management using useQueryStates --- src/actions/get-credit-transactions.ts | 29 ++++++++--- src/components/admin/users-table.tsx | 2 +- .../credits/credit-transactions-table.tsx | 30 ++++++++--- .../settings/credits/credit-transactions.tsx | 50 ++++++++++++++----- .../settings/credits/credits-page-client.tsx | 18 ++++++- 5 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/actions/get-credit-transactions.ts b/src/actions/get-credit-transactions.ts index ec40ae5..2e5b052 100644 --- a/src/actions/get-credit-transactions.ts +++ b/src/actions/get-credit-transactions.ts @@ -44,17 +44,30 @@ export const getCreditTransactionsAction = userActionClient const { pageIndex, pageSize, search, sorting } = parsedInput; const currentUser = (ctx as { user: User }).user; - // search by type, amount, paymentId, description, and restrict to current user + // Search logic: text fields use ilike, and if search is a number, also search amount fields + const searchConditions = []; + if (search) { + // Always search text fields + searchConditions.push( + ilike(creditTransaction.type, `%${search}%`), + ilike(creditTransaction.paymentId, `%${search}%`), + ilike(creditTransaction.description, `%${search}%`) + ); + + // If search is a valid number, also search numeric fields + const numericSearch = Number.parseInt(search, 10); + if (!Number.isNaN(numericSearch)) { + searchConditions.push( + eq(creditTransaction.amount, numericSearch), + eq(creditTransaction.remainingAmount, numericSearch) + ); + } + } + const where = search ? and( eq(creditTransaction.userId, currentUser.id), - or( - ilike(creditTransaction.type, `%${search}%`), - ilike(creditTransaction.amount, `%${search}%`), - ilike(creditTransaction.remainingAmount, `%${search}%`), - ilike(creditTransaction.paymentId, `%${search}%`), - ilike(creditTransaction.description, `%${search}%`) - ) + or(...searchConditions) ) : eq(creditTransaction.userId, currentUser.id); diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index baf21ee..2485f49 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -515,7 +515,7 @@ export function UsersTable({ - {[5, 10, 20, 30, 40, 50].map((pageSize) => ( + {[10, 20, 30, 40, 50].map((pageSize) => ( {pageSize} diff --git a/src/components/settings/credits/credit-transactions-table.tsx b/src/components/settings/credits/credit-transactions-table.tsx index 0e1fd51..39fedfa 100644 --- a/src/components/settings/credits/credit-transactions-table.tsx +++ b/src/components/settings/credits/credit-transactions-table.tsx @@ -76,6 +76,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '../../ui/badge'; import { Label } from '../../ui/label'; +import { Skeleton } from '../../ui/skeleton'; // Define the credit transaction interface export interface CreditTransaction { @@ -152,12 +153,27 @@ function DataTableColumnHeader({ ); } +function TableRowSkeleton({ columns }: { columns: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+ +
+
+ ))} +
+ ); +} + interface CreditTransactionsTableProps { data: CreditTransaction[]; total: number; pageIndex: number; pageSize: number; search: string; + sorting?: SortingState; loading?: boolean; onSearch: (search: string) => void; onPageChange: (page: number) => void; @@ -171,6 +187,7 @@ export function CreditTransactionsTable({ pageIndex, pageSize, search, + sorting = [{ id: 'createdAt', desc: true }], loading, onSearch, onPageChange, @@ -179,9 +196,6 @@ export function CreditTransactionsTable({ }: CreditTransactionsTableProps) { const t = useTranslations('Dashboard.settings.credits.transactions'); const tTable = useTranslations('Common.table'); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -449,7 +463,6 @@ export function CreditTransactionsTable({ }, onSortingChange: (updater) => { const next = typeof updater === 'function' ? updater(sorting) : updater; - setSorting(next); onSortingChange?.(next); }, onColumnFiltersChange: setColumnFilters, @@ -538,7 +551,12 @@ export function CreditTransactionsTable({ ))} - {table.getRowModel().rows?.length ? ( + {loading ? ( + // Show skeleton rows while loading + Array.from({ length: pageSize }).map((_, index) => ( + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {loading ? tTable('loading') : tTable('noResults')} + {tTable('noResults')} )} diff --git a/src/components/settings/credits/credit-transactions.tsx b/src/components/settings/credits/credit-transactions.tsx index 608f0c9..d9b0351 100644 --- a/src/components/settings/credits/credit-transactions.tsx +++ b/src/components/settings/credits/credit-transactions.tsx @@ -4,22 +4,36 @@ import { CreditTransactionsTable } from '@/components/settings/credits/credit-tr import { useCreditTransactions } from '@/hooks/use-credits'; import type { SortingState } from '@tanstack/react-table'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { + parseAsIndex, + parseAsInteger, + parseAsString, + useQueryStates, +} from 'nuqs'; +import { useMemo } from 'react'; /** * Credit transactions component */ export function CreditTransactions() { const t = useTranslations('Dashboard.settings.credits.transactions'); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [search, setSearch] = useState(''); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); + + const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] = + useQueryStates({ + page: parseAsIndex.withDefault(0), // 0-based internally, 1-based in URL + pageSize: parseAsInteger.withDefault(10), + search: parseAsString.withDefault(''), + sortId: parseAsString.withDefault('createdAt'), + sortDesc: parseAsInteger.withDefault(1), + }); + + const sorting: SortingState = useMemo( + () => [{ id: sortId, desc: Boolean(sortDesc) }], + [sortId, sortDesc] + ); const { data, isLoading } = useCreditTransactions( - pageIndex, + page, pageSize, search, sorting @@ -29,14 +43,24 @@ export function CreditTransactions() { setQueryStates({ search: newSearch, page: 0 })} + onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })} + onPageSizeChange={(newPageSize) => + setQueryStates({ pageSize: newPageSize, page: 0 }) + } + onSortingChange={(newSorting) => { + if (newSorting.length > 0) { + setQueryStates({ + sortId: newSorting[0].id, + sortDesc: newSorting[0].desc ? 1 : 0, + }); + } + }} /> ); } diff --git a/src/components/settings/credits/credits-page-client.tsx b/src/components/settings/credits/credits-page-client.tsx index dc5172f..34cedb8 100644 --- a/src/components/settings/credits/credits-page-client.tsx +++ b/src/components/settings/credits/credits-page-client.tsx @@ -5,6 +5,7 @@ import { CreditTransactions } from '@/components/settings/credits/credit-transac import CreditsBalanceCard from '@/components/settings/credits/credits-balance-card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useTranslations } from 'next-intl'; +import { parseAsStringLiteral, useQueryState } from 'nuqs'; /** * Credits page client, show credit balance and transactions @@ -12,9 +13,24 @@ import { useTranslations } from 'next-intl'; export default function CreditsPageClient() { const t = useTranslations('Dashboard.settings.credits'); + const [activeTab, setActiveTab] = useQueryState( + 'tab', + parseAsStringLiteral(['balance', 'transactions']).withDefault('balance') + ); + + const handleTabChange = (value: string) => { + if (value === 'balance' || value === 'transactions') { + setActiveTab(value); + } + }; + return (
- + {t('tabs.balance')}