"use client"; import { AxiosError } from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; const isInvalidCursorError = (error: unknown) => { 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?: { limit: number; offset: number; page?: number; pages?: number; has_more?: boolean; next_cursor?: string; }; params: Record; setParams: React.Dispatch>>; isError?: boolean; 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 = ({ meta, params, setParams, isError, error, }: UseHybridPaginationOptions) => { const [cursorStack, setCursorStack] = useState([]); const [lastValidPages, setLastValidPages] = useState(1); const [lastValidLimit, setLastValidLimit] = useState(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(() => { 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); /** 0-based page index; do not trust meta.page alone while cursor pages are active. */ 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; return 0; }, [ cursorStack, params.cursor, params.offset, meta?.offset, meta?.page, limit, ]); const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0; const pagination = useMemo( () => ({ currentPage: currentPageIndex, totalPages: meta?.pages ?? lastValidPages, }), [currentPageIndex, meta?.pages, lastValidPages], ); 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( (fromPage1Based: number) => { const nextCursor = meta?.next_cursor; if (!meta?.has_more || !nextCursor) return; setCursorStack((stack) => [ ...stack, { cursor: nextCursor, fromPage: fromPage1Based }, ]); 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 poppedEntry = cursorStack[cursorStack.length - 1]; const nextStack = cursorStack.slice(0, -1); const previousEntry = nextStack[nextStack.length - 1]; setCursorStack(nextStack); setParams((current) => { const next: Record = { ...current, limit }; if (previousEntry) { next.cursor = previousEntry.cursor; delete next.offset; } else if (poppedEntry.fromPage > 1) { next.offset = (poppedEntry.fromPage - 1) * limit; delete next.cursor; } else { next.offset = 0; delete next.cursor; } return next; }); return; } if (currentPageIndex <= 0) { resetToFirstPage(); return; } goToOffsetPage(currentPageIndex - 1); }, [ cursorStack, currentPageIndex, goToOffsetPage, limit, resetToFirstPage, setParams, ]); const handlePaginationChange = useCallback( (e: { selected: number }) => { const targetIndex = e.selected; const delta = targetIndex - currentPageIndex; if (delta === 1 && hasNext) { goToNextCursorPage(currentPageIndex + 1); return; } if (delta === -1) { const cursorReturnIndex = cursorStack.length > 0 ? cursorStack[cursorStack.length - 1].fromPage - 1 : null; if ( cursorReturnIndex != null && targetIndex === cursorReturnIndex && cursorStack.length > 0 ) { goToPreviousPage(); return; } if (targetIndex >= 0) { goToOffsetPage(targetIndex); return; } } goToOffsetPage(targetIndex); }, [ currentPageIndex, cursorStack, goToNextCursorPage, goToOffsetPage, goToPreviousPage, hasNext, ], ); 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, }; };