# 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 `useQuery` → `useInfiniteQuery` (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

| File | Change |
|------|--------|
| `apps/web/src/shared/api/keys/catalog.keys.ts` | Add `infiniteItems` key |
| `apps/web/src/shared/api/queries/use-catalog-items.ts` | Replace with `useInfiniteQuery`, rename export |
| `apps/web/src/shared/api/index.ts` | Update export name |
| `apps/web/src/shared/lib/catalog-filter-context.tsx` | Remove `page` / `setPage` |
| `apps/web/src/views/home/model/build-api-params.ts` | Remove `page` and `limit` from output |
| `apps/web/src/views/home/model/prefetch-catalog.ts` | Use `prefetchInfiniteQuery` |
| `apps/web/src/widgets/home/ui/CourseFeed.tsx` | Sentinel 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`:

```ts
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:
```bash
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:

```ts
'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:
```bash
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:
```ts
export { useCatalogItems } from './queries/use-catalog-items';
```
To:
```ts
export { useInfiniteCatalogItems } from './queries/use-catalog-items';
```

- [ ] Commit:
```bash
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:
```ts
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:
```bash
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:
```ts
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:
```ts
if (filters.format !== 'any') apiParams.studyFormat = filters.format;
```
To:
```ts
if (filters.format !== 'any') apiParams.studyFormat = [filters.format];
```

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

- [ ] Commit:
```bash
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:

```ts
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:
```bash
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:

```tsx
'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:
```bash
git add apps/web/src/widgets/home/ui/CourseFeed.tsx
git commit -m "feat: infinite scroll with IntersectionObserver, remove Load More button"
```
