Repository: VirtoCommerce/vc-frontend
Pull request #1683: feat: catalog load more mode
Author: login: ivan-kalachikov
Created: 2025-04-10T16:00:03Z | Merged: 2025-04-30T08:49:30Z

## Description


https://github.com/user-attachments/assets/79a44b90-9740-45e6-a3c7-7580ad69095f

A setting for switching between “infinity scroll” and “show more” modes in the catalog has been added.

the file where the setting is located: client-app/config/settings_data.json

"catalog_mode": "load_more", or "catalog_mode": "infinite_scroll",

The setting will be applied after build and deploy.

**load_more:**

- If the page parameter is not specified in the URL, it defaults to page 1.

- "Load Previous Page" and "Load Next Page" buttons are placed at the top and bottom of the product list.

- The "Load Previous Page" button is hidden if all previous pages have already been loaded — for example, when starting from page 1 and loading products page by page without leaving or refreshing the page.

- If the user lands on a later page (e.g. starts at ?page=4) without previously loading pages 1–3, the "Load Previous Page" button is shown at the top, allowing backward navigation.

- Clicking "Load Next Page" updates the visible content and reflects the new page number in the URL query string (?page=2, ?page=3, etc.).

- Clicking "Load Previous Page" loads earlier products, but the URL does not change — the page query stays the same.

- Changing filters resets the current page to 1, and updates the query string accordingly.

- Clicking the "Reset Page" button also resets the page to 1, updating or removing the page query param.

- When the first or last page is reached, the corresponding button (Previous or Next) becomes hidden, as there's no more data in that direction.

- If the page in the URL is set to a number higher than the actual page count, the list will be empty, but the "Reset Page" button at the top allows quick navigation back to page 1.

**infinite_scroll:**

- the flow should remain as it was before, nothing changed

**to switch to this mode ask me to make changes in the code and push it as to create new artefact for testing**

## References
### Jira-link:
https://virtocommerce.atlassian.net/browse/VCST-2799
### Artifact URL:
https://vc3prerelease.blob.core.windows.net/packages/vc-theme-b2b-vue-2.21.0-pr-1683-1337-1337fefa.zip


Diff:
diff --git a/client-app/config/settings_data.json b/client-app/config/settings_data.json
index 452d1a864e..7d27643f7b 100644
--- a/client-app/config/settings_data.json
+++ b/client-app/config/settings_data.json
@@ -38,6 +38,7 @@
     "logo_inverted_image": "logo-white.svg",
     "favicon_image": "/static/icons/favicon-32x32.png",
     "homepage_background_image": "main-banner.webp",
+    "catalog_pagination_mode": "infinite_scroll",
 
     "image_thumbnails_enabled": true,
     "image_thumbnails_original_fallback_enabled": true,
diff --git a/client-app/core/types/theme-config.ts b/client-app/core/types/theme-config.ts
index 7b44a29fb6..d206c29ac5 100644
--- a/client-app/core/types/theme-config.ts
+++ b/client-app/core/types/theme-config.ts
@@ -2,6 +2,7 @@ import type { BrowserTargetType } from "./browser-window-target";
 import type { ISocialSharingService } from "./social-sharing";
 import type { SortDirection } from "@/core/enums";
 import type { IOrderStatus, IQuoteStatus } from "@/core/types";
+import type { CatalogPaginationModeType } from "@/shared/catalog/types/catalog";
 
 export interface IThemeConfigPreset {
   primary_font_family?: string;
@@ -187,6 +188,7 @@ export interface IThemeConfigSettings {
 
   catalog_items_limit?: number;
   catalog_items_selector?: number[];
+  catalog_pagination_mode?: CatalogPaginationModeType;
   infinite_scrolling_enabled?: boolean;
   in_stock_count_enabled?: boolean;
   zero_price_product_enabled?: boolean;
diff --git a/client-app/shared/catalog/components/category.vue b/client-app/shared/catalog/components/category.vue
index 0f246237e9..bd2171eee6 100644
--- a/client-app/shared/catalog/components/category.vue
+++ b/client-app/shared/catalog/components/category.vue
@@ -1,5 +1,5 @@
 <template>
-  <VcContainer ref="categoryComponentAnchor" class="category">
+  <VcContainer ref="categoryComponentAnchor" class="category" style="overflow-anchor: none">
     <!-- Breadcrumbs -->
     <VcBreadcrumbs v-if="!hideBreadcrumbs" class="category__breadcrumbs" :items="breadcrumbs" />
 
@@ -135,7 +135,15 @@
       />
 
       <!-- Filters chips -->
-      <div v-if="hasSelectedFacets" class="category__chips">
+      <div
+        v-if="
+          hasSelectedFacets ||
+          (catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore &&
+            $route.query.page &&
+            Number($route.query.page) > 1)
+        "
+        class="category__chips"
+      >
         <template v-for="facet in productsFilters.facets">
           <template v-for="filterItem in facet.values">
             <VcChip
@@ -156,7 +164,23 @@
           </template>
         </template>
 
-        <VcChip color="secondary" variant="outline" clickable @click="resetFacetFilters">
+        <VcChip
+          v-if="
+            catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore &&
+            $route.query.page &&
+            Number($route.query.page) > 1
+          "
+          color="secondary"
+          variant="outline"
+          clickable
+          @click="resetPage"
+        >
+          <span>{{ $t("common.buttons.reset_page") }}</span>
+
+          <VcIcon name="reset" />
+        </VcChip>
+
+        <VcChip v-if="hasSelectedFacets" color="secondary" variant="outline" clickable @click="resetFacetFilters">
           <span>{{ $t("common.buttons.reset_filters") }}</span>
 
           <VcIcon name="reset" />
@@ -178,9 +202,11 @@
         :items-per-page="itemsPerPage"
         :pages-count="pagesCount"
         :page-number="currentPage"
+        :page-history="pageHistory"
         :products="products"
         :saved-view-mode="savedViewMode"
         :search-params="searchParams"
+        :mode="catalogPaginationMode"
         class="category__products"
         @change-page="changeProductsPage"
         @reset-facet-filters="resetFacetFilters"
@@ -225,6 +251,7 @@ import {
   getFilterExpressionFromFacets,
 } from "@/core/utilities";
 import { useCategorySeo } from "@/shared/catalog/composables/useCategorySeo";
+import { CATALOG_PAGINATION_MODES } from "@/shared/catalog/constants/catalog";
 import { useSlugInfo } from "@/shared/common";
 import { LOCAL_ID_PREFIX, useShipToLocation } from "@/shared/ship-to-location/composables";
 import { useCategory, useProducts } from "../composables";
@@ -273,6 +300,10 @@ const { catalogId, currencyCode } = globals;
 const breakpoints = useBreakpoints(BREAKPOINTS);
 const isMobile = breakpoints.smaller("md");
 
+const catalogPaginationMode = computed(
+  () => themeContext.value?.settings?.catalog_pagination_mode ?? CATALOG_PAGINATION_MODES.infiniteScroll,
+);
+
 const { themeContext } = useThemeContext();
 const {
   getFacets,
@@ -287,6 +318,7 @@ const {
   localStorageBranches,
   localStorageInStock,
   pagesCount,
+  pageHistory,
   products,
   productsFilters,
   searchQueryParam,
@@ -311,6 +343,7 @@ const {
   filtersDisplayOrder,
   useQueryParams: true,
   withFacets: true,
+  catalogPaginationMode: catalogPaginationMode.value,
 });
 const { loading: loadingCategory, category: currentCategory, fetchCategory } = useCategory();
 const { analytics } = useAnalytics();
@@ -477,6 +510,11 @@ function selectProduct(product: Product): void {
   analytics("selectItem", product, categoryListProperties.value);
 }
 
+function resetPage() {
+  void resetCurrentPage();
+  void fetchProducts();
+}
+
 whenever(() => !isMobile.value, hideFiltersSidebar);
 
 watch(
diff --git a/client-app/shared/catalog/components/category/category-products.vue b/client-app/shared/catalog/components/category/category-products.vue
index 5c2afd367a..69f85ac13a 100644
--- a/client-app/shared/catalog/components/category/category-products.vue
+++ b/client-app/shared/catalog/components/category/category-products.vue
@@ -1,6 +1,22 @@
 <template>
   <div class="category-products">
     <template v-if="products.length || fetchingProducts">
+      <div
+        v-if="mode === CATALOG_PAGINATION_MODES.loadMore && minVisitedPage > 1"
+        class="-mt-2 mb-6 flex justify-center"
+      >
+        <VcButton
+          v-if="products.length"
+          class="mt-4"
+          size="sm"
+          :loading="fetchingMoreProducts && pageNumber < minVisitedPage"
+          prepend-icon="arrow-left"
+          @click="loadPreviousPage"
+        >
+          {{ $t("pages.catalog.load_previous_page") }}
+        </VcButton>
+      </div>
+
       <div
         :class="[
           'category-products__list',
@@ -30,16 +46,30 @@
       </div>
 
       <VcInfinityScrollLoader
-        v-if="!Number(fixedProductsCount)"
+        v-if="mode === CATALOG_PAGINATION_MODES.infiniteScroll && !Number(fixedProductsCount)"
         :loading="fetchingProducts || fetchingMoreProducts"
         :is-page-limit-reached="pageNumber >= PAGE_LIMIT"
         :page-number="pageNumber"
         :pages-count="pagesCount"
         distance="400"
         class="category-products__infinity"
-        @visible="$emit('changePage', ++pageNumber)"
+        @visible="$emit('changePage', pageNumber + 1)"
       />
 
+      <div
+        v-if="mode === CATALOG_PAGINATION_MODES.loadMore && maxVisitedPage < pagesCount"
+        class="mt-6 flex justify-center"
+      >
+        <VcButton
+          :loading="fetchingMoreProducts && pageNumber > maxVisitedPage"
+          append-icon="arrow-right"
+          size="sm"
+          @click="loadNextPage"
+        >
+          {{ $t("pages.catalog.load_next_page") }}
+        </VcButton>
+      </div>
+
       <VcScrollTopButton />
     </template>
 
@@ -69,11 +99,14 @@ import { useRouteQueryParam } from "@/core/composables";
 import { PAGE_LIMIT, BREAKPOINTS, DEFAULT_PAGE_SIZE } from "@/core/constants";
 import { QueryParamName } from "@/core/enums";
 import { ProductCard, ProductSkeletonGrid, ProductSkeletonList } from "@/shared/catalog/components";
+import { CATALOG_PAGINATION_MODES } from "@/shared/catalog/constants/catalog";
 import type { Product } from "@/core/api/graphql/types";
+import type { CatalogPaginationModeType } from "@/shared/catalog/types/catalog";
 
 const emit = defineEmits<IEmits>();
 
 const props = withDefaults(defineProps<IProps>(), {
+  mode: CATALOG_PAGINATION_MODES.infiniteScroll,
   itemsPerPage: DEFAULT_PAGE_SIZE,
   viewMode: "grid",
   columnsAmountTablet: "3",
@@ -91,9 +124,11 @@ interface IProps {
   hasSelectedFacets: boolean;
   itemsPerPage?: number;
   pagesCount: number;
+  pageHistory: Readonly<number[]>;
   pageNumber: number;
   products: Product[];
   savedViewMode: "grid" | "list";
+  mode?: CatalogPaginationModeType;
 }
 
 interface IEmits {
@@ -107,7 +142,19 @@ const keywordQueryParam = useRouteQueryParam<string>(QueryParamName.Keyword, {
   defaultValue: "",
 });
 
+function loadPreviousPage() {
+  emit("changePage", minVisitedPage.value - 1);
+}
+
+function loadNextPage() {
+  emit("changePage", maxVisitedPage.value + 1);
+}
+
 const pageNumber = toRef(props, "pageNumber");
+const pageHistory = toRef(props, "pageHistory");
+
+const minVisitedPage = computed(() => Math.min(...pageHistory.value));
+const maxVisitedPage = computed(() => Math.max(...pageHistory.value));
 
 const breakpoints = useBreakpoints(BREAKPOINTS);
 
diff --git a/client-app/shared/catalog/composables/useProducts.test.ts b/client-app/shared/catalog/composables/useProducts.test.ts
new file mode 100644
index 0000000000..66203dcc59
--- /dev/null
+++ b/client-app/shared/catalog/composables/useProducts.test.ts
@@ -0,0 +1,427 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { PAGE_LIMIT } from "@/core/constants";
+import { CATALOG_PAGINATION_MODES } from "@/shared/catalog/constants/catalog";
+import { useProducts } from "./useProducts";
+import type { CatalogPaginationModeType } from "../types";
+
+// Mock types
+interface Product {
+  id: string;
+}
+
+interface PageInfo {
+  hasNextPage: boolean;
+  hasPreviousPage: boolean;
+  startCursor: string;
+  endCursor: string;
+}
+
+interface ProductConnection {
+  items: Product[] | undefined;
+  totalCount: number;
+  pageInfo: PageInfo;
+  term_facets: unknown[];
+  range_facets: unknown[];
+  filter_facets: unknown[];
+  edges: unknown[];
+}
+
+interface ILanguage {
+  languageCode: string;
+  cultureName: string;
+}
+
+interface ICurrency {
+  code: string;
+}
+
+interface IThemeSettings {
+  catalog_pagination_mode?: CatalogPaginationModeType;
+  image_carousel_in_product_card_enabled: boolean;
+  zero_price_product_enabled: boolean;
+}
+
+interface IThemeContext {
+  storeId: string;
+  storeName: string;
+  catalogId: string;
+  defaultLanguage: ILanguage;
+  defaultCurrency: ICurrency;
+  availableLanguages: ILanguage[];
+  availableCurrencies: ICurrency[];
+  settings: IThemeSettings;
+  defaultPresetName: string;
+  storeSettings: Record<string, unknown>;
+}
+
+const mockData = vi.hoisted(() => {
+  const mockPageInfo: PageInfo = {
+    hasNextPage: true,
+    hasPreviousPage: false,
+    startCursor: "",
+    endCursor: "",
+  };
+
+  const mockThemeContext: IThemeContext = {
+    storeId: "store1",
+    storeName: "Test Store",
+    catalogId: "catalog1",
+    defaultLanguage: { languageCode: "en-US", cultureName: "en-US" },
+    defaultCurrency: { code: "USD" },
+    availableLanguages: [],
+    availableCurrencies: [],
+    settings: {
+      image_carousel_in_product_card_enabled: true,
+      zero_price_product_enabled: true,
+    },
+    defaultPresetName: "default",
+    storeSettings: {},
+  };
+
+  const mockSearchProductsResponse: ProductConnection = {
+    items: [{ id: "product1" }, { id: "product2" }],
+    totalCount: 10,
+    pageInfo: mockPageInfo,
+    term_facets: [],
+    range_facets: [],
+    filter_facets: [],
+    edges: [],
+  };
+
+  const mockSearchProductsMoreResponse: ProductConnection = {
+    items: [{ id: "product3" }, { id: "product4" }],
+    totalCount: 10,
+    pageInfo: mockPageInfo,
+    term_facets: [],
+    range_facets: [],
+    filter_facets: [],
+    edges: [],
+  };
+
+  const searchProducts = vi.fn();
+  const useThemeContext = vi.fn();
+
+  return {
+    mockPageInfo,
+    mockThemeContext,
+    mockSearchProductsResponse,
+    mockSearchProductsMoreResponse,
+    searchProducts,
+    useThemeContext,
+  };
+});
+
+vi.mock("@/core/api/graphql/catalog", () => ({
+  searchProducts: mockData.searchProducts,
+}));
+
+vi.mock("@/core/composables", () => ({
+  useThemeContext: mockData.useThemeContext,
+  useRouteQueryParam: () => ({
+    value: "",
+  }),
+}));
+
+vi.mock("@/core/utilities", () => ({
+  Logger: {
+    error: vi.fn(),
+  },
+  getFilterExpressionFromFacets: vi.fn(),
+  rangeFacetToCommonFacet: vi.fn(),
+  termFacetToCommonFacet: vi.fn(),
+}));
+
+vi.mock("@/shared/modal", () => ({
+  useModal: () => ({
+    openModal: vi.fn(),
+  }),
+}));
+
+vi.mock("@vueuse/core", () => {
+  return {
+    useLocalStorage: () => ({
+      value: [],
+    }),
+    createGlobalState: (fn: () => unknown) => fn,
+    createFetch: () => ({}),
+  };
+});
+
+describe("useProducts", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockData.searchProducts.mockResolvedValue(mockData.mockSearchProductsResponse);
+    mockData.useThemeContext.mockReturnValue({
+      themeContext: { value: mockData.mockThemeContext },
+    });
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe("fetchMoreProducts", () => {
+    it("should fetch more products and append them to the existing products list when catalog_pagination_mode is infinite_scroll", async () => {
+      const { fetchProducts, fetchMoreProducts, products } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 1, itemsPerPage: 2 });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchMoreProducts({ page: 2, itemsPerPage: 2 });
+
+      expect(products.value).toEqual([{ id: "product1" }, { id: "product2" }, { id: "product3" }, { id: "product4" }]);
+    });
+
+    it("should append products when loading a page higher than minimum visited page with catalog_pagination_mode=load_more", async () => {
+      const { fetchProducts, fetchMoreProducts, products } = useProducts({
+        catalogPaginationMode: CATALOG_PAGINATION_MODES.loadMore,
+      });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 2, itemsPerPage: 2 });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchMoreProducts({ page: 3, itemsPerPage: 2 });
+
+      expect(products.value).toEqual([{ id: "product1" }, { id: "product2" }, { id: "product3" }, { id: "product4" }]);
+    });
+
+    it("should prepend products when loading a page equal to the minimum visited page with catalog_pagination_mode=load_more", async () => {
+      const { fetchProducts, fetchMoreProducts, products } = useProducts({
+        catalogPaginationMode: CATALOG_PAGINATION_MODES.loadMore,
+      });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product5" }, { id: "product6" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 3, itemsPerPage: 2 });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchMoreProducts({ page: 2, itemsPerPage: 2 });
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchMoreProducts({ page: 1, itemsPerPage: 2 });
+
+      expect(products.value).toEqual([
+        { id: "product1" },
+        { id: "product2" },
+        { id: "product3" },
+        { id: "product4" },
+        { id: "product5" },
+        { id: "product6" },
+      ]);
+    });
+
+    it("should handle API errors gracefully during fetchMoreProducts", async () => {
+      const errorMessage = "API Error";
+      mockData.searchProducts.mockRejectedValueOnce(new Error(errorMessage));
+
+      const { fetchMoreProducts, fetchingMoreProducts } = useProducts();
+
+      await expect(fetchMoreProducts({ page: 2, itemsPerPage: 2 })).rejects.toThrow(errorMessage);
+
+      expect(fetchingMoreProducts.value).toBe(false);
+    });
+  });
+
+  describe("page history handling", () => {
+    beforeEach(() => {
+      const localThemeContext = { ...mockData.mockThemeContext };
+      localThemeContext.settings = {
+        ...mockData.mockThemeContext.settings,
+        catalog_pagination_mode: CATALOG_PAGINATION_MODES.loadMore,
+      };
+
+      mockData.useThemeContext.mockReturnValue({
+        themeContext: { value: localThemeContext },
+      });
+    });
+
+    it("should add page to history when fetchProducts is called without page", async () => {
+      const { fetchProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([1]);
+    });
+
+    it("should add pages to history when fetchProducts is called", async () => {
+      const { fetchProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 3, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([3]);
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 2, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([3, 2]);
+    });
+
+    it("should add page to history on fetchMoreProducts", async () => {
+      const { fetchProducts, fetchMoreProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product1" }, { id: "product2" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 3, itemsPerPage: 2 });
+
+      await fetchMoreProducts({ page: 2, itemsPerPage: 2 });
+      await fetchMoreProducts({ page: 1, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([3, 2, 1]);
+    });
+
+    it("should not add page to history when page is 0", async () => {
+      const { fetchProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 0, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([]);
+    });
+
+    it("should not add page to history when page is greater than pagesCount", async () => {
+      const { fetchProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 10,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: 10, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([]);
+    });
+
+    it("should not add page to history when page is greater than page limit", async () => {
+      const { fetchProducts, pageHistory } = useProducts();
+
+      mockData.searchProducts.mockResolvedValueOnce({
+        items: [{ id: "product3" }, { id: "product4" }],
+        totalCount: 300,
+        pageInfo: mockData.mockPageInfo,
+        term_facets: [],
+        range_facets: [],
+        filter_facets: [],
+        edges: [],
+      });
+
+      await fetchProducts({ page: PAGE_LIMIT + 1, itemsPerPage: 2 });
+
+      expect(pageHistory.value).toEqual([]);
+    });
+  });
+});
diff --git a/client-app/shared/catalog/composables/useProducts.ts b/client-app/shared/catalog/composables/useProducts.ts
index 46f36b73bc..d83063a037 100644
--- a/client-app/shared/catalog/composables/useProducts.ts
+++ b/client-app/shared/catalog/composables/useProducts.ts
@@ -1,5 +1,6 @@
 import { useLocalStorage } from "@vueuse/core";
-import { cloneDeep, isEqual } from "lodash";
+import cloneDeep from "lodash/cloneDeep";
+import isEqual from "lodash/isEqual";
 import { computed, readonly, ref, shallowRef, triggerRef } from "vue";
 import { searchProducts } from "@/core/api/graphql/catalog";
 import { useRouteQueryParam, useThemeContext } from "@/core/composables";
@@ -11,8 +12,14 @@ import {
   rangeFacetToCommonFacet,
   termFacetToCommonFacet,
 } from "@/core/utilities";
+import { CATALOG_PAGINATION_MODES } from "@/shared/catalog/constants/catalog";
 import { useModal } from "@/shared/modal";
-import type { FiltersDisplayOrderType, ProductsFiltersType, ProductsSearchParamsType } from "../types";
+import type {
+  CatalogPaginationModeType,
+  FiltersDisplayOrderType,
+  ProductsFiltersType,
+  ProductsSearchParamsType,
+} from "../types";
 import type { Product, RangeFacet, TermFacet } from "@/core/api/graphql/types";
 import type { FacetItemType, FacetValueItemType } from "@/core/types";
 import type { Ref } from "vue";
@@ -30,6 +37,8 @@ export function useProducts(
     withZeroPrice?: boolean;
     filtersDisplayOrder?: Ref<FiltersDisplayOrderType | undefined>;
     useQueryParams?: boolean;
+    /** @default CATALOG_PAGINATION_MODES.infiniteScroll */
+    catalogPaginationMode?: CatalogPaginationModeType;
   } = {},
 ) {
   const { themeContext } = useThemeContext();
@@ -37,12 +46,21 @@ export function useProducts(
     withFacets = false,
     withImages = themeContext.value?.settings?.image_carousel_in_product_card_enabled,
     withZeroPrice = themeContext.value?.settings?.zero_price_product_enabled,
+    catalogPaginationMode = CATALOG_PAGINATION_MODES.infiniteScroll,
   } = options;
   const { openModal } = useModal();
 
   const localStorageInStock = useLocalStorage<boolean>(IN_STOCK_PRODUCTS_LOCAL_STORAGE, true);
   const localStorageBranches = useLocalStorage<string[]>(FFC_LOCAL_STORAGE, []);
 
+  const pageQueryParam = useRouteQueryParam<string>(QueryParamName.Page, {
+    defaultValue: "1",
+    removeDefaultValue: true,
+    validator: (value) => {
+      return String(Number(value)) === value && Number(value) > 0 && Number.isInteger(Number(value));
+    },
+  });
+
   const sortQueryParam = useRouteQueryParam<string>(QueryParamName.Sort, {
     defaultValue: PRODUCT_SORTING_LIST[0].id,
     validator: (value) => PRODUCT_SORTING_LIST.some((item) => item.id === value),
@@ -66,6 +84,12 @@ export function useProducts(
   const totalProductsCount = ref(0);
   const pagesCount = ref(1);
   const isFiltersSidebarVisible = ref(false);
+  const currentPage = ref(
+    catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore && pageQueryParam.value
+      ? Number(pageQueryParam.value)
+      : 1,
+  );
+  const pageHistory = ref<number[]>([]);
 
   const products = ref<Product[]>([]);
   const facets = shallowRef<FacetItemType[]>([]);
@@ -142,31 +166,35 @@ export function useProducts(
       localStorageBranches.value = newFilters.branches;
     }
 
-    resetCurrentPage();
+    void resetCurrentPage();
   }
 
-  function removeFacetFilter(payload: Pick<FacetItemType, "paramName"> & Pick<FacetValueItemType, "value">): void {
+  async function removeFacetFilter(payload: Pick<FacetItemType, "paramName"> & Pick<FacetValueItemType, "value">) {
     const facet = productsFilters.value.facets.find((item) => item.paramName === payload.paramName);
     const facetValue = facet?.values.find((item) => item.value === payload.value);
 
     if (facetValue) {
       facetValue.selected = false;
       facetsQueryParam.value = options?.useQueryParams ? getFilterExpressionFromFacets(facets) : "";
+      await new Promise((resolve) => setTimeout(resolve, 0));
+      // needs to wait for the router to update the query params, because of race condition on setting query params with useRouteQueryParam composable
 
       triggerRef(facets);
-      resetCurrentPage();
+      void resetCurrentPage();
     }
   }
 
-  function resetFacetFilters(): void {
+  async function resetFacetFilters() {
     facetsQueryParam.value = "";
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    // needs to wait for the router to update the query params, because of race condition on setting query params with useRouteQueryParam composable
 
     productsFilters.value.facets.forEach((filter) =>
       filter.values.forEach((filterItem) => (filterItem.selected = false)),
     );
 
     triggerRef(facets);
-    resetCurrentPage();
+    void resetCurrentPage();
   }
 
   function resetFilterKeyword(): void {
@@ -199,7 +227,7 @@ export function useProducts(
           } else {
             localStorageBranches.value = branches;
           }
-          resetCurrentPage();
+          void resetCurrentPage();
         },
       },
     });
@@ -223,12 +251,24 @@ export function useProducts(
     );
   }
 
-  async function fetchProducts(searchParams: Partial<ProductsSearchParamsType>) {
+  async function fetchProducts(_searchParams: Partial<ProductsSearchParamsType>) {
+    const searchParams = {
+      ..._searchParams,
+      page:
+        catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore && _searchParams.page === undefined
+          ? currentPage.value
+          : _searchParams.page,
+    };
+
     fetchingProducts.value = true;
     products.value = [];
     totalProductsCount.value = 0;
     pagesCount.value = 1;
 
+    if (searchParams.page) {
+      updateCurrentPage(Number(searchParams.page));
+    }
+
     try {
       const {
         items = [],
@@ -244,6 +284,8 @@ export function useProducts(
         PAGE_LIMIT,
       );
 
+      addPageHistory(searchParams.page ?? 1);
+
       if (withFacets) {
         setFacets({
           termFacets: term_facets,
@@ -270,12 +312,29 @@ export function useProducts(
     try {
       const { items = [], totalCount = 0 } = await searchProducts(searchParams, { withImages, withZeroPrice });
 
-      products.value = products.value.concat(items);
+      const page = searchParams.page;
+      const minVisitedPage = Math.min(...pageHistory.value);
+
+      if (
+        catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore &&
+        page &&
+        minVisitedPage &&
+        page < minVisitedPage
+      ) {
+        // if load previous page, we need to add new items to the beginning of the array
+        products.value = [...items, ...products.value];
+      } else {
+        // if load next page, we need to add new items to the end of the array
+        products.value = products.value.concat(items);
+      }
+
       totalProductsCount.value = totalCount;
       pagesCount.value = Math.min(
         Math.ceil(totalProductsCount.value / (searchParams.itemsPerPage || DEFAULT_ITEMS_PER_PAGE)),
         PAGE_LIMIT,
       );
+
+      addPageHistory(page);
     } catch (e) {
       Logger.error(`useProducts.${fetchMoreProducts.name}`, e);
       throw e;
@@ -284,6 +343,12 @@ export function useProducts(
     }
   }
 
+  function addPageHistory(page?: number) {
+    if (page && page <= pagesCount.value && !pageHistory.value.includes(page)) {
+      pageHistory.value.push(page);
+    }
+  }
+
   async function getFacets(searchParams: Partial<ProductsSearchParamsType>): Promise<FacetItemType[]> {
     fetchingFacets.value = true;
 
@@ -309,14 +374,22 @@ export function useProducts(
     }
   }
 
-  const currentPage = ref(1);
-
   function updateCurrentPage(page: number) {
     currentPage.value = page;
+
+    if (catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore && page > Math.max(...pageHistory.value)) {
+      pageQueryParam.value = page.toString();
+    }
   }
 
-  function resetCurrentPage() {
+  async function resetCurrentPage() {
     updateCurrentPage(1);
+    if (catalogPaginationMode === CATALOG_PAGINATION_MODES.loadMore) {
+      await new Promise((resolve) => setTimeout(resolve, 0));
+      // needs to wait for the router to update the query params, because of race condition on setting query params with useRouteQueryParam composable
+      pageQueryParam.value = "";
+      pageHistory.value = [1];
+    }
   }
 
   return {
@@ -340,6 +413,7 @@ export function useProducts(
     totalProductsCount: readonly(totalProductsCount),
 
     currentPage: readonly(currentPage),
+    pageHistory: readonly(pageHistory),
     resetCurrentPage,
     updateCurrentPage,
 
diff --git a/client-app/shared/catalog/constants/catalog.ts b/client-app/shared/catalog/constants/catalog.ts
new file mode 100644
index 0000000000..b1f2e4863f
--- /dev/null
+++ b/client-app/shared/catalog/constants/catalog.ts
@@ -0,0 +1,4 @@
+export const CATALOG_PAGINATION_MODES = {
+  loadMore: "load_more",
+  infiniteScroll: "infinite_scroll",
+} as const;
diff --git a/client-app/shared/catalog/types/catalog.ts b/client-app/shared/catalog/types/catalog.ts
new file mode 100644
index 0000000000..148c442287
--- /dev/null
+++ b/client-app/shared/catalog/types/catalog.ts
@@ -0,0 +1,3 @@
+import type { CATALOG_PAGINATION_MODES } from "@/shared/catalog/constants/catalog";
+
+export type CatalogPaginationModeType = (typeof CATALOG_PAGINATION_MODES)[keyof typeof CATALOG_PAGINATION_MODES];
diff --git a/client-app/shared/catalog/types/index.ts b/client-app/shared/catalog/types/index.ts
index 53850daf21..862b82ecf1 100644
--- a/client-app/shared/catalog/types/index.ts
+++ b/client-app/shared/catalog/types/index.ts
@@ -1,2 +1,3 @@
+export * from "./catalog";
 export * from "./filtersDisplayOrder";
 export * from "./search";
diff --git a/client-app/ui-kit/components/atoms/infinity-scroll-loader/vc-infinity-scroll-loader.vue b/client-app/ui-kit/components/atoms/infinity-scroll-loader/vc-infinity-scroll-loader.vue
index ba372a4f28..82ec018f98 100644
--- a/client-app/ui-kit/components/atoms/infinity-scroll-loader/vc-infinity-scroll-loader.vue
+++ b/client-app/ui-kit/components/atoms/infinity-scroll-loader/vc-infinity-scroll-loader.vue
@@ -60,13 +60,11 @@ function initObserver(): void {
 
 onMounted(() => {
   initObserver();
-  document.documentElement.style.setProperty("overflow-anchor", "none");
 });
 
 onBeforeUnmount(() => {
   observer?.disconnect();
   observer = null;
-  document.documentElement.style.removeProperty("overflow-anchor");
 });
 
 watch(() => [props.viewport, props.distance], initObserver);
diff --git a/client-app/ui-kit/components/molecules/button/vc-button.vue b/client-app/ui-kit/components/molecules/button/vc-button.vue
index 70e60a3cde..ac2750d6b8 100644
--- a/client-app/ui-kit/components/molecules/button/vc-button.vue
+++ b/client-app/ui-kit/components/molecules/button/vc-button.vue
@@ -2,6 +2,7 @@
   <component
     :is="componentTag"
     v-bind="attrs"
+    ref="buttonRef"
     :target="target"
     :type="componentTag === 'button' ? type : null"
     :disabled="!enabled"
@@ -54,7 +55,7 @@
 
 <script setup lang="ts">
 import { eagerComputed } from "@vueuse/core";
-import { computed, inject } from "vue";
+import { computed, inject, nextTick, ref, watch } from "vue";
 import type { RouteLocationRaw } from "vue-router";
 
 export interface IEmits {
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<IProps>(), {
 });
 
 const inputContext = inject<VcInputContextType | null>("inputContext", null);
+const buttonRef = ref<HTMLElement | null>(null);
 
 const _size = computed(() => {
   if (props.size) {
@@ -166,6 +168,14 @@ const attrs = computed(() => {
 
   return attributes;
 });
+
+watch(enabled, async (newValue, oldValue) => {
+  await nextTick();
+  if (newValue && oldValue === false && document.activeElement === document.body) {
+    // return focus after button is enabled if it was focused before
+    buttonRef.value?.focus({ preventScroll: true });
+  }
+});
 </script>
 
 <style lang="scss">
diff --git a/locales/de.json b/locales/de.json
index 681ab3bf27..13221219a1 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -75,6 +75,7 @@
       "all_orders": "Alle Bestellungen",
       "my_orders": "Meine Bestellungen",
       "scroll_to_top": "Nach oben scrollen",
+      "reset_page": "Seite zurücksetzen",
       "show_less": "Weniger anzeigen",
       "expand_all": "Alle ausklappen"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Keine Ergebnisse",
       "no_products_button": "Suche zurücksetzen",
       "products_icon": "Produktikone",
-      "show_all_results": "Alle Ergebnisse anzeigen"
+      "show_all_results": "Alle Ergebnisse anzeigen",
+      "load_previous_page": "Vorherige Seite laden",
+      "load_next_page": "Nächste Seite laden"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/en.json b/locales/en.json
index 5b2c677898..dd4afd0476 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -25,6 +25,7 @@
       "register": "Register",
       "reset_password": "Reset password",
       "reset_filters": "Reset filters",
+      "reset_page": "Reset page",
       "add_to_cart": "Add to cart",
       "apply": "Apply",
       "send": "Send",
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "No results",
       "no_products_button": "Reset search",
       "products_icon": "Product icon",
-      "show_all_results": "Show all results"
+      "show_all_results": "Show all results",
+      "load_previous_page": "Load previous page",
+      "load_next_page": "Load next page"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/es.json b/locales/es.json
index 3d13bf71cd..f45193ea8d 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -75,6 +75,7 @@
       "all_orders": "Todos los pedidos",
       "my_orders": "Mis pedidos",
       "scroll_to_top": "Ir arriba",
+      "reset_page": "Restablecer página",
       "show_less": "Mostrar menos",
       "expand_all": "Expandir todo"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "No se encontraron resultados",
       "no_products_button": "Restablecer búsqueda",
       "products_icon": "Icono de producto",
-      "show_all_results": "Mostrar todos los resultados"
+      "show_all_results": "Mostrar todos los resultados",
+      "load_previous_page": "Cargar página anterior",
+      "load_next_page": "Cargar página siguiente"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/fr.json b/locales/fr.json
index e83e4a86d3..9161b64e00 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -75,6 +75,7 @@
       "all_orders": "Toutes les commandes",
       "my_orders": "Mes commandes",
       "scroll_to_top": "Haut de page",
+      "reset_page": "Réinitialiser la page",
       "show_less": "Afficher moins",
       "expand_all": "Tout développer"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Aucun résultat",
       "no_products_button": "Réinitialiser la recherche",
       "products_icon": "Icône du produit",
-      "show_all_results": "Afficher tous les résultats"
+      "show_all_results": "Afficher tous les résultats",
+      "load_previous_page": "Charger la page précédente",
+      "load_next_page": "Charger la page suivante"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/it.json b/locales/it.json
index 31516b279f..75980cd6ae 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -75,6 +75,7 @@
       "all_orders": "Tutti gli ordini",
       "my_orders": "I miei ordini",
       "scroll_to_top": "Torna su",
+      "reset_page": "Resetta pagina",
       "show_less": "Mostra di meno",
       "expand_all": "Espandi tutto"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Nessun risultato",
       "no_products_button": "Reimposta ricerca",
       "products_icon": "Icona prodotto",
-      "show_all_results": "Mostra tutti i risultati"
+      "show_all_results": "Mostra tutti i risultati",
+      "load_previous_page": "Carica la pagina precedente",
+      "load_next_page": "Carica la pagina successiva"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/ja.json b/locales/ja.json
index 71dfe8e39a..2e823f3852 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -75,6 +75,7 @@
       "all_orders": "すべての注文",
       "my_orders": "私の注文",
       "scroll_to_top": "トップへスクロール",
+      "reset_page": "ページをリセット",
       "show_less": "折りたたむ",
       "expand_all": "すべて展開"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "該当なし",
       "no_products_button": "検索をリセット",
       "products_icon": "商品アイコン",
-      "show_all_results": "すべての結果を表示"
+      "show_all_results": "すべての結果を表示",
+      "load_previous_page": "前のページを読み込む",
+      "load_next_page": "次のページを読み込む"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/pl.json b/locales/pl.json
index 188bf5239a..d1ad394f5e 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -75,6 +75,7 @@
       "all_orders": "Wszystkie zamówienia",
       "my_orders": "Moje zamówienia",
       "scroll_to_top": "Przewiń do góry",
+      "reset_page": "Resetuj stronę",
       "show_less": "Pokaż mniej",
       "expand_all": "Rozwiń wszystko"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Brak wyników",
       "no_products_button": "Resetuj wyszukiwanie",
       "products_icon": "Ikona produktu",
-      "show_all_results": "Pokaż wszystkie wyniki"
+      "show_all_results": "Pokaż wszystkie wyniki",
+      "load_previous_page": "Załaduj poprzednią stronę",
+      "load_next_page": "Załaduj następną stronę"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/pt.json b/locales/pt.json
index bd7fd67c2c..22cdb264b3 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -75,6 +75,7 @@
       "all_orders": "Todas as encomendas.",
       "my_orders": "Minhas encomendas",
       "scroll_to_top": "Voltar ao topo",
+      "reset_page": "Redefinir página",
       "show_less": "Mostrar menos",
       "expand_all": "Expandir tudo"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Nenhum resultado",
       "no_products_button": "Resetar pesquisa",
       "products_icon": "Ícone de produto",
-      "show_all_results": "Mostrar todos os resultados"
+      "show_all_results": "Mostrar todos os resultados",
+      "load_previous_page": "Carregar página anterior",
+      "load_next_page": "Carregar próxima página"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/ru.json b/locales/ru.json
index 846ae245b4..08aa9e193d 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -75,6 +75,7 @@
       "all_orders": "Все заказы",
       "my_orders": "Мои заказы",
       "scroll_to_top": "Прокрутить вверх",
+      "reset_page": "Сбросить страницу",
       "show_less": "Показать меньше",
       "expand_all": "Развернуть все"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "Результатов нет",
       "no_products_button": "Очистить поиск",
       "products_icon": "Иконка товара",
-      "show_all_results": "Показать все результаты"
+      "show_all_results": "Показать все результаты",
+      "load_previous_page": "Загрузить предыдущую страницу",
+      "load_next_page": "Загрузить следующую страницу"
     },
     "product": {
       "sku_label": "@:common.labels.sku",
diff --git a/locales/zh.json b/locales/zh.json
index 4b02d62216..fb2e6663f3 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -75,6 +75,7 @@
       "all_orders": "所有订单",
       "my_orders": "我的订单",
       "scroll_to_top": "回到顶部",
+      "reset_page": "重置页面",
       "show_less": "收起",
       "expand_all": "全部展开"
     },
@@ -1303,7 +1304,9 @@
       "no_facet_found_message": "无结果",
       "no_products_button": "重置搜索",
       "products_icon": "产品图标",
-      "show_all_results": "显示所有结果"
+      "show_all_results": "显示所有结果",
+      "load_previous_page": "加载上一页",
+      "load_next_page": "加载下一页"
     },
     "product": {
       "sku_label": "@:common.labels.sku",


Reviews:
user: login: NaMax66
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:09:20Z
user: login: ivan-kalachikov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T16:39:02Z
user: login: ivan-kalachikov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T16:55:27Z
user: login: NaMax66
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T09:55:35Z
user: login: NaMax66
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T08:41:39Z

Review comments:
diff_hunk: @@ -38,6 +38,7 @@
     "logo_inverted_image": "logo-white.svg",
     "favicon_image": "/static/icons/favicon-32x32.png",
     "homepage_background_image": "main-banner.webp",
+    "catalog_mode": "load_more",
path: client-app/config/settings_data.json
user: login: NaMax66
body: catalog_pagination_mode
created_at: 2025-04-15T14:03:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,9 +119,29 @@ const keywordQueryParam = useRouteQueryParam<string>(QueryParamName.Keyword, {
   defaultValue: "",
 });
 
+function loadPreviousPage() {
+  emit("changePage", minVisitedPage.value - 1);
+}
+
+function loadNextPage() {
+  emit("changePage", maxVisitedPage.value + 1);
+}
+
 const pageNumber = toRef(props, "pageNumber");
+const pageHistory = toRef(props, "pageHistory");
+
+const minVisitedPage = computed(() => Math.min(...pageHistory.value));
+const maxVisitedPage = computed(() => Math.max(...pageHistory.value));
 
 function sendGASelectItemEvent(product: Product): void {
   emit("selectProduct", product);
 }
+
+onMounted(() => {
+  document.documentElement.style.setProperty("overflow-anchor", "none");
path: client-app/shared/catalog/components/category/category-products.vue
user: login: NaMax66
body: why this? could it be implemented with vue tools?
created_at: 2025-04-15T14:06:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,6 +38,7 @@
     "logo_inverted_image": "logo-white.svg",
     "favicon_image": "/static/icons/favicon-32x32.png",
     "homepage_background_image": "main-banner.webp",
+    "catalog_mode": "load_more",
path: client-app/config/settings_data.json
user: login: ivan-kalachikov
body: renamed that property and all related constants
created_at: 2025-04-15T16:39:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,9 +119,29 @@ const keywordQueryParam = useRouteQueryParam<string>(QueryParamName.Keyword, {
   defaultValue: "",
 });
 
+function loadPreviousPage() {
+  emit("changePage", minVisitedPage.value - 1);
+}
+
+function loadNextPage() {
+  emit("changePage", maxVisitedPage.value + 1);
+}
+
 const pageNumber = toRef(props, "pageNumber");
+const pageHistory = toRef(props, "pageHistory");
+
+const minVisitedPage = computed(() => Math.min(...pageHistory.value));
+const maxVisitedPage = computed(() => Math.max(...pageHistory.value));
 
 function sendGASelectItemEvent(product: Product): void {
   emit("selectProduct", product);
 }
+
+onMounted(() => {
+  document.documentElement.style.setProperty("overflow-anchor", "none");
path: client-app/shared/catalog/components/category/category-products.vue
user: login: ivan-kalachikov
body: https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor

you are right, moved it to style of the category component
created_at: 2025-04-15T16:55:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-30T08:43:36Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-theme-b2b-vue&pullRequest=1683)


author_association: NONE