Developing price range filtering for 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1173
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    811
  • image_bitrix-bitrix-24-1c_development_of_an_online_appointment_booking_widget_for_a_medical_center_594_0.webp
    Development based on Bitrix, Bitrix24, 1C for the company Development of an Online Appointment Booking Widget for a Medical Center
    564
  • image_bitrix-bitrix-24-1c_mirsanbel_458_0.webp
    Development based on 1C Enterprise for MIRSANBEL
    745
  • image_crm_dolbimby_434_0.webp
    Website development on CRM Bitrix24 for DOLBIMBY
    655
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

Price Range Filtering for 1C-Bitrix

The standard 1C-Bitrix smart filter includes a price range as two text fields — "from" and "to". In most cases this is sufficient — until requirements emerge: a dual-handle slider, instant catalog updates without page reload, correct handling of multiple price types, and boundary values drawn from real data. The standard filter component does not cover these scenarios without custom development.

How Price Filtering Works in Bitrix

The smart filter builds a query parameter in the form PRICE_1[MIN]=100&PRICE_1[MAX]=5000, where 1 is the price type ID. CIBlockElement::GetList accepts these parameters via arFilter with the keys >=PRICE and <=PRICE. With multiple price types, filtering is applied against the base price or the price for the current user's group.

Retrieving the minimum and maximum price from the catalog to initialize the slider:

// Retrieving price boundary values from a catalog section
$priceRange = [];
$res = CPrice::GetList(
    ['PRICE' => 'ASC'],
    [
        'CATALOG_GROUP_ID' => 1,
        'ELEMENT_IBLOCK_ID' => $iblockId,
    ],
    false,
    ['nPageSize' => 1]
);
if ($item = $res->Fetch()) {
    $priceRange['min'] = floatval($item['PRICE']);
}

$res = CPrice::GetList(
    ['PRICE' => 'DESC'],
    [
        'CATALOG_GROUP_ID' => 1,
        'ELEMENT_IBLOCK_ID' => $iblockId,
    ],
    false,
    ['nPageSize' => 1]
);
if ($item = $res->Fetch()) {
    $priceRange['max'] = floatval($item['PRICE']);
}

These values are passed to JavaScript via data-* attributes or JSON in a <script> tag.

Implementing a Dual-Range Slider

Native HTML does not support a slider with two handles. Implementation using two input[type=range] elements with CSS positioning:

class PriceRangeSlider {
  constructor(container, options) {
    this.container = container;
    this.min = options.min || 0;
    this.max = options.max || 100000;
    this.valueMin = options.valueMin || this.min;
    this.valueMax = options.valueMax || this.max;
    this.onChange = options.onChange || function() {};

    this.render();
    this.bindEvents();
  }

  render() {
    this.container.innerHTML = `
      <div class="price-slider">
        <div class="price-slider__track">
          <div class="price-slider__range" id="sliderRange"></div>
        </div>
        <input type="range" class="price-slider__thumb price-slider__thumb--min"
          min="${this.min}" max="${this.max}" value="${this.valueMin}" step="100">
        <input type="range" class="price-slider__thumb price-slider__thumb--max"
          min="${this.min}" max="${this.max}" value="${this.valueMax}" step="100">
      </div>
      <div class="price-inputs">
        <input type="number" class="price-input price-input--min" value="${this.valueMin}">
        <span>—</span>
        <input type="number" class="price-input price-input--max" value="${this.valueMax}">
      </div>
    `;

    this.thumbMin = this.container.querySelector('.price-slider__thumb--min');
    this.thumbMax = this.container.querySelector('.price-slider__thumb--max');
    this.rangeEl = this.container.querySelector('#sliderRange');
    this.inputMin = this.container.querySelector('.price-input--min');
    this.inputMax = this.container.querySelector('.price-input--max');

    this.updateTrack();
  }

  updateTrack() {
    const percent1 = ((this.valueMin - this.min) / (this.max - this.min)) * 100;
    const percent2 = ((this.valueMax - this.min) / (this.max - this.min)) * 100;
    this.rangeEl.style.left = percent1 + '%';
    this.rangeEl.style.width = (percent2 - percent1) + '%';
  }

  bindEvents() {
    this.thumbMin.addEventListener('input', (e) => {
      const val = Math.min(parseInt(e.target.value), this.valueMax - 100);
      this.valueMin = val;
      e.target.value = val;
      this.inputMin.value = val;
      this.updateTrack();
      this.onChange(this.valueMin, this.valueMax);
    });

    this.thumbMax.addEventListener('input', (e) => {
      const val = Math.max(parseInt(e.target.value), this.valueMin + 100);
      this.valueMax = val;
      e.target.value = val;
      this.inputMax.value = val;
      this.updateTrack();
      this.onChange(this.valueMin, this.valueMax);
    });

    this.inputMin.addEventListener('change', (e) => {
      const val = Math.max(this.min, Math.min(parseInt(e.target.value) || this.min, this.valueMax - 100));
      this.valueMin = val;
      e.target.value = val;
      this.thumbMin.value = val;
      this.updateTrack();
      this.onChange(this.valueMin, this.valueMax);
    });

    this.inputMax.addEventListener('change', (e) => {
      const val = Math.min(this.max, Math.max(parseInt(e.target.value) || this.max, this.valueMin + 100));
      this.valueMax = val;
      e.target.value = val;
      this.thumbMax.value = val;
      this.updateTrack();
      this.onChange(this.valueMin, this.valueMax);
    });
  }
}

AJAX Application of the Price Filter

Integrating the slider with AJAX catalog updates:

// Initialization
const priceData = window.__PRICE_RANGE__ || { min: 0, max: 100000 };
const urlParams = new URLSearchParams(window.location.search);
const currentMin = parseInt(urlParams.get('PRICE_1_MIN')) || priceData.min;
const currentMax = parseInt(urlParams.get('PRICE_1_MAX')) || priceData.max;

const slider = new PriceRangeSlider(
  document.getElementById('price-range-container'),
  {
    min: priceData.min,
    max: priceData.max,
    valueMin: currentMin,
    valueMax: currentMax,
    onChange: debounce((min, max) => applyPriceFilter(min, max), 400),
  }
);

function applyPriceFilter(min, max) {
  const url = new URL(window.location.href);

  if (min > priceData.min) {
    url.searchParams.set('PRICE_1_MIN', min);
  } else {
    url.searchParams.delete('PRICE_1_MIN');
  }

  if (max < priceData.max) {
    url.searchParams.set('PRICE_1_MAX', max);
  } else {
    url.searchParams.delete('PRICE_1_MAX');
  }

  // Reset pagination on filter change
  url.searchParams.delete('PAGEN_1');

  loadCatalog(url.toString());
}

function loadCatalog(url) {
  const catalogEl = document.getElementById('catalog-container');
  catalogEl.classList.add('loading');

  fetch(url, {
    headers: { 'X-Requested-With': 'XMLHttpRequest' }
  })
    .then(r => r.text())
    .then(html => {
      const parser = new DOMParser();
      const doc = parser.parseFromString(html, 'text/html');
      const newCatalog = doc.getElementById('catalog-container');
      if (newCatalog) {
        catalogEl.innerHTML = newCatalog.innerHTML;
      }
      catalogEl.classList.remove('loading');
      history.pushState(null, '', url);
    });
}

Server-Side Price Parameter Handling

In the catalog component or filter template — receiving parameters and passing them to CIBlockElement::GetList:

// catalog component template.php
$filterPrice = [];

if (!empty($_REQUEST['PRICE_1_MIN'])) {
    $filterPrice['>=MIN_PRICE'] = floatval($_REQUEST['PRICE_1_MIN']);
}
if (!empty($_REQUEST['PRICE_1_MAX'])) {
    $filterPrice['<=MAX_PRICE'] = floatval($_REQUEST['PRICE_1_MAX']);
}

// Merge with the main filter
$arFilter = array_merge($arFilter, $filterPrice);

When using the smart filter component (bitrix:catalog.smart.filter), the parameters are handled automatically if the slider submits values via the standard filter form fields.

Filtering by Multiple Price Types

B2B catalogs often have multiple price types for different buyer groups. The slider must work with the current group's price:

// Determining the price type ID for the current user
$userGroupIds = CUser::GetUserGroup($USER->GetID());
$priceTypeId = 1; // base price by default

$res = CCatalogGroup::GetList(
    ['ID' => 'ASC'],
    ['BUY' => 'Y'],
);
while ($group = $res->Fetch()) {
    if (array_intersect($userGroupIds, $group['BUY_GROUP_IDS'])) {
        $priceTypeId = $group['ID'];
        break;
    }
}

// Pass price type ID to JS
echo '<script>window.__PRICE_TYPE_ID__ = ' . intval($priceTypeId) . ';</script>';

Case Study: Electronics Catalog with Wide Price Range

An appliance online store had a catalog with prices ranging from 300 to 450,000. The standard "from/to" input fields worked poorly: users typed values manually and made mistakes with zeros. A slider with a logarithmic scale solved the problem — the lower range (300–5,000) occupied as much screen space as the upper range (100,000–450,000).

Logarithmic transformation of slider values:

function toSliderPosition(value, min, max) {
  return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min));
}

function fromSliderPosition(position, min, max) {
  return Math.round(Math.exp(
    Math.log(min) + position * (Math.log(max) - Math.log(min))
  ) / 100) * 100; // Round to hundreds
}

After implementation, the share of users who applied the price filter rose from 12% to 29%, and conversion from filtered catalog pages was 18% higher than from unfiltered pages.

Displaying Product Count

Dynamic counter update without catalog reload:

// Ajax method for retrieving count
if ($_REQUEST['action'] === 'price_count') {
    $min = floatval($_REQUEST['min']);
    $max = floatval($_REQUEST['max']);

    $res = CIBlockElement::GetList(
        [],
        [
            'IBLOCK_ID' => $iblockId,
            'ACTIVE' => 'Y',
            '>=PRICE' => $min,
            '<=PRICE' => $max,
        ],
        []
    );
    $count = $res->SelectedRowsCount();

    header('Content-Type: application/json');
    echo json_encode(['count' => $count]);
    die();
}

JavaScript requests the counter with a 600 ms debounce while the slider handle moves and updates the "Show N products" button.

Timeline

Slider with AJAX updates and standard parameters for a single price type — 2–3 business days. Full implementation with logarithmic scale, multiple price types, product counter, and synchronization with the smart filter — 4–6 business days.