feat(tenders): enhance pagination functionality and styling

- Added absoluteCurrentPage to the useTenderListPresenter for better pagination state management.
- Updated the TendersTable component to utilize absoluteCurrentPage in the pagination key.
- Enhanced the Pagination component to support a new "tenders" variant for improved styling.
- Introduced custom styles for the active page in the tenders pagination, ensuring a distinct visual representation.
- Refactored the useHybridPagination hook to maintain accurate page tracking with cursor navigation.
This commit is contained in:
AmirReza Jamali
2026-05-20 15:48:29 +03:30
parent f080d51e63
commit f6eaac94c6
5 changed files with 144 additions and 33 deletions
+3
View File
@@ -46,6 +46,7 @@ const TendersTable = () => {
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination, pagination,
absoluteCurrentPage,
} = useTenderListPresenter(); } = useTenderListPresenter();
return ( return (
@@ -155,12 +156,14 @@ const TendersTable = () => {
</div> </div>
<IsVisible condition={shouldShowPagination}> <IsVisible condition={shouldShowPagination}>
<Pagination <Pagination
key={`pagination-tenders-${absoluteCurrentPage}`}
currentPage={pagination.currentPage} currentPage={pagination.currentPage}
totalPages={pagination.totalPages} totalPages={pagination.totalPages}
onPageChange={handlePaginationChange} onPageChange={handlePaginationChange}
pageRangeDisplayed={3} pageRangeDisplayed={3}
marginPagesDisplayed={1} marginPagesDisplayed={1}
pageLabelBuilder={buildWindowedPageLabel} pageLabelBuilder={buildWindowedPageLabel}
variant="tenders"
/> />
</IsVisible> </IsVisible>
</ListWrapper> </ListWrapper>
@@ -539,6 +539,7 @@ const useTenderListPresenter = () => {
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination: windowedPagination, pagination: windowedPagination,
absoluteCurrentPage: pagination.currentPage,
}; };
}; };
+22 -4
View File
@@ -1,5 +1,8 @@
import { FC, Fragment } from "react"; import { FC } from "react";
import ReactPaginate, { ReactPaginateProps } from "react-paginate"; import ReactPaginate, { ReactPaginateProps } from "react-paginate";
type PaginationVariant = "default" | "tenders";
interface IProps { interface IProps {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
@@ -7,8 +10,21 @@ interface IProps {
pageRangeDisplayed?: number; pageRangeDisplayed?: number;
marginPagesDisplayed?: number; marginPagesDisplayed?: number;
pageLabelBuilder?: ReactPaginateProps["pageLabelBuilder"]; pageLabelBuilder?: ReactPaginateProps["pageLabelBuilder"];
variant?: PaginationVariant;
} }
const activeClassNames: Record<PaginationVariant, string> = {
default:
"flex h-9 min-w-9 items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br from-primary/85 via-primary/75 to-primary/65 px-3 text-sm font-semibold !text-white shadow-theme-xs shadow-primary/20 backdrop-blur-md transition-all duration-300 before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:rounded-t-xl before:bg-gradient-to-b before:from-white/30 before:to-transparent dark:border-white/20 dark:before:from-white/20",
tenders:
"pagination-tenders-active relative flex h-9 min-w-9 items-center justify-center rounded-xl border border-white/35 !bg-gradient-to-br !from-primary/85 !via-primary/75 !to-primary/65 px-3 text-sm font-semibold !text-white shadow-theme-xs shadow-primary/20 backdrop-blur-md transition-all duration-300 before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:rounded-t-xl before:bg-gradient-to-b before:from-white/30 before:to-transparent hover:!border-white/35 hover:!text-white dark:!from-primary/85 dark:!via-primary/75 dark:!to-primary/65 dark:border-white/20 dark:before:from-white/20",
};
const activeLinkClassNames: Record<PaginationVariant, string | undefined> = {
default: undefined,
tenders: "!text-white",
};
/** Full-cell hit target: styles live on <li>, but react-paginate puts clicks on the inner <a>. */ /** Full-cell hit target: styles live on <li>, but react-paginate puts clicks on the inner <a>. */
const pageLinkClassName = const pageLinkClassName =
"absolute inset-0 z-[1] flex cursor-pointer items-center justify-center select-none"; "absolute inset-0 z-[1] flex cursor-pointer items-center justify-center select-none";
@@ -20,9 +36,10 @@ const Pagination: FC<IProps> = ({
pageRangeDisplayed = 5, pageRangeDisplayed = 5,
marginPagesDisplayed = 3, marginPagesDisplayed = 3,
pageLabelBuilder, pageLabelBuilder,
variant = "default",
}) => { }) => {
return ( return (
<Fragment> <nav data-pagination-variant={variant} aria-label="Pagination">
<ReactPaginate <ReactPaginate
key={currentPage} key={currentPage}
pageCount={totalPages} pageCount={totalPages}
@@ -32,7 +49,8 @@ const Pagination: FC<IProps> = ({
pageRangeDisplayed={pageRangeDisplayed} pageRangeDisplayed={pageRangeDisplayed}
marginPagesDisplayed={marginPagesDisplayed} marginPagesDisplayed={marginPagesDisplayed}
pageLabelBuilder={pageLabelBuilder} pageLabelBuilder={pageLabelBuilder}
activeClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br from-primary/85 via-primary/75 to-primary/65 px-3 text-sm font-semibold !text-white shadow-theme-xs shadow-primary/20 backdrop-blur-md transition-all duration-300 before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:rounded-t-xl before:bg-gradient-to-b before:from-white/30 before:to-transparent dark:border-white/20 dark:before:from-white/20" activeClassName={activeClassNames[variant]}
activeLinkClassName={activeLinkClassNames[variant]}
onPageChange={(e) => { onPageChange={(e) => {
if (!onPageChange) return; if (!onPageChange) return;
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
@@ -46,7 +64,7 @@ const Pagination: FC<IProps> = ({
previousLinkClassName={pageLinkClassName} previousLinkClassName={pageLinkClassName}
nextLinkClassName={pageLinkClassName} nextLinkClassName={pageLinkClassName}
/> />
</Fragment> </nav>
); );
}; };
+18
View File
@@ -545,6 +545,24 @@ span.flatpickr-weekday,
@apply !visible; @apply !visible;
} }
/* Tenders table — active page (react-paginate merges page + active classes on <li>) */
nav[data-pagination-variant="tenders"] li.pagination-tenders-active {
background-color: #5750f1 !important;
background-image: linear-gradient(
to bottom right,
rgb(87 80 241 / 0.85),
rgb(87 80 241 / 0.75),
rgb(87 80 241 / 0.65)
) !important;
border-color: rgb(255 255 255 / 0.35) !important;
box-shadow: 0 1px 2px 0 rgb(87 80 241 / 0.25) !important;
color: #fff !important;
}
nav[data-pagination-variant="tenders"] li.pagination-tenders-active a {
color: #fff !important;
}
.data-table-two .datatable-bottom { .data-table-two .datatable-bottom {
@apply flex flex-col gap-4 px-8 py-7 after:hidden sm:flex-row sm:items-center sm:justify-between sm:gap-0 sm:space-x-4; @apply flex flex-col gap-4 px-8 py-7 after:hidden sm:flex-row sm:items-center sm:justify-between sm:gap-0 sm:space-x-4;
} }
+91 -20
View File
@@ -19,6 +19,10 @@ type UseHybridPaginationOptions = {
error?: unknown; error?: unknown;
}; };
/** Each push records the absolute page we were on when this cursor was added,
* so a later pop can fall back to that page via offset if needed. */
type CursorStackEntry = { cursor: string; fromPage: number };
export const useHybridPagination = ({ export const useHybridPagination = ({
meta, meta,
params, params,
@@ -26,9 +30,23 @@ export const useHybridPagination = ({
isError, isError,
error, error,
}: UseHybridPaginationOptions) => { }: UseHybridPaginationOptions) => {
const [cursorStack, setCursorStack] = useState<string[]>([]); const [cursorStack, setCursorStack] = useState<CursorStackEntry[]>([]);
const [lastValidPages, setLastValidPages] = useState<number>(1);
const [lastValidLimit, setLastValidLimit] = useState<number>(10);
const limit = meta?.limit ?? Number(params.limit) ?? 10; const limit = meta?.limit ?? Number(params.limit) ?? lastValidLimit;
useEffect(() => {
if (meta?.limit != null) {
setLastValidLimit(meta.limit);
}
}, [meta?.limit]);
useEffect(() => {
if (meta?.pages != null) {
setLastValidPages(meta.pages);
}
}, [meta?.pages]);
const clearCursorStack = useCallback(() => { const clearCursorStack = useCallback(() => {
setCursorStack([]); setCursorStack([]);
@@ -49,19 +67,40 @@ export const useHybridPagination = ({
}, [isError, error, resetToFirstPage]); }, [isError, error, resetToFirstPage]);
const hasNext = Boolean(meta?.has_more && meta?.next_cursor); const hasNext = Boolean(meta?.has_more && meta?.next_cursor);
const hasPrevious = cursorStack.length > 0 || (meta?.page ?? 1) > 1;
/** 0-based page index; do not trust meta.page alone while cursor pages are active. */
const currentPageIndex = useMemo(() => { const currentPageIndex = useMemo(() => {
const pageSize = limit > 0 ? limit : 1;
if (cursorStack.length > 0) {
const last = cursorStack[cursorStack.length - 1];
return Math.max(0, last.fromPage - 1 + cursorStack.length);
}
const paramsCursor =
typeof params.cursor === "string" ? params.cursor.trim() : "";
const paramsOffset = Number(params.offset);
if (!paramsCursor && Number.isFinite(paramsOffset) && paramsOffset >= 0) {
return Math.floor(paramsOffset / pageSize);
}
const metaOffset = Number(meta?.offset);
if (Number.isFinite(metaOffset) && metaOffset >= 0) {
return Math.floor(metaOffset / pageSize);
}
if (meta?.page != null) return meta.page - 1; if (meta?.page != null) return meta.page - 1;
return cursorStack.length; return 0;
}, [cursorStack.length, meta?.page]); }, [cursorStack, params.cursor, params.offset, meta?.offset, meta?.page, limit]);
const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0;
const pagination = useMemo( const pagination = useMemo(
() => ({ () => ({
currentPage: currentPageIndex, currentPage: currentPageIndex,
totalPages: meta?.pages ?? 1, totalPages: meta?.pages ?? lastValidPages,
}), }),
[currentPageIndex, meta?.pages], [currentPageIndex, meta?.pages, lastValidPages],
); );
const goToOffsetPage = useCallback( const goToOffsetPage = useCallback(
@@ -80,29 +119,39 @@ export const useHybridPagination = ({
[clearCursorStack, limit, setParams], [clearCursorStack, limit, setParams],
); );
const goToNextCursorPage = useCallback(() => { const goToNextCursorPage = useCallback(
(fromPage1Based: number) => {
const nextCursor = meta?.next_cursor; const nextCursor = meta?.next_cursor;
if (!meta?.has_more || !nextCursor) return; if (!meta?.has_more || !nextCursor) return;
setCursorStack((stack) => [...stack, nextCursor]); setCursorStack((stack) => [
...stack,
{ cursor: nextCursor, fromPage: fromPage1Based },
]);
setParams((current) => { setParams((current) => {
const next: Record<string, any> = { ...current, cursor: nextCursor, limit }; const next: Record<string, any> = { ...current, cursor: nextCursor, limit };
delete next.offset; delete next.offset;
return next; return next;
}); });
}, [limit, meta?.has_more, meta?.next_cursor, setParams]); },
[limit, meta?.has_more, meta?.next_cursor, setParams],
);
const goToPreviousPage = useCallback(() => { const goToPreviousPage = useCallback(() => {
if (cursorStack.length > 0) { if (cursorStack.length > 0) {
const poppedEntry = cursorStack[cursorStack.length - 1];
const nextStack = cursorStack.slice(0, -1); const nextStack = cursorStack.slice(0, -1);
const previousCursor = nextStack[nextStack.length - 1]; const previousEntry = nextStack[nextStack.length - 1];
setCursorStack(nextStack); setCursorStack(nextStack);
setParams((current) => { setParams((current) => {
const next: Record<string, any> = { ...current, limit }; const next: Record<string, any> = { ...current, limit };
if (previousCursor) { if (previousEntry) {
next.cursor = previousCursor; next.cursor = previousEntry.cursor;
delete next.offset; delete next.offset;
} else if (poppedEntry.fromPage > 1) {
next.offset = (poppedEntry.fromPage - 1) * limit;
delete next.cursor;
} else { } else {
next.offset = 0; next.offset = 0;
delete next.cursor; delete next.cursor;
@@ -112,14 +161,20 @@ export const useHybridPagination = ({
return; return;
} }
const page = meta?.page ?? 1; if (currentPageIndex <= 0) {
if (page <= 1) {
resetToFirstPage(); resetToFirstPage();
return; return;
} }
goToOffsetPage(page - 2); goToOffsetPage(currentPageIndex - 1);
}, [cursorStack, goToOffsetPage, limit, meta?.page, resetToFirstPage, setParams]); }, [
cursorStack,
currentPageIndex,
goToOffsetPage,
limit,
resetToFirstPage,
setParams,
]);
const handlePaginationChange = useCallback( const handlePaginationChange = useCallback(
(e: { selected: number }) => { (e: { selected: number }) => {
@@ -127,24 +182,40 @@ export const useHybridPagination = ({
const delta = targetIndex - currentPageIndex; const delta = targetIndex - currentPageIndex;
if (delta === 1 && hasNext) { if (delta === 1 && hasNext) {
goToNextCursorPage(); goToNextCursorPage(currentPageIndex + 1);
return; return;
} }
if (delta === -1 && hasPrevious) { if (delta === -1) {
const cursorReturnIndex =
cursorStack.length > 0
? cursorStack[cursorStack.length - 1].fromPage - 1
: null;
if (
cursorReturnIndex != null &&
targetIndex === cursorReturnIndex &&
cursorStack.length > 0
) {
goToPreviousPage(); goToPreviousPage();
return; return;
} }
if (targetIndex >= 0) {
goToOffsetPage(targetIndex);
return;
}
}
goToOffsetPage(targetIndex); goToOffsetPage(targetIndex);
}, },
[ [
currentPageIndex, currentPageIndex,
cursorStack,
goToNextCursorPage, goToNextCursorPage,
goToOffsetPage, goToOffsetPage,
goToPreviousPage, goToPreviousPage,
hasNext, hasNext,
hasPrevious,
], ],
); );