fix: avoid duplicate billing portal link (#330)

* fix: avoid duplicate billing portal link
* fix: improve DataTable API
This commit is contained in:
Giancarlo Buomprisco
2025-08-26 05:30:18 +07:00
committed by GitHub
parent ad427365c9
commit f9ebe2f927
31 changed files with 1706 additions and 1085 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,12 @@
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
},
"dependencies": {
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/openai": "^2.0.20",
"@faker-js/faker": "^9.9.0",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "5.85.5",
"ai": "5.0.21",
"lucide-react": "^0.540.0",
"ai": "5.0.23",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"nodemailer": "^7.0.5",
"react": "19.1.1",
@@ -30,7 +30,7 @@
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/nodemailer": "7.0.1",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"pino-pretty": "13.0.0",

View File

@@ -62,13 +62,13 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
navigationTimeout: 15_000,
navigationTimeout: 15 * 1000,
},
// test timeout set to 2 minutes
timeout: 120 * 1000,
expect: {
// expect timeout set to 5 seconds
timeout: 5 * 1000,
// expect timeout set to 10 seconds
timeout: 10 * 1000,
},
/* Configure projects for major browsers */
projects: [

View File

@@ -388,11 +388,15 @@ async function filterAccounts(page: Page, email: string) {
.fill(email);
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
await page.waitForTimeout(500);
}
async function selectAccount(page: Page, email: string) {
await page.getByRole('link', { name: email.split('@')[0] }).click();
await page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a')
.click();
await page.waitForURL(new RegExp(`/admin/accounts/[a-z0-9-]+`));
await page.waitForTimeout(500);
}

View File

@@ -60,15 +60,14 @@ async function PersonalAccountBillingPage() {
<PageBody>
<div className={'flex flex-col space-y-4'}>
<If condition={!hasBillingData}>
<PersonalAccountCheckoutForm customerId={customerId} />
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</If>
<If condition={hasBillingData}>
<If
condition={hasBillingData}
fallback={
<>
<PersonalAccountCheckoutForm customerId={customerId} />
</>
}
>
<div className={'flex w-full max-w-2xl flex-col space-y-6'}>
<If condition={subscription}>
{(subscription) => {

View File

@@ -60,14 +60,14 @@
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"next-sitemap": "^4.2.3",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"recharts": "2.15.3",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.74"
@@ -79,10 +79,10 @@
"@next/bundle-analyzer": "15.5.0",
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cssnano": "^7.1.0",
"cssnano": "^7.1.1",
"pino-pretty": "13.0.0",
"prettier": "^3.6.2",
"supabase": "2.34.0",

View File

@@ -45,6 +45,7 @@
"skip": "Skip",
"signedInAs": "Signed in as",
"pageOfPages": "Page {{page}} of {{total}}",
"showingRecordCount": "Showing {{pageSize}} of {{totalCount}} rows",
"noData": "No data available",
"pageNotFoundHeading": "Ouch! :|",
"errorPageHeading": "Ouch! :|",

View File

@@ -27,13 +27,13 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.55.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"typesVersions": {

View File

@@ -24,7 +24,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"next": "15.5.0",
"react": "19.1.1",
"zod": "^3.25.74"

View File

@@ -27,7 +27,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"date-fns": "^4.1.0",
"next": "15.5.0",
"react": "19.1.1",

View File

@@ -27,7 +27,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -21,7 +21,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"wp-types": "^4.68.1"
},
"typesVersions": {

View File

@@ -36,15 +36,15 @@
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config",

View File

@@ -23,8 +23,8 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",

View File

@@ -31,11 +31,11 @@
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"sonner": "^2.0.7",
"zod": "^3.25.74"
},

View File

@@ -21,11 +21,11 @@
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.1"
"react-i18next": "^15.7.2"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -35,16 +35,16 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config",

View File

@@ -24,10 +24,10 @@
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.1"
"react-i18next": "^15.7.2"
},
"dependencies": {
"i18next": "25.4.0",
"i18next": "25.4.2",
"i18next-browser-languagedetector": "8.2.0",
"i18next-resources-to-backend": "^1.2.1"
},

View File

@@ -24,7 +24,7 @@
"@kit/sentry": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -24,7 +24,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -17,7 +17,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1"
},
"typesVersions": {

View File

@@ -24,7 +24,7 @@
"@kit/monitoring-core": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1"
},
"typesVersions": {

View File

@@ -26,7 +26,7 @@
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"react": "19.1.1",
"react-dom": "19.1.1",

View File

@@ -20,7 +20,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10"
"@types/react": "19.1.11"
},
"dependencies": {
"pino": "^9.8.0"

View File

@@ -25,10 +25,10 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/ssr": "^0.6.1",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"next": "15.5.0",
"react": "19.1.1",
"server-only": "^0.0.1",

View File

@@ -14,7 +14,7 @@
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"input-otp": "1.4.2",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"radix-ui": "1.4.3",
"react-dropzone": "^14.3.8",
"react-top-loading-bar": "3.0.2",
@@ -28,17 +28,17 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"eslint": "^9.33.0",
"eslint": "^9.34.0",
"next": "15.5.0",
"next-themes": "0.4.6",
"prettier": "^3.6.2",
"react-day-picker": "^9.9.0",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"sonner": "^2.0.7",
"tailwindcss": "4.1.12",
"tailwindcss-animate": "^1.0.7",

View File

@@ -1,10 +1,11 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Cell,
flexRender,
getCoreRowModel,
useReactTable,
@@ -49,6 +50,7 @@ export {
Row,
SortingState,
VisibilityState,
flexRender,
};
interface ReactTableProps<T extends DataItem> {
@@ -57,6 +59,8 @@ interface ReactTableProps<T extends DataItem> {
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
pageIndex?: number;
className?: string;
headerClassName?: string;
footerClassName?: string;
pageSize?: number;
pageCount?: number;
sorting?: SortingState;
@@ -69,14 +73,17 @@ interface ReactTableProps<T extends DataItem> {
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
onClick?: (row: Row<T>) => void;
onClick?: (props: { row: Row<T>; cell: Cell<T, unknown> }) => void;
tableProps?: React.ComponentProps<typeof Table> &
Record<`data-${string}`, string>;
sticky?: boolean;
renderCell?: (props: {
cell: Cell<T, unknown>;
style?: React.CSSProperties;
className?: string;
}) => (props: React.PropsWithChildren<object>) => React.ReactNode;
renderRow?: (props: {
row: Row<T>;
onClick?: (row: Row<T>) => void;
className?: string;
}) => (props: React.PropsWithChildren<object>) => React.ReactNode;
noResultsMessage?: React.ReactNode;
forcePagination?: boolean; // Force pagination to show even when pageCount <= 1
@@ -97,7 +104,10 @@ export function DataTable<RecordData extends DataItem>({
onClick,
tableProps,
className,
headerClassName,
footerClassName,
renderRow,
renderCell,
noResultsMessage,
sorting: controlledSorting,
columnVisibility: controlledColumnVisibility,
@@ -106,6 +116,9 @@ export function DataTable<RecordData extends DataItem>({
sticky = false,
forcePagination = false,
}: ReactTableProps<RecordData>) {
// TODO: remove when https://github.com/TanStack/table/issues/5567 gets fixed
'use no memo';
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 15,
@@ -127,19 +140,7 @@ export function DataTable<RecordData extends DataItem>({
controlledRowSelection ?? {},
);
// Use props if provided (controlled mode), otherwise use internal state (uncontrolled mode)
const columnVisibility =
controlledColumnVisibility ?? internalColumnVisibility;
const columnPinning = controlledColumnPinning ?? internalColumnPinning;
const rowSelection = controlledRowSelection ?? internalRowSelection;
if (pagination.pageIndex !== pageIndex && pageIndex !== undefined) {
setPagination({
pageIndex,
pageSize: pagination.pageSize,
});
}
// Computed values for table state - computed inline in callbacks for fresh values
const navigateToPage = useNavigateToNewPage();
@@ -155,7 +156,9 @@ export function DataTable<RecordData extends DataItem>({
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(columnVisibility);
const currentVisibility =
controlledColumnVisibility ?? internalColumnVisibility;
const nextState = updater(currentVisibility);
// If controlled mode (callback provided), call it
if (onColumnVisibilityChange) {
@@ -176,7 +179,8 @@ export function DataTable<RecordData extends DataItem>({
},
onColumnPinningChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(columnPinning);
const currentPinning = controlledColumnPinning ?? internalColumnPinning;
const nextState = updater(currentPinning);
// If controlled mode (callback provided), call it
if (onColumnPinningChange) {
@@ -197,7 +201,8 @@ export function DataTable<RecordData extends DataItem>({
},
onRowSelectionChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(rowSelection);
const currentSelection = controlledRowSelection ?? internalRowSelection;
const nextState = updater(currentSelection);
// If controlled mode (callback provided), call it
if (onRowSelectionChange) {
@@ -221,9 +226,9 @@ export function DataTable<RecordData extends DataItem>({
pagination,
sorting,
columnFilters,
columnVisibility,
columnPinning,
rowSelection,
columnVisibility: controlledColumnVisibility ?? internalColumnVisibility,
columnPinning: controlledColumnPinning ?? internalColumnPinning,
rowSelection: controlledRowSelection ?? internalRowSelection,
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
@@ -269,27 +274,12 @@ export function DataTable<RecordData extends DataItem>({
},
});
// Force table to update column pinning when controlled prop changes
useEffect(() => {
if (controlledColumnPinning) {
// Use the table's setColumnPinning method to force an update
table.setColumnPinning(controlledColumnPinning);
}
}, [controlledColumnPinning, table]);
// Force table to update column visibility when controlled prop changes
useEffect(() => {
if (controlledColumnVisibility) {
table.setColumnVisibility(controlledColumnVisibility);
}
}, [controlledColumnVisibility, table]);
// Force table to update row selection when controlled prop changes
useEffect(() => {
if (controlledRowSelection) {
table.setRowSelection(controlledRowSelection);
}
}, [controlledRowSelection, table]);
if (pagination.pageIndex !== pageIndex && pageIndex !== undefined) {
setPagination({
pageIndex,
pageSize: pagination.pageSize,
});
}
const rows = table.getRowModel().rows;
@@ -302,7 +292,7 @@ export function DataTable<RecordData extends DataItem>({
data-testid="data-table"
{...tableProps}
className={cn(
'bg-background border-separate border-spacing-0',
'bg-background border-collapse border-spacing-0',
className,
{
'h-full': data.length === 0,
@@ -310,8 +300,8 @@ export function DataTable<RecordData extends DataItem>({
)}
>
<TableHeader
className={cn('', {
['bg-background/20 outline-border sticky top-[0px] z-10 outline backdrop-blur-sm transition-all duration-300']:
className={cn(headerClassName, {
['bg-background/20 outline-border sticky top-[0px] z-10 outline backdrop-blur-sm']:
sticky,
})}
>
@@ -344,10 +334,10 @@ export function DataTable<RecordData extends DataItem>({
className={cn(
'text-muted-foreground bg-background/80 border-transparent font-sans font-medium',
{
['border-r-background sticky top-0 z-10 border-r opacity-95 backdrop-blur-sm']:
isPinned === 'left',
['border-l-background sticky top-0 z-10 border-l opacity-95 backdrop-blur-sm']:
isPinned === 'right',
['border-r-background border-r']: isPinned === 'left',
['border-l-background border-l']: isPinned === 'right',
['sticky top-0 z-10 opacity-95 backdrop-blur-sm']:
isPinned,
['relative z-0']: !isPinned,
},
)}
@@ -375,9 +365,7 @@ export function DataTable<RecordData extends DataItem>({
<TableBody>
{rows.map((row) => {
const RowWrapper = renderRow
? renderRow({ row, onClick })
: TableRow;
const RowWrapper = renderRow ? renderRow({ row }) : TableRow;
const children = row.getVisibleCells().map((cell, index) => {
const isPinned = cell.column.getIsPinned();
@@ -417,16 +405,23 @@ export function DataTable<RecordData extends DataItem>({
},
);
return (
const style = {
width: `${size}px`,
minWidth: `${size}px`,
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
};
return renderCell ? (
<Fragment key={cell.id}>
{renderCell({ cell, style, className })({})}
</Fragment>
) : (
<TableCell
style={{
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
width: `${size}px`,
minWidth: `${size}px`,
}}
key={cell.id}
style={style}
className={className}
onClick={onClick ? () => onClick({ row, cell }) : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
@@ -435,11 +430,12 @@ export function DataTable<RecordData extends DataItem>({
return (
<RowWrapper
className={cn('active:bg-accent bg-background/80', {
['hover:bg-accent/60 cursor-pointer']: !row.getIsSelected(),
})}
onClick={() => onClick && onClick(row)}
key={row.id}
className={cn('bg-background/80', {
'hover:bg-accent/60': !row.getIsSelected(),
'active:bg-accent': !!onClick,
'cursor-pointer': !!onClick && !row.getIsSelected(),
})}
data-state={row.getIsSelected() && 'selected'}
>
{children}
@@ -460,15 +456,22 @@ export function DataTable<RecordData extends DataItem>({
<If condition={displayPagination}>
<div
className={cn(
'bg-background/80 outline-border sticky bottom-0 z-10 border-b outline backdrop-blur-sm',
'bg-background/80 sticky bottom-0 z-10 border-t backdrop-blur-sm',
{
['sticky bottom-0 z-10 max-w-full rounded-none']: sticky,
},
footerClassName,
)}
>
<div>
<div className={'px-2.5 py-1.5'}>
<Pagination table={table} />
<Pagination
table={table}
pageSize={pageSize}
totalCount={
pageCount && pageSize ? pageCount * pageSize : undefined
}
/>
</div>
</div>
</div>
@@ -479,8 +482,12 @@ export function DataTable<RecordData extends DataItem>({
function Pagination<T>({
table,
totalCount,
pageSize,
}: React.PropsWithChildren<{
table: ReactTable<T>;
totalCount?: number;
pageSize?: number;
}>) {
return (
<div className="flex items-center space-x-4">
@@ -539,6 +546,15 @@ function Pagination<T>({
<ChevronsRight className={'h-4'} />
</Button>
</div>
<If condition={totalCount}>
<span className="text-muted-foreground flex items-center text-xs">
<Trans
i18nKey={'common:showingRecordCount'}
values={{ totalCount, pageSize }}
/>
</span>
</If>
</div>
);
}

View File

@@ -36,7 +36,7 @@ export function DataTable<TData, TValue>({
});
return (
<div className="rounded-md border">
<div className="rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

1488
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ export default tsEsLint.config(
},
{
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-anonymous-default-export': 'off',
'import/named': 'off',

View File

@@ -22,7 +22,7 @@
"devDependencies": {
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"eslint": "^9.33.0",
"eslint": "^9.34.0",
"typescript": "^5.9.2"
},
"prettier": "@kit/prettier-config"