test2

PHP-обработчик, который отдаёт элементы постранично (с фильтрами), и jQuery-скрипт, который подгружает их (кнопкой «Ещё» или бесконечной прокруткой). Всё на стандартном CIBlockElement.

1) Подключаем jQuery и наш скрипт

В шаблоне (обычно /local/templates/ВАШ_ШАБЛОН/header.php):

php
1) Подключаем jQuery и наш скрипт

В шаблоне (обычно /local/templates/ВАШ_ШАБЛОН/header.php):

2) HTML-разметка страницы

В нужном шаблоне/странице выведи контейнер и фильтры:

php
<?php
// можно прокинуть ID инфоблока/раздела как data-атрибуты
$IBLOCK_ID = 10;
$SECTION_ID = 123;
?>
<div id="catalog" class="catalog"
     data-iblock="<?=$IBLOCK_ID?>"
     data-section="<?=$SECTION_ID?>">

  <form id="catalog-filters" class="catalog__filters">
    <input type="text" name="q" placeholder="Поиск по названию" />
    <select name="color">
      <option value="">Цвет (любой)</option>
      <option value="red">Красный</option>
      <option value="blue">Синий</option>
    </select>
    <input type="number" name="price_min" placeholder="Цена от" min="0" step="1" />
    <input type="number" name="price_max" placeholder="Цена до" min="0" step="1" />
    <button type="submit">Найти</button>
  </form>

  <div id="catalog-items" class="catalog__items"></div>

  <button id="catalog-more" class="catalog__more" hidden>Загрузить ещё</button>
  <div id="catalog-loader" class="catalog__loader" hidden>Загрузка…</div>
  <div id="catalog-empty" class="catalog__empty" hidden>Ничего не найдено</div>
</div>

3) AJAX-обработчик /local/ajax/items.php

Создай файл /local/ajax/items.php:

php
<?php
// /local/ajax/items.php
define('NO_KEEP_STATISTIC', true);
define('STOP_STATISTICS', true);
define('NO_AGENT_STATISTIC', true);
define('DisableEventsCheck', true);

require $_SERVER['DOCUMENT_ROOT'].'/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\Context;
use Bitrix\Main\Loader;

header('Content-Type: application/json; charset=UTF-8');

if (!Loader::includeModule('iblock')) {
  echo json_encode(['error' => 'iblock module not loaded']); die();
}

$request  = Context::getCurrent()->getRequest();

// входные параметры
$iblockId   = (int)$request->get('iblockId');
$sectionId  = (int)$request->get('sectionId');
$page       = max(1, (int)$request->get('page'));
$pageSize   = min(60, max(1, (int)$request->get('pageSize') ?: 12));
$includeSub = $request->get('includeSub') === 'Y';

// фильтры
$q         = trim((string)$request->get('q'));
$color     = trim((string)$request->get('color'));
$priceMin  = $request->get('price_min') !== null ? (float)$request->get('price_min') : null;
$priceMax  = $request->get('price_max') !== null ? (float)$request->get('price_max') : null;

$filter = [
  'IBLOCK_ID' => $iblockId,
  'ACTIVE'    => 'Y',
];

if ($sectionId > 0) {
  $filter['SECTION_ID'] = $sectionId;
  if ($includeSub) {
    $filter['INCLUDE_SUBSECTIONS'] = 'Y';
  }
}

if ($q !== '') {
  // поиск по имени (можно заменить на full-text, если нужно)
  $filter['?NAME'] = $q;
}

if ($color !== '') {
  $filter['PROPERTY_COLOR'] = $color;
}

if ($priceMin !== null) {
  $filter['>=PROPERTY_PRICE'] = $priceMin;
}
if ($priceMax !== null) {
  $filter['<=PROPERTY_PRICE'] = $priceMax;
}

$select = [
  'ID','IBLOCK_ID','NAME','DETAIL_PAGE_URL','PREVIEW_PICTURE',
  'PROPERTY_COLOR','PROPERTY_PRICE'
];

$nav = [
  'nPageSize'        => $pageSize,
  'iNumPage'         => $page,
  'checkOutOfRange'  => true,
  'bShowAll'         => false,
];

$res = CIBlockElement::GetList(
  ['SORT' => 'ASC', 'ID' => 'DESC'],
  $filter,
  false,
  $nav,
  $select
);

// навигационные данные
$pageCount = (int)$res->NavPageCount;
$total     = (int)$res->NavRecordCount;

$items = [];

while ($ob = $res->GetNextElement()) {
  $f = $ob->GetFields();
  $p = $ob->GetProperties();

  $img = null;
  if ((int)$f['PREVIEW_PICTURE'] > 0) {
    $pic = CFile::ResizeImageGet(
      $f['PREVIEW_PICTURE'],
      ['width' => 400, 'height' => 400],
      BX_RESIZE_IMAGE_PROPORTIONAL,
      true
    );
    $img = $pic['src'];
  }

  $items[] = [
    'id'    => (int)$f['ID'],
    'name'  => $f['~NAME'],
    'url'   => $f['DETAIL_PAGE_URL'],
    'img'   => $img,
    'color' => $p['COLOR']['VALUE'] ?? null,
    'price' => $p['PRICE']['VALUE'] ?? null,
  ];
}

echo json_encode([
  'items'     => $items,
  'page'      => $page,
  'pageCount' => $pageCount,
  'total'     => $total,
], JSON_UNESCAPED_UNICODE);
  1. jQuery-скрипт /local/templates/.../js/catalog.js
javascript
jQuery(function($){
  const $root   = $('#catalog');
  if (!$root.length) return;

  const $items  = $('#catalog-items');
  const $form   = $('#catalog-filters');
  const $more   = $('#catalog-more');
  const $loader = $('#catalog-loader');
  const $empty  = $('#catalog-empty');

  const iblockId  = parseInt($root.data('iblock'), 10);
  const sectionId = parseInt($root.data('section'), 10) || 0;

  const state = {
    page: 1,
    pageCount: 1,
    loading: false,
    pageSize: 12,
    lastQueryKey: ''
  };

  function queryParams(extra={}) {
    const fd = new FormData($form[0]);
    const params = {
      iblockId,
      sectionId,
      includeSub: 'Y',
      page: state.page,
      pageSize: state.pageSize
    };
    for (const [k,v] of fd.entries()) {
      if (v !== '') params[k] = v;
    }
    return Object.assign(params, extra);
  }

  function renderCard(it){
    const img = it.img ? `<img src="${it.img}" alt="${escapeHtml(it.name)}" loading="lazy">` : '';
    const price = it.price ? `<div class="card__price">${it.price}</div>` : '';
    const color = it.color ? `<div class="card__color">Цвет: ${escapeHtml(it.color)}</div>` : '';
    return `
      <a class="card" href="${it.url}">
        <div class="card__img">${img}</div>
        <div class="card__body">
          <div class="card__title">${escapeHtml(it.name)}</div>
          <div class="card__meta">
            ${price}
            ${color}
          </div>
        </div>
      </a>
    `;
  }

  function escapeHtml(s){
    return String(s).replace(/[&<>"']/g, m => ({
      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    }[m]));
  }

  function setLoading(flag){
    state.loading = !!flag;
    $loader.prop('hidden', !flag);
    $more.prop('disabled', !!flag);
  }

  function updateMoreVisibility(){
    const hasMore = state.page < state.pageCount;
    $more.prop('hidden', !hasMore);
  }

  function fetchItems({append=false} = {}){
    if (state.loading) return;
    setLoading(true);
    $empty.prop('hidden', true);

    const params = queryParams();
    // ключ запроса для предотвращения гонок
    const queryKey = JSON.stringify(Object.assign({}, params, { page: append ? state.page : 1 }));
    state.lastQueryKey = queryKey;

    $.ajax({
      url: '/local/ajax/items.php',
      method: 'GET',
      data: params,
      dataType: 'json'
    }).done(function(resp){
      // защита от «переехавших» ответов
      if (state.lastQueryKey !== queryKey) return;

      const list = Array.isArray(resp.items) ? resp.items : [];
      state.page      = resp.page || 1;
      state.pageCount = resp.pageCount || 1;

      if (!append) $items.empty();

      if (list.length) {
        const html = list.map(renderCard).join('');
        $items.append(html);
      }

      if (!list.length && !append && state.pageCount === 1) {
        $empty.prop('hidden', false);
      }

      updateMoreVisibility();
    }).fail(function(){
      // здесь можно показать toast/ошибку
      console.error('Ошибка загрузки элементов');
    }).always(function(){
      setLoading(false);
    });
  }

  // Первичная загрузка
  fetchItems();

  // Сабмит фильтров — начинаем с первой страницы
  $form.on('submit', function(e){
    e.preventDefault();
    state.page = 1;
    fetchItems({append:false});
  });

  // Изменение фильтров — дебаунс
  let t;
  $form.on('input change', function(){
    clearTimeout(t);
    t = setTimeout(function(){
      state.page = 1;
      fetchItems({append:false});
    }, 300);
  });

  // Кнопка «Загрузить ещё»
  $more.on('click', function(){
    if (state.loading) return;
    if (state.page >= state.pageCount) return;
    state.page += 1;
    fetchItems({append:true});
  });

  // Бесконечная прокрутка (опционально)
  let scrollT;
  $(window).on('scroll', function(){
    if (state.loading) return;
    if (state.page >= state.pageCount) return;

    clearTimeout(scrollT);
    scrollT = setTimeout(function(){
      const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
      if (nearBottom) {
        state.page += 1;
        fetchItems({append:true});
      }
    }, 100);
  });
});
  1. Простейшие стили /local/templates/.../css/catalog.css (по желанию)
css
.catalog__filters { display:flex; gap:8px; flex-wrap:wrap; margin:16px 0; }
.catalog__items { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:16px; }
.card { display:block; border:1px solid #eee; border-radius:12px; overflow:hidden; text-decoration:none; color:inherit; }
.card__img { aspect-ratio: 1 / 1; display:flex; align-items:center; justify-content:center; background:#fafafa; }
.card__img img { max-width:100%; max-height:100%; display:block; }
.card__body { padding:12px; }
.card__title { font-weight:600; margin-bottom:6px; }
.card__meta { font-size:14px; opacity:.8; display:flex; gap:8px; flex-wrap:wrap; }
.catalog__more { display:block; margin:16px auto; padding:10px 16px; }
.catalog__loader, .catalog__empty { text-align:center; margin:16px 0; }

Как это работает

  • PHP отдаёт JSON с элементами и числами страниц (page, pageCount).
  • jQuery:
    • рендерит карточки;
    • применяет фильтры (поиск, цвет, цена);
    • подгружает следующую страницу кнопкой или при прокрутке;
    • показывает «Ничего не найдено», если пусто.

Хочешь — добавлю сортировку (селект «Цена ↑/↓», «По имени», «По дате») и серверную обработку ($order в GetList). Или интегрирую сюда свойства-списки/множественные и фильтр по разделам с «включая под-разделы».