В интернет-магазине каждый лишний клик снижает конверсию. Один из самых раздражающих моментов — выбор города в форме оформления заказа. В Bitrix bitrix:sale.order.ajax
это свойство типа LOCATION. В статье показываю, как автоматически подставлять местоположение покупателя в это поле: сначала — полный рабочий код (он ниже), затем разберём логику, тонкости и еще один более надёжный, «битриксовый» апгрейд.

<?php
// /local/php_interface/init.php
\Bitrix\Main\EventManager::getInstance()->addEventHandlerCompatible(
'sale',
'OnSaleComponentOrderProperties',
[SaleOrderEvents::class, 'fillLocation']
);
class SaleOrderEvents {
public static function fillLocation(&$arUserResult, $request, &$arParams, &$arResult)
{
$registry = \Bitrix\Sale\Registry::getInstance(\Bitrix\Sale\Registry::REGISTRY_TYPE_ORDER);
$orderClassName = $registry->getOrderClassName();
$order = $orderClassName::create(\Bitrix\Main\Application::getInstance()->getContext()->getSite());
$propertyCollection = $order->getPropertyCollection();
foreach ($propertyCollection as $property) {
if ($property->isUtil()) {
continue;
}
$arProperty = $property->getProperty();
if (
$arProperty['TYPE'] === 'LOCATION'
&& array_key_exists($arProperty['ID'], $arUserResult["ORDER_PROP"])
&& !$request->getPost("ORDER_PROP_" . $arProperty['ID'])
&& (
!is_array($arOrder = $request->getPost("order"))
|| !$arOrder["ORDER_PROP_" . $arProperty['ID']]
)
) {
// Определяем местоположение пользователя
$userCityCode = self::getUserCityCode();
if ($userCityCode) {
$arUserResult["ORDER_PROP"][$arProperty['ID']] = $userCityCode;
}
}
}
}
private static function getUserCityCode()
{
// 1. Попробуем получить город из профиля пользователя (если авторизован)
global $USER;
if ($USER->IsAuthorized()) {
$userID = $USER->GetID();
$user = new CUser;
$userData = $user->GetByID($userID)->Fetch();
if (!empty($userData['PERSONAL_CITY'])) {
$location = CSaleLocation::GetByID($userData['PERSONAL_CITY']);
if ($location) {
return $location['CODE'];
}
}
}
// 2. Попробуем определить город по IP
$ip = $_SERVER['REMOTE_ADDR'];
$cityData = self::getCityByIp($ip);
if ($cityData && !empty($cityData['city'])) {
// Ищем местоположение в базе Битрикс по названию города
$filter = array(
'LID' => LANGUAGE_ID,
'CITY_NAME' => $cityData['city']
);
$location = CSaleLocation::GetList(array(), $filter)->Fetch();
if ($location) {
return $location['CODE'];
}
}
// 3. Можно добавить fallback на город по умолчанию
return false;
}
private static function getCityByIp($ip)
{
// Используем сервис для определения города по IP
// Например, можно использовать http://ip-api.com/json/
$url = "http://ip-api.com/json/{$ip}";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
}
?>
Что делает код
Код подписывается на событие OnSaleComponentOrderProperties
модуля sale и, когда форма заказа только собирается, пытается определить город пользователя:
- Проверяет, не ввёл ли пользователь LOCATION сам. Если поле уже заполнено (есть POST), автоподстановка не сработает — мы не переписываем то, что ввёл человек.
- Пытается достать город из профиля (если пользователь авторизован).
- Если не получилось — определяет город по IP через внешний GeoIP-сервис и ищет соответствующий элемент в справочнике местоположений Битрикс.
- Нашёл — записывает CODE города в
$arUserResult["ORDER_PROP"][ID]
. Именно CODE нужен для свойства типа LOCATION.
Как это работает пошагово
1) Регистрация обработчика события
EventManager::getInstance()->addEventHandlerCompatible(
'sale',
'OnSaleComponentOrderProperties',
[SaleOrderEvents::class, 'fillLocation']
);
Когда компонент bitrix:sale.order.ajax
подготавливает набор свойств заказа, он кидает событие. Наш обработчик получает ссылки на $arUserResult
(итог для формы), $arParams
, $arResult
и объект $request
.
2) Получаем коллекцию свойств заказа
$registry = \Bitrix\Sale\Registry::getInstance(\Bitrix\Sale\Registry::REGISTRY_TYPE_ORDER);
$orderClassName = $registry->getOrderClassName();
$order = $orderClassName::create(\Bitrix\Main\Application::getInstance()->getContext()->getSite());
$propertyCollection = $order->getPropertyCollection();
Создаём «пустой» заказ и его PropertyCollection
. Это нужно, чтобы корректно обойти свойства, узнать их типы и ID.
3) Фильтруем и находим LOCATION
foreach ($propertyCollection as $property) {
if ($property->isUtil()) continue; // служебные пропы не трогаем
$arProperty = $property->getProperty();
if (
$arProperty['TYPE'] === 'LOCATION'
&& array_key_exists($arProperty['ID'], $arUserResult["ORDER_PROP"])
&& !$request->getPost("ORDER_PROP_" . $arProperty['ID'])
&& (!is_array($arOrder = $request->getPost("order")) || !$arOrder["ORDER_PROP_" . $arProperty['ID']])
) {
// здесь будет автоподстановка
}
}
Условие гарантирует, что:
- это именно поле типа LOCATION;
- оно присутствует в результирующем наборе;
- пользователь не прислал значение сам (ни в прямом POST, ни во вложенном
order[...]
).
4) Определяем город (getUserCityCode()
)
Функция идёт по цепочке:
- Из профиля: если пользователь залогинен и в профиле заполнен город, пытаемся его использовать.
- По IP: берём IP клиента, вызываем внешний API (
ip-api.com
) → получаем название города → ищем его в базе местоположений Битрикс и берёмCODE
. - Иначе: возвращаем
false
(ничего не подставляем).
5) Подставляем значение
Если получили CODE
— записываем его в $arUserResult["ORDER_PROP"][ID]
. Компонент возьмёт это значение по умолчанию и отрисует селектор LOCATION уже с нужным городом.
Важные нюансы и подводные камни
- PERSONAL_CITY — это текст, а не ID. В профиле пользователя поле «Город» (
PERSONAL_CITY
) обычно строковое, а не «ссылка» на справочник местоположений. В приведённом коде вызовCSaleLocation::GetByID($userData['PERSONAL_CITY'])
сработает только если там действительно ID, что редко. Надёжнее искать по названию (см. улучшенный вариант ниже). - CODE vs ID. Свойство LOCATION в современных установках работает через CODE (символьный код местоположения), а не через числовой ID. Именно его мы и подставляем.
- Мультиязычность. Поиск по
CITY_NAME
чувствителен к языку и орфографии. «Москва» ≠ «Moscow». Убедитесь, что ваш LANGUAGE_ID и база местоположений согласованы. - IP через прокси/CDN.
$_SERVER['REMOTE_ADDR']
может принадлежать прокси, тогда GeoIP покажет «город CDN». Для продакшна лучше учитывать заголовкиX-Forwarded-For
/X-Real-IP
(если доверяете им) или использовать встроенный GeoIP-менеджер Битрикс. - Зависимость от внешнего сервиса. Лимиты, задержки и падения стороннего API — риск. В коде таймаут 3 сек, но всё равно это блокирующий вызов на критическом пути. Желательно кэшировать результаты и/или перейти на локальную базу GeoIP.
- Переопределение пользователем. После автоподстановки пользователь должен иметь возможность выбрать другой город — условие в коде это уважает.
Тест-план (чтобы не словить регресс)
- Гость без POST: город подставляется из GeoIP.
- Авторизованный, город в профиле: берётся профильный город.
- Пользователь вручную меняет LOCATION: наш код не перетирает его выбор.
- Город из GeoIP отсутствует в справочнике: поле остаётся пустым, заказ оформляется.
- Сайт за CDN: проверить корректность IP и что не подставляется «Лондон» вместо «Тверь»
Улучшаем код: D7 + встроенный GeoIP + корректный поиск локации
Преимущества этого подхода:
- Использует встроенный GeoIP Битрикс - более надежно и не требует внешних HTTP-запросов
- Правильное определение IP-адреса с учетом заголовков прокси
- Поиск по названию города в базе местоположений Битрикс
- Приоритетность - сначала проверяет профиль пользователя, затем GeoIP
- Типизация - строгая типизация метода getClientIp()
Вставьте эти методы вместо текущих getUserCityCode()
и getCityByIp()
и поправьте use
-импорты при необходимости.
<?php
use Bitrix\Main\Service\GeoIp\Manager as GeoIpManager;
class SaleOrderEvents
{
// ...
private static function getUserCityCode()
{
// 1) Авторизованный пользователь: PERSONAL_CITY -> ищем по названию
global $USER;
if (is_object($USER) && $USER->IsAuthorized()) {
$user = CUser::GetByID($USER->GetID())->Fetch();
if (!empty($user['PERSONAL_CITY'])) {
if ($code = self::findLocationCodeByCityName($user['PERSONAL_CITY'])) {
return $code;
}
}
}
// 2) Определяем город через встроенный GeoIP (без внешних HTTP)
$ip = self::getClientIp();
$geo = GeoIpManager::getDataResult($ip, LANGUAGE_ID);
if ($geo && $geo->isSuccess()) {
$data = $geo->getGeoData();
if (!empty($data->cityName)) {
if ($code = self::findLocationCodeByCityName($data->cityName)) {
return $code;
}
}
}
// 3) Fallback: верните CODE вашего дефолтного местоположения, если хотите
// return '0000073738';
return false;
}
private static function getClientIp(): string
{
// аккуратнее с заголовками: доверяйте им только если настроены прокси
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return $_SERVER['HTTP_X_REAL_IP'];
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
private static function findLocationCodeByCityName(string $cityName)
{
// Быстрый и совместимый путь: старый API поиска по названию
$loc = CSaleLocation::GetList(
[],
['LID' => LANGUAGE_ID, 'CITY_NAME' => $cityName],
false,
false,
['ID', 'CODE', 'CITY_NAME']
)->Fetch();
return $loc['CODE'] ?? false;
}
// ...
}
?>
Почему так лучше
- Нет внешних запросов на оформлении — быстрее и стабильнее.
- PERSONAL_CITY используется по назначению (как строка), а не как ID.
- Логика осталась прежней: по-прежнему не перезаписываем пользовательский ввод.
SEO-заметки и UX-советы
- Скорость = конверсия. Благодаря автозаполнению LOCATION уменьшаются клики и «срыв» формы — это снижает отказ и повышает CR в чекауте.
- Локализованные тексты доставки. Зная город, можно сразу показывать релевантные сроки/стоимость доставки и самовывозы — CTR по пунктам выдачи обычно растёт.
- Ясные подсказки. Если подставили город автоматически, добавьте рядом «Это не ваш город?» → «Изменить» — повышает доверие и уменьшает раздражение.
Резюме
- Мы подписались на событие
OnSaleComponentOrderProperties
и автозаполняем LOCATION вbitrix:sale.order.ajax
, если пользователь сам его не трогал. - В базовой версии берём город из профиля либо по IP через внешний сервис; в улучшенной — используем встроенный GeoIP и корректный поиск локации по названию.
- Следуем принципу «не вредить»: пользователь всегда может выбрать другой город.