From 21bba70d5369611fa635b9db26109cd750b5b42a Mon Sep 17 00:00:00 2001
From: Arik Chakma <arikchangma@gmail.com>
Date: Fri, 14 Mar 2025 18:37:58 +0600
Subject: [PATCH] feat: ai course pagination

---
 .../GenerateCourse/AICourseCard.tsx           |   4 +-
 .../GenerateCourse/AICourseSearch.tsx         |  46 +++++++
 .../GenerateCourse/UserCoursesList.tsx        | 122 +++++++++++-------
 src/components/Pagination/Pagination.tsx      |  14 +-
 src/queries/ai-course.ts                      |  31 ++++-
 5 files changed, 157 insertions(+), 60 deletions(-)
 create mode 100644 src/components/GenerateCourse/AICourseSearch.tsx

diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx
index 4048dd73f..329969ade 100644
--- a/src/components/GenerateCourse/AICourseCard.tsx
+++ b/src/components/GenerateCourse/AICourseCard.tsx
@@ -1,9 +1,9 @@
-import type { AICourseListItem } from '../../queries/ai-course';
+import type { AICourseWithLessonCount } from '../../queries/ai-course';
 import type { DifficultyLevel } from './AICourse';
 import { BookOpen } from 'lucide-react';
 
 type AICourseCardProps = {
-  course: AICourseListItem;
+  course: AICourseWithLessonCount;
 };
 
 export function AICourseCard(props: AICourseCardProps) {
diff --git a/src/components/GenerateCourse/AICourseSearch.tsx b/src/components/GenerateCourse/AICourseSearch.tsx
new file mode 100644
index 000000000..bd0f68a76
--- /dev/null
+++ b/src/components/GenerateCourse/AICourseSearch.tsx
@@ -0,0 +1,46 @@
+import { SearchIcon } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useDebounceValue } from '../../hooks/use-debounce';
+
+type AICourseSearchProps = {
+  value: string;
+  onChange: (value: string) => void;
+};
+
+export function AICourseSearch(props: AICourseSearchProps) {
+  const { value: defaultValue, onChange } = props;
+
+  const [searchTerm, setSearchTerm] = useState(defaultValue);
+  const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
+
+  useEffect(() => {
+    setSearchTerm(defaultValue);
+  }, [defaultValue]);
+
+  useEffect(() => {
+    if (debouncedSearchTerm && debouncedSearchTerm.length < 3) {
+      return;
+    }
+
+    if (debouncedSearchTerm === defaultValue) {
+      return;
+    }
+
+    onChange(debouncedSearchTerm);
+  }, [debouncedSearchTerm]);
+
+  return (
+    <div className="relative w-64 max-sm:hidden">
+      <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+        <SearchIcon className="h-4 w-4 text-gray-400" />
+      </div>
+      <input
+        type="text"
+        className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
+        placeholder="Search your courses..."
+        value={searchTerm}
+        onChange={(e) => setSearchTerm(e.target.value)}
+      />
+    </div>
+  );
+}
diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx
index 92ecab6f7..a7f1e1bfb 100644
--- a/src/components/GenerateCourse/UserCoursesList.tsx
+++ b/src/components/GenerateCourse/UserCoursesList.tsx
@@ -2,24 +2,33 @@ import { useQuery } from '@tanstack/react-query';
 import {
   getAiCourseLimitOptions,
   listUserAiCoursesOptions,
+  type ListUserAiCoursesQuery,
 } from '../../queries/ai-course';
 import { queryClient } from '../../stores/query-client';
 import { AICourseCard } from './AICourseCard';
 import { useEffect, useState } from 'react';
-import { Gift, Loader2, Search, User2 } from 'lucide-react';
+import { Gift, Loader2, User2 } from 'lucide-react';
 import { isLoggedIn } from '../../lib/jwt';
 import { showLoginPopup } from '../../lib/popup';
 import { cn } from '../../lib/classname';
 import { useIsPaidUser } from '../../queries/billing';
 import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
+import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser';
+import { AICourseSearch } from './AICourseSearch';
+import { Pagination } from '../Pagination/Pagination';
 
 type UserCoursesListProps = {};
 
 export function UserCoursesList(props: UserCoursesListProps) {
-  const [searchTerm, setSearchTerm] = useState('');
   const [isInitialLoading, setIsInitialLoading] = useState(true);
   const [showUpgradePopup, setShowUpgradePopup] = useState(false);
 
+  const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
+    perPage: '10',
+    currPage: '1',
+    query: '',
+  });
+
   const { data: limits, isLoading: isLimitsLoading } = useQuery(
     getAiCourseLimitOptions(),
     queryClient,
@@ -29,7 +38,7 @@ export function UserCoursesList(props: UserCoursesListProps) {
   const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
 
   const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
-    listUserAiCoursesOptions(),
+    listUserAiCoursesOptions(pageState),
     queryClient,
   );
 
@@ -37,21 +46,31 @@ export function UserCoursesList(props: UserCoursesListProps) {
     setIsInitialLoading(false);
   }, [userAiCourses]);
 
-  const filteredCourses = userAiCourses?.filter((course) => {
-    if (!searchTerm.trim()) {
-      return true;
-    }
+  const courses = userAiCourses?.data ?? [];
+  const isAuthenticated = isLoggedIn();
+  const limitUsedPercentage = Math.round((used / limit) * 100);
 
-    const searchLower = searchTerm.toLowerCase();
+  useEffect(() => {
+    const queryParams = getUrlParams();
 
-    return (
-      course.title.toLowerCase().includes(searchLower) ||
-      course.keyword.toLowerCase().includes(searchLower)
-    );
-  });
+    setPageState({
+      ...pageState,
+      currPage: queryParams?.p || '1',
+      query: queryParams?.q || '',
+    });
+  }, []);
 
-  const isAuthenticated = isLoggedIn();
-  const limitUsedPercentage = Math.round((used / limit) * 100);
+  useEffect(() => {
+    if (pageState?.currPage !== '1' || pageState?.query !== '') {
+      setUrlParams({
+        p: pageState?.currPage || '1',
+        q: pageState?.query || '',
+      });
+    } else {
+      deleteUrlParam('p');
+      deleteUrlParam('q');
+    }
+  }, [pageState]);
 
   return (
     <>
@@ -69,9 +88,9 @@ export function UserCoursesList(props: UserCoursesListProps) {
           {used > 0 && limit > 0 && !isPaidUserLoading && (
             <div
               className={cn(
-                'flex items-center gap-2 opacity-0 transition-opacity',
+                'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity',
                 {
-                  'opacity-100': !isPaidUser,
+                  'pointer-events-auto opacity-100': !isPaidUser,
                 },
               )}
             >
@@ -95,18 +114,16 @@ export function UserCoursesList(props: UserCoursesListProps) {
             </div>
           )}
 
-          <div className={cn('relative w-64 max-sm:hidden', {})}>
-            <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
-              <Search className="h-4 w-4 text-gray-400" />
-            </div>
-            <input
-              type="text"
-              className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 transition-all focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
-              placeholder="Search your courses..."
-              value={searchTerm}
-              onChange={(e) => setSearchTerm(e.target.value)}
-            />
-          </div>
+          <AICourseSearch
+            value={pageState?.query || ''}
+            onChange={(value) => {
+              setPageState({
+                ...pageState,
+                query: value,
+                currPage: '1',
+              });
+            }}
+          />
         </div>
       </div>
 
@@ -127,15 +144,13 @@ export function UserCoursesList(props: UserCoursesListProps) {
         </div>
       )}
 
-      {!isUserAiCoursesLoading &&
-        !isInitialLoading &&
-        userAiCourses?.length === 0 && (
-          <div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
-            <p className="text-sm text-gray-600">
-              You haven't generated any courses yet.
-            </p>
-          </div>
-        )}
+      {!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && (
+        <div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
+          <p className="text-sm text-gray-600">
+            You haven't generated any courses yet.
+          </p>
+        </div>
+      )}
 
       {(isUserAiCoursesLoading || isInitialLoading) && (
         <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4">
@@ -147,19 +162,28 @@ export function UserCoursesList(props: UserCoursesListProps) {
         </div>
       )}
 
-      {!isUserAiCoursesLoading &&
-        filteredCourses &&
-        filteredCourses.length > 0 && (
-          <div className="flex flex-col gap-2">
-            {filteredCourses.map((course) => (
-              <AICourseCard key={course._id} course={course} />
-            ))}
-          </div>
-        )}
+      {!isUserAiCoursesLoading && courses && courses.length > 0 && (
+        <div className="flex flex-col gap-2">
+          {courses.map((course) => (
+            <AICourseCard key={course._id} course={course} />
+          ))}
+
+          <Pagination
+            totalCount={userAiCourses?.totalCount || 0}
+            totalPages={userAiCourses?.totalPages || 0}
+            currPage={Number(userAiCourses?.currPage || 1)}
+            perPage={Number(userAiCourses?.perPage || 10)}
+            onPageChange={(page) => {
+              setPageState({ ...pageState, currPage: String(page) });
+            }}
+            className="rounded-lg border border-gray-200 bg-white p-4"
+          />
+        </div>
+      )}
 
       {!isUserAiCoursesLoading &&
-        (userAiCourses?.length || 0 > 0) &&
-        filteredCourses?.length === 0 && (
+        (userAiCourses?.data?.length || 0 > 0) &&
+        courses.length === 0 && (
           <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
             <p className="text-sm text-gray-600">
               No courses match your search.
diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx
index 86f3ac4de..3f4c8d604 100644
--- a/src/components/Pagination/Pagination.tsx
+++ b/src/components/Pagination/Pagination.tsx
@@ -11,6 +11,7 @@ type PaginationProps = {
   totalCount: number;
   isDisabled?: boolean;
   onPageChange: (page: number) => void;
+  className?: string;
 };
 
 export function Pagination(props: PaginationProps) {
@@ -22,6 +23,7 @@ export function Pagination(props: PaginationProps) {
     currPage,
     perPage,
     isDisabled = false,
+    className,
   } = props;
 
   if (!totalPages || totalPages === 1) {
@@ -32,10 +34,14 @@ export function Pagination(props: PaginationProps) {
 
   return (
     <div
-      className={cn('flex items-center', {
-        'justify-between': variant === 'default',
-        'justify-start': variant === 'minimal',
-      })}
+      className={cn(
+        'flex items-center',
+        {
+          'justify-between': variant === 'default',
+          'justify-start': variant === 'minimal',
+        },
+        className,
+      )}
     >
       <div className="flex items-center gap-1 text-xs font-medium">
         <button
diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts
index c95c77154..d7c86333a 100644
--- a/src/queries/ai-course.ts
+++ b/src/queries/ai-course.ts
@@ -75,17 +75,38 @@ export function getAiCourseLimitOptions() {
   });
 }
 
-export type AICourseListItem = AICourseDocument & {
+export type ListUserAiCoursesQuery = {
+  perPage?: string;
+  currPage?: string;
+  query?: string;
+};
+
+export type AICourseWithLessonCount = AICourseDocument & {
   lessonCount: number;
 };
 
-type ListUserAiCoursesResponse = AICourseListItem[];
+type ListUserAiCoursesResponse = {
+  data: AICourseWithLessonCount[];
+  totalCount: number;
+  totalPages: number;
+  currPage: number;
+  perPage: number;
+};
 
-export function listUserAiCoursesOptions() {
+export function listUserAiCoursesOptions(
+  params: ListUserAiCoursesQuery = {
+    perPage: '10',
+    currPage: '1',
+    query: '',
+  },
+) {
   return {
-    queryKey: ['user-ai-courses'],
+    queryKey: ['user-ai-courses', params],
     queryFn: () => {
-      return httpGet<ListUserAiCoursesResponse>(`/v1-list-user-ai-courses`);
+      return httpGet<ListUserAiCoursesResponse>(
+        `/v1-list-user-ai-courses`,
+        params,
+      );
     },
     enabled: !!isLoggedIn(),
   };