From f4a425a6e598f0af2e01cc625605fa1e6f870409 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sun, 17 May 2026 17:01:28 +0330 Subject: [PATCH] feat(pagination): implement hybrid pagination across multiple components - Introduced a new `useHybridPagination` hook to manage pagination logic, enhancing data fetching and navigation. - Updated various components including `useAdminsPresenter`, `useCompanyListPresenter`, `useCustomerListPresenter`, and others to utilize the new pagination hook, improving code consistency and reducing duplication. - Refactored pagination handling in each component to streamline state management and improve user experience during data navigation. - Enhanced search functionality to reset pagination state when new search parameters are applied, ensuring accurate data display. --- .../Tables/admins/useAdminsPresenter.ts | 34 ++-- .../useCompanyCategoriesPresenter.ts | 34 ++-- .../companies/useCompanyListPresenter.ts | 32 ++-- .../contact-us/useContactUsListPresenter.ts | 34 ++-- .../customers/useCustomerListPresenter.ts | 34 ++-- .../useFeedbackTablePresenter.ts | 34 ++-- .../inquiries/useInquiriesListPresenter.ts | 34 ++-- .../useNotificationHistoryTablePresenter.ts | 35 ++-- .../Tables/tenders/useTenderListPresenter.ts | 42 ++--- src/components/ui/pagination.tsx | 3 +- src/hooks/useHybridPagination.ts | 171 ++++++++++++++++++ src/lib/api/types/Factory.ts | 16 +- 12 files changed, 292 insertions(+), 211 deletions(-) create mode 100644 src/hooks/useHybridPagination.ts diff --git a/src/components/Tables/admins/useAdminsPresenter.ts b/src/components/Tables/admins/useAdminsPresenter.ts index 2b83def..35e3018 100644 --- a/src/components/Tables/admins/useAdminsPresenter.ts +++ b/src/components/Tables/admins/useAdminsPresenter.ts @@ -8,6 +8,7 @@ import { useGetAdminListQuery, } from "@/hooks/queries"; import { IUser, TChangeAdminStatusCredentials } from "@/lib/api"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared"; import gsap from "gsap"; import { useIsMutating } from "@tanstack/react-query"; @@ -70,25 +71,26 @@ export const useAdminsPresenter = () => { filterFormReset(newParams); }, [searchParams, filterFormReset]); - const { data, isPending } = useGetAdminListQuery({ + const { data, isPending, isError, error } = useGetAdminListQuery({ ...apiDefaultParams, ...params, }); + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params: { ...apiDefaultParams, ...params }, + setParams, + isError, + error, + }); + const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; const usersList = data?.data?.users ?? []; const metadata = data?.meta; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - const getRowNumber = useCallback( (index: number) => getPaginatedRowNumber({ @@ -99,18 +101,6 @@ export const useAdminsPresenter = () => { [data?.meta?.limit, data?.meta?.page], ); - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit, setParams], - ); - const navigateToEditAdmin = useCallback( (userId: string) => { router.push(`/admins/edit/${userId}`); @@ -193,7 +183,7 @@ export const useAdminsPresenter = () => { }); const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); diff --git a/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts b/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts index 972587c..ef1f244 100644 --- a/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts +++ b/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts @@ -5,6 +5,7 @@ import { useDeleteCompanyCategoryQuery, useToggleCompanyCategoryPublished, } from "@/hooks/queries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { TCompanyCategory } from "@/lib/api"; import { deleteEmptyKeys } from "@/utils/shared"; @@ -49,7 +50,16 @@ const useCompanyCategoriesPresenter = () => { filterFormReset(newParams); }, [searchParams, filterFormReset]); - const { data, isLoading, isError } = useCompanyCategoriesQuery(params); + const { data, isLoading, isError, error } = useCompanyCategoriesQuery(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isLoading, data, { tableSectionRef, @@ -63,26 +73,6 @@ const useCompanyCategoriesPresenter = () => { !isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const [optimisticCategories, addOptimisticPublished] = useOptimistic< TCompanyCategory[], TogglePublishedOptimistic @@ -96,7 +86,7 @@ const useCompanyCategoriesPresenter = () => { useToggleCompanyCategoryPublished(); const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); diff --git a/src/components/Tables/companies/useCompanyListPresenter.ts b/src/components/Tables/companies/useCompanyListPresenter.ts index b63da18..751fbfe 100644 --- a/src/components/Tables/companies/useCompanyListPresenter.ts +++ b/src/components/Tables/companies/useCompanyListPresenter.ts @@ -5,6 +5,7 @@ import { useCompaniesQuery, useDeleteCompanyQuery, } from "@/hooks/queries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { TCompany } from "@/lib/api"; import { deleteEmptyKeys } from "@/utils/shared"; @@ -65,6 +66,15 @@ export const useCompanyListPresenter = () => { const { data, error, isPending, isError } = useCompaniesQuery(params); + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); + useTableSectionRowAnimations(isPending, data, { tableSectionRef, skeletonTbodyRef, @@ -79,7 +89,7 @@ export const useCompanyListPresenter = () => { ); const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); @@ -89,26 +99,6 @@ export const useCompanyListPresenter = () => { router.push(pathName); }, [pathName, router]); - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const onConfirmDelete = () => { if (currentCompany) { deleteCompany(currentCompany.id); diff --git a/src/components/Tables/contact-us/useContactUsListPresenter.ts b/src/components/Tables/contact-us/useContactUsListPresenter.ts index a6c2f16..6470359 100644 --- a/src/components/Tables/contact-us/useContactUsListPresenter.ts +++ b/src/components/Tables/contact-us/useContactUsListPresenter.ts @@ -4,6 +4,7 @@ import { useDeleteContactUs, useReadAllContactUs, } from "@/hooks/queries/useContactUsQuery"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { TContact } from "@/lib/api/types/TContacts"; import { deleteEmptyKeys } from "@/utils/shared"; @@ -55,7 +56,16 @@ export const useContactUsListPresenter = () => { filterFormReset(newParams as any); }, [searchParams, filterFormReset]); - const { data, isPending } = useReadAllContactUs(params); + const { data, isPending, isError, error } = useReadAllContactUs(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isPending, data, { tableSectionRef, @@ -70,28 +80,8 @@ export const useContactUsListPresenter = () => { const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams( cleanedParams as Record, diff --git a/src/components/Tables/customers/useCustomerListPresenter.ts b/src/components/Tables/customers/useCustomerListPresenter.ts index a0a2d18..74d4a04 100644 --- a/src/components/Tables/customers/useCustomerListPresenter.ts +++ b/src/components/Tables/customers/useCustomerListPresenter.ts @@ -6,6 +6,7 @@ import { useCustomersQuery, useDeleteCustomerQuery, } from "@/hooks/queries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api"; import { apiDefaultParams } from "@/constants/Api_Params"; @@ -59,7 +60,16 @@ const useCustomerListPresenter = () => { filterFormReset(newParams); }, [searchParams, filterFormReset]); - const { data, isPending } = useCustomersQuery(params); + const { data, isPending, isError, error } = useCustomersQuery(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isPending, data, { tableSectionRef, @@ -81,7 +91,7 @@ const useCustomerListPresenter = () => { ); const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); @@ -112,26 +122,6 @@ const useCustomerListPresenter = () => { const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - return { allCustomers: customers, metadata: data?.meta, diff --git a/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts b/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts index 1fe9d7b..fc30fbd 100644 --- a/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts +++ b/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts @@ -1,6 +1,7 @@ "use client"; import { apiDefaultParams } from "@/constants/Api_Params"; import { useGetFeedback } from "@/hooks/queries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { deleteEmptyKeys } from "@/utils/shared"; import { useIsMutating } from "@tanstack/react-query"; @@ -50,7 +51,16 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => { [params, paramKey, id], ); - const { data, isLoading } = useGetFeedback(queryParams); + const { data, isLoading, isError, error } = useGetFeedback(queryParams); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params: queryParams, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isLoading, data, { tableSectionRef, @@ -70,28 +80,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => { !isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const search = (formData: any) => { - const next = { ...params, ...formData, offset: 0 }; + const next = buildFirstPageParams({ ...params, ...formData }); const cleaned = deleteEmptyKeys(next); const queryString = new URLSearchParams( cleaned as Record, diff --git a/src/components/Tables/inquiries/useInquiriesListPresenter.ts b/src/components/Tables/inquiries/useInquiriesListPresenter.ts index 05d5bb2..d9f45cd 100644 --- a/src/components/Tables/inquiries/useInquiriesListPresenter.ts +++ b/src/components/Tables/inquiries/useInquiriesListPresenter.ts @@ -4,6 +4,7 @@ import { useDeleteInquiry, useGetInquiries, } from "@/hooks/queries/useInquiryQueries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { TInquiries } from "@/lib/api"; import { deleteEmptyKeys } from "@/utils/shared"; @@ -55,7 +56,16 @@ const useInquiriesListPresenter = () => { filterFormReset(newParams); }, [searchParams, filterFormReset]); - const { data, isPending } = useGetInquiries(params); + const { data, isPending, isError, error } = useGetInquiries(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isPending, data, { tableSectionRef, @@ -66,32 +76,12 @@ const useInquiriesListPresenter = () => { const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const { mutate } = useDeleteInquiry(() => setIsDeleteModalOpen(false)); const { mutate: changeStatus } = useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false)); const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathname}?${queryString}`); diff --git a/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts index 6bc2f93..102fe5c 100644 --- a/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts +++ b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts @@ -1,6 +1,7 @@ "use client"; import { apiDefaultParams } from "@/constants/Api_Params"; import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { deleteEmptyKeys } from "@/utils/shared"; import { useIsMutating } from "@tanstack/react-query"; @@ -24,7 +25,17 @@ const useNotificationHistoryTablePresenter = () => { const skeletonTbodyRef = useRef(null); const dataTbodyRef = useRef(null); - const { data, isLoading } = useGetNotificationHistoryQuery(params); + const { data, isLoading, isError, error } = + useGetNotificationHistoryQuery(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); useTableSectionRowAnimations(isLoading, data, { tableSectionRef, @@ -64,28 +75,8 @@ const useNotificationHistoryTablePresenter = () => { filterFormReset(newParams); }, [searchParams, filterFormReset]); - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams: Record) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit], - ); - const search = (formData: any) => { - const newParams = { ...params, ...formData, offset: 0 }; + const newParams = buildFirstPageParams({ ...params, ...formData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index 2f6dd28..0e53fdc 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -2,6 +2,7 @@ import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal"; import { apiDefaultParams } from "@/constants/Api_Params"; import { useTendersQuery } from "@/hooks/queries"; +import { useHybridPagination } from "@/hooks/useHybridPagination"; import { TCustomer } from "@/lib/api"; import { deleteEmptyKeys, getPaginatedRowNumber, unixToDate } from "@/utils/shared"; import gsap from "gsap"; @@ -284,7 +285,17 @@ const useTenderListPresenter = () => { filterFormReset(mergeTenderFilterFormState(newParams)); }, [searchParams, filterFormReset]); - const { data, error, isPending } = useTendersQuery(params); + const { data, error, isPending, isError } = useTendersQuery(params); + + const { pagination, handlePaginationChange, buildFirstPageParams } = + useHybridPagination({ + meta: data?.meta, + params, + setParams, + isError, + error, + }); + const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; @@ -311,18 +322,6 @@ const useTenderListPresenter = () => { [pathName, router, langQuerySuffix], ); - const handlePaginationChange = useCallback( - (e: { selected: number }) => { - const limit = data?.meta?.limit ?? 10; - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * limit, - limit, - })); - }, - [data?.meta?.limit, setParams], - ); - const getRowNumber = useCallback( (index: number) => getPaginatedRowNumber({ @@ -337,14 +336,6 @@ const useTenderListPresenter = () => { return unixToDate({ unix, hasTime: true }); }, []); - const pagination = useMemo( - () => ({ - currentPage: data?.meta?.page ? data.meta.page - 1 : 0, - totalPages: data?.meta?.pages ?? 1, - }), - [data?.meta?.page, data?.meta?.pages], - ); - useLayoutEffect(() => { const reduced = typeof window !== "undefined" && @@ -401,21 +392,20 @@ const useTenderListPresenter = () => { delete merged[key]; } } - const newParams = { ...merged, ...normalizedData, offset: 0 }; + const newParams = buildFirstPageParams({ ...merged, ...normalizedData }); const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); }; const clearAllFilters = useCallback(() => { - const cleared: Record = { + const cleared = buildFirstPageParams({ ...apiDefaultParams, - offset: 0, limit: 10, - }; + }); filterFormReset(mergeTenderFilterFormState(cleared)); router.push(pathName); - }, [pathName, router, filterFormReset]); + }, [buildFirstPageParams, pathName, router, filterFormReset]); const columns = [ "row", "title", diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index 544a0dd..b19a474 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -14,8 +14,9 @@ const Pagination: FC = ({ totalPages, currentPage, onPageChange }) => { return ( { + const axiosError = error as AxiosError<{ error?: { message?: string } }>; + if (axiosError.response?.status !== 400) return false; + const message = axiosError.response?.data?.error?.message ?? ""; + return /cursor|pagination/i.test(message); +}; + +type UseHybridPaginationOptions = { + meta?: IMetaData; + params: Record; + setParams: React.Dispatch>>; + isError?: boolean; + error?: unknown; +}; + +export const useHybridPagination = ({ + meta, + params, + setParams, + isError, + error, +}: UseHybridPaginationOptions) => { + const [cursorStack, setCursorStack] = useState([]); + + const limit = meta?.limit ?? Number(params.limit) ?? 10; + + const clearCursorStack = useCallback(() => { + setCursorStack([]); + }, []); + + const resetToFirstPage = useCallback(() => { + clearCursorStack(); + setParams((current) => { + const next: Record = { ...current, offset: 0, limit }; + delete next.cursor; + return next; + }); + }, [clearCursorStack, limit, setParams]); + + useEffect(() => { + if (!isError || !error || !isInvalidCursorError(error)) return; + resetToFirstPage(); + }, [isError, error, resetToFirstPage]); + + const hasNext = Boolean(meta?.has_more && meta?.next_cursor); + const hasPrevious = cursorStack.length > 0 || (meta?.page ?? 1) > 1; + + const currentPageIndex = useMemo(() => { + if (meta?.page != null) return meta.page - 1; + return cursorStack.length; + }, [cursorStack.length, meta?.page]); + + const pagination = useMemo( + () => ({ + currentPage: currentPageIndex, + totalPages: meta?.pages ?? 1, + }), + [currentPageIndex, meta?.pages], + ); + + const goToOffsetPage = useCallback( + (pageIndex: number) => { + clearCursorStack(); + setParams((current) => { + const next: Record = { + ...current, + offset: pageIndex * limit, + limit, + }; + delete next.cursor; + return next; + }); + }, + [clearCursorStack, limit, setParams], + ); + + const goToNextCursorPage = useCallback(() => { + const nextCursor = meta?.next_cursor; + if (!meta?.has_more || !nextCursor) return; + + setCursorStack((stack) => [...stack, nextCursor]); + setParams((current) => { + const next: Record = { ...current, cursor: nextCursor, limit }; + delete next.offset; + return next; + }); + }, [limit, meta?.has_more, meta?.next_cursor, setParams]); + + const goToPreviousPage = useCallback(() => { + if (cursorStack.length > 0) { + const nextStack = cursorStack.slice(0, -1); + const previousCursor = nextStack[nextStack.length - 1]; + + setCursorStack(nextStack); + setParams((current) => { + const next: Record = { ...current, limit }; + if (previousCursor) { + next.cursor = previousCursor; + delete next.offset; + } else { + next.offset = 0; + delete next.cursor; + } + return next; + }); + return; + } + + const page = meta?.page ?? 1; + if (page <= 1) { + resetToFirstPage(); + return; + } + + goToOffsetPage(page - 2); + }, [cursorStack, goToOffsetPage, limit, meta?.page, resetToFirstPage, setParams]); + + const handlePaginationChange = useCallback( + (e: { selected: number }) => { + const targetIndex = e.selected; + const delta = targetIndex - currentPageIndex; + + if (delta === 1 && hasNext) { + goToNextCursorPage(); + return; + } + + if (delta === -1 && hasPrevious) { + goToPreviousPage(); + return; + } + + goToOffsetPage(targetIndex); + }, + [ + currentPageIndex, + goToNextCursorPage, + goToOffsetPage, + goToPreviousPage, + hasNext, + hasPrevious, + ], + ); + + const buildFirstPageParams = useCallback( + (base: Record) => { + const next: Record = { ...base, offset: 0, limit }; + delete next.cursor; + clearCursorStack(); + return next; + }, + [clearCursorStack, limit], + ); + + return { + pagination, + handlePaginationChange, + buildFirstPageParams, + resetToFirstPage, + clearCursorStack, + currentPageIndex, + hasNext, + hasPrevious, + }; +}; diff --git a/src/lib/api/types/Factory.ts b/src/lib/api/types/Factory.ts index f23a7bf..131b379 100644 --- a/src/lib/api/types/Factory.ts +++ b/src/lib/api/types/Factory.ts @@ -1,26 +1,24 @@ import { z } from "zod"; + const MetaDataSchema = z.object({ limit: z.number(), offset: z.number(), page: z.number(), pages: z.number(), total: z.number(), + has_more: z.boolean().optional(), + next_cursor: z.string().optional(), + prev_cursor: z.string().optional(), }); + export function createApiResponseSchema(dataSchema: T) { return z.object({ data: dataSchema, - meta: z - .object({ - limit: z.number(), - offset: z.number(), - page: z.number(), - pages: z.number(), - total: z.number(), - }) - .optional(), + meta: MetaDataSchema.optional(), message: z.string(), code: z.number().optional(), success: z.boolean(), }); } + export type IMetaData = z.infer;