Это улучшенный вариант двух прошлых заметок: Уникальный URL для торговых предложений (SKU) и «Уникальная СЕО информация для каждого торгового предложения». Там всё строилось на GET-параметре ?offer= и кастомном 404.php. Здесь то же самое делается аккуратно, на новой системе роутингов Битрикса.
Почему прошлый способ стоит улучшить
- ЧПУ с ID предложения в пути обрабатывался через кастомный 404.php, который на каждую несуществующую страницу перебирает все предложения инфоблока — лишняя нагрузка и хрупкая логика.
- Мета-информация подставлялась через ручное выключение штатных SET_TITLE/SET_META_* в element.php — легко сломать при обновлении типового компонента.
- URL получался с GET-параметром ?offer=ID — не самый красивый вариант для шэринга и SEO.
Новая система роутингов (класс Bitrix\Main\Routing\RoutingConfigurator, файл local/routes/web.php) позволяет объявить URL с ID предложения заранее, поэтому всё перечисленное не нужно.
Как это устроено
Схема такая: роутер принимает ЧПУ вида /catalog/<раздел>/<товар>/<ID-ТП>/, забирает числовой ID из последнего сегмента, кладёт его в запрос как OFFER_ID и отдаёт управление каталогу уже с «чистым» путём. Дальше битриксовый bitrix:catalog находит обычный товар, а шаблон карточки по OFFER_ID выбирает предложение и подставляет его мета-информацию.
1. Маршруты
Файл local/routes/web.php. Важен порядок: сначала маршрут с ID предложения, потом общий по каталогу — иначе общий правило «съест» адрес с ID.
<?php
use Bitrix\Main\Application;
use Bitrix\Main\Routing\RoutingConfigurator;
use Bitrix\Main\Routing\Controllers\PublicPageController;
return static function (RoutingConfigurator $routes) {
$routes->any('/catalog', new PublicPageController('/catalog/index.php'));
$routes->any('/catalog/', new PublicPageController('/catalog/index.php'));
// URL торгового предложения: /catalog/<раздел>/<товар>/<ID-ТП>/
// Числовой ID последним сегментом отдаём каталогу как OFFER_ID,
// а путь нормализуем до чистого SEF, чтобы bitrix:catalog нашёл товар.
$routes
->any('/catalog/{path}/{offerId}/', function ($path, $offerId) {
$request = Application::getInstance()->getContext()->getRequest();
$request->set('OFFER_ID', (int)$offerId);
$_REQUEST['OFFER_ID'] = (int)$offerId;
$cleanUri = '/catalog/' . $path . '/';
$_SERVER['REQUEST_URI'] = $cleanUri;
$request->setRequestUri($cleanUri);
require $_SERVER['DOCUMENT_ROOT'] . '/catalog/index.php';
})
->where(['path' => '.+', 'offerId' => '\d+']);
// Остальные страницы каталога: разделы, товары, фильтр.
$routes
->any('/catalog/{path}/', new PublicPageController('/catalog/index.php'))
->where('path', '.+');
};
- offerId ограничен регуляркой \d+, поэтому маршрут срабатывает только на числовой последний сегмент.
- В замыкании ID уходит в запрос как OFFER_ID, а REQUEST_URI подменяется на чистый путь без ID — SEF-движок каталога видит обычный товар.
- Если у товаров могут быть числовые символьные коды, добавьте проверку, что последний сегмент действительно является торговым предложением этого товара.
2. ЧПУ и подмена h1 при переключении
В script.js шаблона catalog.element добавляем две функции и вызываем их в конце selectOfferProp. ID дописывается в конец пути:
setOffer: function(offerNum)
{
this.offerNum = parseInt(offerNum);
this.setCurrent();
this.updateH1ByOffer();
},
updateUrlByOffer: function()
{
var offerId = (this.offers && this.offers[this.offerNum]) ? this.offers[this.offerNum].ID : 0;
if (!offerId)
return;
// Снимаем старый ID предложения из конца пути и дописываем выбранный.
var path = window.location.pathname
.replace(/\/\d+\/?$/, '/') // убрать предыдущий ID
.replace(/\/+$/, ''); // убрать хвостовые слэши
var url = path + '/' + offerId + '/';
if (url !== window.location.pathname)
{
history.replaceState(null, '', url);
}
},
updateH1ByOffer: function()
{
var h1 = document.querySelector('h1');
if (!h1)
return;
var offerName = (this.offers && this.offers[this.offerNum]) ? this.offers[this.offerNum].NAME : '';
if (offerName)
{
h1.textContent = offerName;
}
},
И в selectOfferProp, в самом конце перед закрывающей скобкой:
// start CUSTOM
this.updateUrlByOffer();
this.updateH1ByOffer();
// end CUSTOM
- updateUrlByOffer() меняет адрес без перезагрузки и получает ЧПУ вида /catalog/.../tovar/123/.
- updateH1ByOffer() подменяет текст тега <h1> на название предложения. Ищем именно тег, а не id=pagetitle, — код не зависит от вёрстки.
3. Уникальные СЕО-мета при заходе по прямой ссылке
Парсить URL в шаблоне не нужно — роутер уже положил ID в запрос как OFFER_ID. В component_epilog.php шаблона catalog.element читаем его и подставляем мету конкретного предложения:
<?php
// select target offer
$offerId = (int)$this->request->get('OFFER_ID');
if ($offerId <= 0)
{
$offerId = (int)($_REQUEST['OFFER_ID'] ?? 0);
}
$offerNum = false;
if ($offerId > 0 && !empty($templateData['OFFER_IDS']) && is_array($templateData['OFFER_IDS']))
{
$offerNum = array_search($offerId, $templateData['OFFER_IDS']);
}
if ($offerNum !== false && !empty($templateData['ITEM']['JS_OFFERS'][$offerNum]['ID']))
{
$realOfferId = (int)$templateData['ITEM']['JS_OFFERS'][$offerNum]['ID'];
$offer = \CIBlockElement::GetList(
array(),
array('=ID' => $realOfferId),
false,
array('nTopCount' => 1),
array('ID', 'NAME', 'IBLOCK_ID', 'CANONICAL_PAGE_URL')
)->GetNext();
if ($offer)
{
// Штатные СЕО-свойства торгового предложения
$seoValues = new \Bitrix\Iblock\InheritedProperty\ElementValues(
$offer['IBLOCK_ID'],
$offer['ID']
);
$seoProps = $seoValues->getValues();
$APPLICATION->SetTitle($offer['NAME']);
$APPLICATION->SetPageProperty('title', $seoProps['ELEMENT_META_TITLE']);
$APPLICATION->SetPageProperty('keywords', $seoProps['ELEMENT_META_KEYWORDS']);
$APPLICATION->SetPageProperty('description', $seoProps['ELEMENT_META_DESCRIPTION']);
if (!empty($offer['CANONICAL_PAGE_URL']))
{
$APPLICATION->SetPageProperty('canonical', $offer['CANONICAL_PAGE_URL']);
}
}
?>
<script>
BX.ready(function () {
if (!!window.<?=$templateData['JS_OBJ']?>) {
window.<?=$templateData['JS_OBJ']?>.setOffer(<?=$offerNum?>);
}
});
</script>
<?php
}
- OFFER_ID из запроса через array_search превращаем в позицию предложения в OFFER_IDS.
- CIBlockElement::GetList отдаёт название, ID инфоблока и канонический URL предложения.
- InheritedProperty\ElementValues возвращает SEO-параметры именно этого предложения.
- $APPLICATION->SetPageProperty устанавливаем мета-теги, SetTitle — заголовок страницы.
- На BX.ready вызываем setOffer() — предложение выбирается при загрузке, а h1 приводит в порядок JS из шага 2.
Почему h1 всё равно меняем на клиенте
В шаблоне каталога тег <h1> выводится до подключения вложенного bitrix:catalog.element. К моменту выполнения component_epilog.php h1 уже отрисован, поэтому серверный SetTitle() его не изменит. Серверный SetTitle нужен для корректного заголовка при прямом заходе, а видимый h1 обязательно правится клиентским updateH1ByOffer().
Про canonical и кеш
- ЧПУ с ID предложения — это тот же документ товара. Если у предложения не задан свой канонический URL, такие адреса должны канонизироваться на базовый URL товара, иначе получите дубли в индексе. Отдельные страницы под SKU — только с явно заданным CANONICAL_PAGE_URL.
- Так как путь перед каталогом нормализуется до одного и того же вида, кеш компонента может отдавать одинаковую разметку для разных предложений. component_epilog.php выполняется и на закешированной странице, поэтому серверная мета и вызов setOffer() отработают корректно. Но если в шаблоне появляется зависимая от предложения разметка в теле компонента — учитывайте OFFER_ID в кеше или отключайте кеш для таких URL.
Итог
Порядок действий: объявляем каталог и отдельный маршрут для предложения в local/routes/web.php; ID предложения принимаем числовым сегментом, кладём в OFFER_ID и нормализуем путь; ЧПУ формируем в JS через history.replaceState; уникальные title/keywords/description/canonical подставляем в component_epilog.php через InheritedProperty\ElementValues; видимый h1 меняем на клиенте. На выходе — человекочитаемый URL с ID предложения, автоматический выбор SKU по ссылке и своя мета для каждого ТП, без кастомного 404 и без ручного дёргания параметров компонента.