From 4fcc96ac15c5ee36a8e41457852f0f13a91f6d37 Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Sat, 29 Nov 2025 17:03:24 +0700 Subject: [PATCH] feat(teams): implement numbered pagination for browse teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace infinite scroll with traditional numbered pagination - Add page numbers, prev/next buttons - Show "Showing X-Y of Z teams" count - Auto-reset to page 1 on search/filter changes - Update useTeams hook to return pagination metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/hackathon/src/app/teams/browse/page.tsx | 173 +++++++++++++------ libs/service/src/hooks/teams/index.ts | 21 ++- 2 files changed, 139 insertions(+), 55 deletions(-) diff --git a/apps/hackathon/src/app/teams/browse/page.tsx b/apps/hackathon/src/app/teams/browse/page.tsx index 4f34148..8d62012 100644 --- a/apps/hackathon/src/app/teams/browse/page.tsx +++ b/apps/hackathon/src/app/teams/browse/page.tsx @@ -1,8 +1,8 @@ -import { FC, ReactElement, useState, useEffect, useRef, useCallback } from 'react'; +import { FC, ReactElement, useState, useEffect } from 'react'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Link, useNavigate } from 'react-router'; import { - useInfiniteTeams, + useTeams, useJoinTeam, useMyTeams, ETeamVisibility, @@ -14,6 +14,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { CitySelect } from '../../../components/city-select'; import { Icon } from '@iconify/react'; +const TEAMS_PER_PAGE = 12; + const BrowseTeamsPage: FC = (): ReactElement => { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); @@ -21,25 +23,29 @@ const BrowseTeamsPage: FC = (): ReactElement => { const [selectedCity, setSelectedCity] = useState(''); const [selectedTeamId, setSelectedTeamId] = useState(null); const [showJoinModal, setShowJoinModal] = useState(false); + const [currentPage, setCurrentPage] = useState(1); - // Ref for intersection observer - const loadMoreRef = useRef(null); - - // Debounce search term + // Debounce search term and reset page useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(searchTerm); + setCurrentPage(1); // Reset to first page on search }, 300); return () => clearTimeout(timer); }, [searchTerm]); + // Reset page when city filter changes + useEffect(() => { + setCurrentPage(1); + }, [selectedCity]); + const { data: teamsData, isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - } = useInfiniteTeams({ + isFetching, + } = useTeams({ + page: currentPage, + limit: TEAMS_PER_PAGE, search: debouncedSearch, city: selectedCity || undefined, visibility: ETeamVisibility.PUBLIC, @@ -53,36 +59,11 @@ const BrowseTeamsPage: FC = (): ReactElement => { mode: 'all', }); - // Flatten pages into single array - const teams = teamsData?.pages.flatMap((page) => page.data) || []; + const teams = teamsData?.teams || []; + const totalPages = teamsData?.totalPages || 1; + const total = teamsData?.total || 0; const myTeams = myTeamsData?.data || []; - // Intersection Observer callback - const handleObserver = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [target] = entries; - if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, - [hasNextPage, isFetchingNextPage, fetchNextPage] - ); - - // Set up intersection observer - useEffect(() => { - const element = loadMoreRef.current; - if (!element) return; - - const observer = new IntersectionObserver(handleObserver, { - root: null, - rootMargin: '100px', - threshold: 0, - }); - - observer.observe(element); - return () => observer.disconnect(); - }, [handleObserver]); - // Helper function to check if user is a member of a team const isMyTeam = (teamId: string) => { return myTeams.some((team: any) => team.id === teamId); @@ -106,6 +87,43 @@ const BrowseTeamsPage: FC = (): ReactElement => { } }); + // Generate page numbers to display + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const maxVisible = 5; + + if (totalPages <= maxVisible + 2) { + // Show all pages if total is small + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (currentPage > 3) { + pages.push('...'); + } + + // Show pages around current + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push('...'); + } + + // Always show last page + pages.push(totalPages); + } + + return pages; + }; + return (
{/* Header */} @@ -156,9 +174,18 @@ const BrowseTeamsPage: FC = (): ReactElement => {
+ {/* Teams Count */} + {!isLoading && total > 0 && ( +
+ Showing {(currentPage - 1) * TEAMS_PER_PAGE + 1}- + {Math.min(currentPage * TEAMS_PER_PAGE, total)} of {total} teams +
+ )} + {/* Teams List */} {isLoading ? (
+

Loading teams...

) : teams.length === 0 ? ( @@ -172,8 +199,8 @@ const BrowseTeamsPage: FC = (): ReactElement => { ) : ( <> -
- {teams.map((team) => ( +
+ {teams.map((team: any) => (
{ ))}
- {/* Intersection Observer Sentinel */} -
- {isFetchingNextPage && ( -
- - Loading more teams... + {/* Pagination */} + {totalPages > 1 && ( +
+
+ + +
+ {getPageNumbers().map((page, index) => + typeof page === 'string' ? ( + + ... + + ) : ( + + ) + )} +
+ +
- )} - {!hasNextPage && teams.length > 0 && ( -

- No more teams to load -

- )} -
+ + + Page {currentPage} of {totalPages} + +
+ )} )}
diff --git a/libs/service/src/hooks/teams/index.ts b/libs/service/src/hooks/teams/index.ts index 8c617aa..7005509 100644 --- a/libs/service/src/hooks/teams/index.ts +++ b/libs/service/src/hooks/teams/index.ts @@ -106,6 +106,14 @@ interface Submission { created_at: string; } +// Pagination response type +interface PaginatedTeamsResponse { + teams: Team[]; + total: number; + page: number; + per_page: number; +} + // Team CRUD Hooks export const useTeams = (params?: { page?: number; @@ -118,15 +126,24 @@ export const useTeams = (params?: { queryKey: teamKeys.list(params), queryFn: async () => { const queryParams = new URLSearchParams(); + if (params?.page) queryParams.append('page', String(params.page)); + if (params?.limit) queryParams.append('limit', String(params.limit)); if (params?.search) queryParams.append('search', params.search); if (params?.city) queryParams.append('city', params.city); if (params?.visibility) queryParams.append('visibility', params.visibility); - const response = await hackathonApi.get>( + const response = await hackathonApi.get>( `/teams/browse${queryParams.toString() ? `?${queryParams.toString()}` : ''}` ); - return { data: response.data.data || [] }; + const data = response.data.data; + return { + teams: data?.teams || [], + total: data?.total || 0, + page: data?.page || 1, + perPage: data?.per_page || 12, + totalPages: Math.ceil((data?.total || 0) / (data?.per_page || 12)), + }; }, }); };