Qadam Roadmap
проектplans/archived/2026-03-28-integrated/2026-03-18-infinite-scroll-catalog.md

Infinite Scroll Catalog Implementation Plan

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

Infinite Scroll Catalog Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the "Load More" button with true infinite scroll that accumulates items as the user scrolls down.

Architecture: Switch useQueryuseInfiniteQuery (React Query accumulates pages automatically). An IntersectionObserver on a sentinel <div> at the bottom of the list triggers fetchNextPage(). The page state is removed from CatalogFilterContext since the infinite query manages pagination internally — filter changes automatically create a new query key, resetting the feed.

Tech Stack: React Query 5 useInfiniteQuery, IntersectionObserver API, Next.js App Router SSR with prefetchInfiniteQuery.


File Map

FileChange
apps/web/src/shared/api/keys/catalog.keys.tsAdd infiniteItems key
apps/web/src/shared/api/queries/use-catalog-items.tsReplace with useInfiniteQuery, rename export
apps/web/src/shared/api/index.tsUpdate export name
apps/web/src/shared/lib/catalog-filter-context.tsxRemove page / setPage
apps/web/src/views/home/model/build-api-params.tsRemove page and limit from output
apps/web/src/views/home/model/prefetch-catalog.tsUse prefetchInfiniteQuery
apps/web/src/widgets/home/ui/CourseFeed.tsxSentinel div + IntersectionObserver, remove Load More

Task 1: Add infiniteItems query key

Files:

  • Modify: apps/web/src/shared/api/keys/catalog.keys.ts

  • Add infiniteItems factory to catalogKeys:

export const catalogKeys = {
  all: () => ['catalog'] as const,
  items: (params?: Record<string, string | string[]>) =>
    [...catalogKeys.all(), 'items', params] as const,
  infiniteItems: (params?: Record<string, string | string[]>) =>
    [...catalogKeys.all(), 'infinite-items', params] as const,
  item: (slug: string) => [...catalogKeys.all(), 'item', slug] as const,
  subjects: () => [...catalogKeys.all(), 'subjects'] as const,
  subjectsByGroup: (groupId: string) =>
    [...catalogKeys.all(), 'subjects', groupId] as const,
  locations: () => [...catalogKeys.all(), 'locations'] as const,
};
  • Commit:
git add apps/web/src/shared/api/keys/catalog.keys.ts
git commit -m "feat: add infiniteItems query key to catalogKeys"

Task 2: Replace useCatalogItems with useInfiniteCatalogItems

Files:

  • Modify: apps/web/src/shared/api/queries/use-catalog-items.ts

  • Replace the entire file content:

'use client';

import { useInfiniteQuery, keepPreviousData } from '@tanstack/react-query';
import { useCatalogFilters } from '@/shared/lib/catalog-filter-context';
import { catalogKeys } from '../keys/catalog.keys';
import { apiClient } from '../client';
import type { CatalogResponse } from '../api-types';

export function useInfiniteCatalogItems() {
  const {
    search,
    selectedCategory,
    selectedSubcategory,
    selectedLocation,
    filters,
  } = useCatalogFilters();

  const baseParams: Record<string, string | string[]> = {};

  if (search) baseParams.search = search;
  if (selectedSubcategory) baseParams.subjectId = selectedSubcategory;
  else if (selectedCategory) baseParams.subjectGroupId = selectedCategory;
  if (selectedLocation) baseParams.locationId = selectedLocation;

  if (filters.format !== 'any') baseParams.studyFormat = [filters.format];
  if (filters.lessonTypes.length > 0) baseParams.studyType = filters.lessonTypes;
  if (filters.languages.length > 0) baseParams.language = filters.languages;

  const sellerTypes: string[] = [];
  if (filters.providerIndividual) sellerTypes.push('INDIVIDUAL_CONTRIBUTOR');
  if (filters.providerCompany) sellerTypes.push('SCHOOL_OFFLINE', 'ONLINE_SCHOOL');
  if (sellerTypes.length > 0) baseParams.sellerType = sellerTypes;

  if (filters.freeOnly) {
    baseParams.priceFrom = '0';
    baseParams.priceTo = '0';
  } else {
    if (filters.priceMin) baseParams.priceFrom = filters.priceMin;
    if (filters.priceMax) baseParams.priceTo = filters.priceMax;
  }

  if (filters.ageMode === 'adult') baseParams.ageFrom = '18';
  else if (filters.ageMode === 'child') baseParams.ageTo = '17';

  if (filters.durationMin) baseParams.durationFrom = filters.durationMin;
  if (filters.durationMax) baseParams.durationTo = filters.durationMax;

  return useInfiniteQuery({
    queryKey: catalogKeys.infiniteItems(baseParams),
    queryFn: ({ pageParam }) =>
      apiClient
        .get<CatalogResponse>('/catalog/items', {
          params: { ...baseParams, page: String(pageParam), limit: '30' },
        })
        .then((r) => r.data),
    initialPageParam: 1,
    getNextPageParam: (lastPage) =>
      lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
    placeholderData: keepPreviousData,
    staleTime: 5 * 60 * 1000,
  });
}
  • Commit:
git add apps/web/src/shared/api/queries/use-catalog-items.ts
git commit -m "feat: replace useCatalogItems with useInfiniteCatalogItems"

Task 3: Update shared/api/index.ts export

Files:

  • Modify: apps/web/src/shared/api/index.ts

  • Replace the useCatalogItems export with useInfiniteCatalogItems:

Change line:

export { useCatalogItems } from './queries/use-catalog-items';

To:

export { useInfiniteCatalogItems } from './queries/use-catalog-items';
  • Commit:
git add apps/web/src/shared/api/index.ts
git commit -m "feat: update shared api index to export useInfiniteCatalogItems"

Task 4: Remove page and setPage from CatalogFilterContext

⚠️ Tasks 4 and 7 are coupled — CourseFeed currently destructures setPage from the context. Apply Task 7 before or together with Task 4 to avoid a runtime destructuring error.

Files:

  • Modify: apps/web/src/shared/lib/catalog-filter-context.tsx

  • Remove page: number and setPage from CatalogFilterContextValue interface

  • Remove const [page, setPageRaw] = useState(0) state declaration

  • Remove setPage useCallback

  • Remove all setPageRaw(0) calls inside updateFilters, clearFilters, setSearch, setSelectedCategory, setSelectedSubcategory, setSelectedLocation

  • Remove page and setPage from the value object and its deps array

Result — the interface becomes:

interface CatalogFilterContextValue {
  filters: FilterState;
  search: string;
  selectedCategory: string | null;
  selectedSubcategory: string | null;
  selectedLocation: string;
  drawerOpen: boolean;
  activeFilterCount: number;

  updateFilters: (partial: Partial<FilterState>) => void;
  clearFilters: () => void;
  setSearch: (search: string) => void;
  setSelectedCategory: (category: string | null) => void;
  setSelectedSubcategory: (subcategory: string | null) => void;
  setSelectedLocation: (location: string) => void;
  setDrawerOpen: (open: boolean) => void;
}
  • Commit:
git add apps/web/src/shared/lib/catalog-filter-context.tsx
git commit -m "refactor: remove page/setPage from CatalogFilterContext"

Task 5: Remove page and limit from build-api-params.ts, fix format array

Files:

  • Modify: apps/web/src/views/home/model/build-api-params.ts

  • Delete these two lines:

apiParams.page = '1';
apiParams.limit = '30';

The infinite query injects page and limit per-request in the queryFn.

  • Fix studyFormat to be an array (line 12), to match the infinite query's param structure:

Change:

if (filters.format !== 'any') apiParams.studyFormat = filters.format;

To:

if (filters.format !== 'any') apiParams.studyFormat = [filters.format];

This ensures SSR prefetch params are consistent with client-side infinite query params.

  • Commit:
git add apps/web/src/views/home/model/build-api-params.ts
git commit -m "refactor: remove page/limit from buildApiParams (handled by infinite query)"

Task 6: Update SSR prefetch to prefetchInfiniteQuery

Files:

  • Modify: apps/web/src/views/home/model/prefetch-catalog.ts

  • Replace the entire file:

import { getQueryClient } from '@/shared/lib/get-query-client';
import { serverFetch } from '@/shared/api/server';
import { catalogKeys } from '@/shared/api';
import { buildApiParams, buildQueryString } from './build-api-params';
import type { CatalogInitialState } from '@/shared/lib/catalog-filter-context';
import type { CatalogResponse } from '@/shared/api';

export async function prefetchCatalog(state: CatalogInitialState) {
  const qc = getQueryClient();
  const apiParams = buildApiParams(state);

  await Promise.all([
    qc.prefetchInfiniteQuery({
      queryKey: catalogKeys.infiniteItems(apiParams),
      queryFn: ({ pageParam }) => {
        const query = buildQueryString({ ...apiParams, page: String(pageParam), limit: '30' });
        return serverFetch<CatalogResponse>(`/catalog/items${query}`, { revalidate: 300 });
      },
      initialPageParam: 1,
      getNextPageParam: (lastPage: CatalogResponse) =>
        lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
      pages: 1,
    }),
    qc.prefetchQuery({
      queryKey: catalogKeys.subjects(),
      queryFn: () => serverFetch('/catalog/subjects', { revalidate: 300 }),
    }),
    qc.prefetchQuery({
      queryKey: catalogKeys.locations(),
      queryFn: () => serverFetch('/catalog/locations', { revalidate: 300 }),
    }),
  ]);

  return qc;
}
  • Commit:
git add apps/web/src/views/home/model/prefetch-catalog.ts
git commit -m "feat: use prefetchInfiniteQuery for catalog SSR prefetch"

Task 7: Update CourseFeed — IntersectionObserver + remove Load More

Files:

  • Modify: apps/web/src/widgets/home/ui/CourseFeed.tsx

  • Replace the entire file:

'use client';

import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { ItemCard } from '@/entities/item';
import { useCatalogFilters } from '@/shared/lib/catalog-filter-context';
import { useInfiniteCatalogItems } from '@/shared/api';
import { CourseCardSkeleton } from './CourseCardSkeleton';
import { QadamMap } from './QadamMap';

export function CourseFeed() {
  const t = useTranslations('catalog');
  const { activeFilterCount, clearFilters } = useCatalogFilters();
  const { data, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteCatalogItems();

  const items = data?.pages.flatMap((p) => p.items) ?? [];
  const isInitialLoading = isFetching && items.length === 0;

  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = sentinelRef.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { rootMargin: '200px' },
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div className="flex-1 min-w-0">
      <QadamMap items={items} />

      {isInitialLoading ? (
        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
          {[1, 2, 3, 4, 5, 6].map((i) => (
            <CourseCardSkeleton key={i} />
          ))}
        </div>
      ) : items.length === 0 ? (
        <div className="flex flex-col items-center py-16 text-center">
          <div className="text-5xl mb-4">🔍</div>
          <p className="text-gray-800 font-bold text-lg mb-1">{t('noResults')}</p>
          <p className="text-gray-400 text-sm mb-6">{t('noResultsSub')}</p>
          {activeFilterCount > 0 && (
            <div className="bg-[#F9FAFB] border border-gray-200 rounded-2xl p-5 max-w-sm w-full">
              <p className="text-sm font-semibold text-gray-800 mb-3">{t('narrowResults')}</p>
              <div className="flex flex-wrap gap-2">
                <button
                  onClick={clearFilters}
                  className="px-3 py-1.5 bg-white border border-gray-200 text-xs font-medium text-gray-700 rounded-lg hover:border-[#1DB57A] hover:text-[#1DB57A] transition-all"
                >
                  {t('clearFilters')}
                </button>
              </div>
            </div>
          )}
        </div>
      ) : (
        <>
          <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
            {items.map((item) => (
              <ItemCard key={item.id} item={item} />
            ))}
          </div>

          {/* Sentinel triggers next page load */}
          <div ref={sentinelRef} className="h-1" />

          {isFetchingNextPage && (
            <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
              {[1, 2, 3].map((i) => (
                <CourseCardSkeleton key={i} />
              ))}
            </div>
          )}
        </>
      )}
    </div>
  );
}
  • Commit:
git add apps/web/src/widgets/home/ui/CourseFeed.tsx
git commit -m "feat: infinite scroll with IntersectionObserver, remove Load More button"