Оформление
Событие onProductRecommendations
Когда размещение товарных рекомендаций работает в режиме Передавать данные через JavaScript, скрипт Sales Ninja не рисует карточки сам. Он передаёт товары и состояние в обработчик. Витриной владеет сайт.
Этот метод нужен только для такого размещения. Если блок вставляет Sales Ninja, подписка не требуется — см. размещение.
Сначала очередь ninja(...) должна получить init и start. Код проекта копируйте из Настройки проекта → Установка на сайт.
html
<script>
ninja('init', '<код проекта>');
ninja('start');
</script>onProductRecommendations можно вызвать до start: вызов встанет в очередь и выполнится после загрузки скрипта. Если вызвать его до init, вернётся ошибка Run init before onProductRecommendations.
Подписка
js
const unsubscribe = await ninja('onProductRecommendations', {
placementId: '11111111-1111-1111-1111-111111111111',
handler: (payload) => {
// payload.status, payload.items, ...
},
})| Поле | Обязательно | Назначение |
|---|---|---|
handler | да | Функция, которая получает каждый результат |
placementId | нет | Идентификатор размещения из кабинета. Без него обработчик получает все JavaScript-размещения |
timeout | нет | Миллисекунды ожидания первого результата, если задан onTimeoutExceeded. По умолчанию 5000 |
onTimeoutExceeded | нет | Вызывается один раз, если за отведённое время не пришёл ни один результат |
Метод возвращает Promise с функцией отписки. Вызовите её, когда компонент окончательно размонтирован.
Идентификатор размещения появляется после первого сохранения. До сохранения в кабинете показан пример без конкретного идентификатора.
Если подписка создана уже после ответа, обработчик сразу получает последний актуальный результат этого размещения.
Состояния
handler получает один объект.
status | Когда приходит | Что делать |
|---|---|---|
available | Есть товары для этого размещения | Нарисовать карточки из items |
cleared | Страница больше не подходит, сменился маршрут SPA или размещение исчезло из ответа | Удалить прежний блок. items будет пустым |
При переходах внутри SPA событие приходит повторно. Не копируйте карточки поверх старых: заменяйте содержимое контейнера целиком.
Пример ответа
Ниже — типичный объект available с тремя товарами. Служебные идентификаторы нужны, если вы связываете карточку с аналитикой сайта. Для витрины достаточно названия, ссылки, изображения и цены.
json
{
"status": "available",
"placementId": "11111111-1111-1111-1111-111111111111",
"intentKey": "pdp-complement",
"maxItems": 8,
"deliveryMode": "dataOnly",
"presentation": {
"id": "22222222-2222-2222-2222-222222222222",
"schemaVersion": 1,
"settings": {
"layout": "carousel",
"cardOrientation": "vertical",
"columns": 4,
"gap": 16,
"cardStyle": "outlined",
"showImage": true,
"showBrand": true,
"showPrice": true,
"showOldPrice": true,
"showDiscountBadge": true,
"showHeading": true,
"showCta": true,
"heading": null,
"ctaText": null,
"cardBackground": "#ffffff",
"headingColor": "#0f172a",
"textColor": "#0f172a",
"mutedColor": "#64748b",
"accentColor": "#2563eb"
}
},
"items": [
{
"projectProductId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"feedItemId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"sourceRecommendationId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"dynamicModelVersionId": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"externalItemId": "SKU-FLOW-42",
"title": "Кроссовки беговые Flow Runner",
"brand": "Northwind",
"linkUrl": "https://shop.example/catalog/flow-runner",
"imageUrl": "https://cdn.example/images/flow-runner.jpg",
"priceAmount": 7490,
"oldPriceAmount": 9990,
"priceCurrency": "RUB",
"availability": "inStock",
"score": 0.91
},
{
"projectProductId": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
"feedItemId": "ffffffff-ffff-ffff-ffff-ffffffffffff",
"sourceRecommendationId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"dynamicModelVersionId": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"externalItemId": "SKU-CUP-450",
"title": "Термокружка 450 мл",
"brand": "Steamline",
"linkUrl": "https://shop.example/catalog/thermo-cup",
"imageUrl": "https://cdn.example/images/thermo-cup.jpg",
"priceAmount": 1290,
"oldPriceAmount": null,
"priceCurrency": "RUB",
"availability": "inStock",
"score": 0.74
},
{
"projectProductId": "99999999-9999-9999-9999-999999999999",
"feedItemId": "88888888-8888-8888-8888-888888888888",
"sourceRecommendationId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"dynamicModelVersionId": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"externalItemId": "SKU-BAG-15",
"title": "Рюкзак городской водонепроницаемый",
"brand": "Trailhead",
"linkUrl": "https://shop.example/catalog/city-pack",
"imageUrl": "https://cdn.example/images/city-pack.jpg",
"priceAmount": 4590,
"oldPriceAmount": 5290,
"priceCurrency": "RUB",
"availability": "inStock",
"score": 0.68
}
]
}presentation может быть null, если у размещения не выбрано отображение. heading и ctaText в настройках отображения обычно пустые: тексты задаются в размещении и в этот объект не копируются автоматически. Если сайт хочет повторить кабинетный дизайн, читайте presentation.settings; Sales Ninja их не применяет.
Поля товара
| Поле | Тип | Смысл |
|---|---|---|
title | строка или null | Название |
brand | строка или null | Бренд |
linkUrl | строка или null | Ссылка на карточку товара |
imageUrl | строка или null | Изображение |
priceAmount | число или null | Цена |
oldPriceAmount | число или null | Старая цена, если есть скидка |
priceCurrency | строка или null | Валюта, например RUB |
availability | строка или null | Нормализованное наличие, например inStock |
externalItemId | строка или null | Идентификатор товара в каталоге магазина |
projectProductId | строка или null | Идентификатор товара в Sales Ninja |
feedItemId | строка или null | Идентификатор позиции фида |
sourceRecommendationId | строка или null | Какая рекомендация отдала товар |
dynamicModelVersionId | строка или null | Рабочая версия модели |
score | число или null | Служебная оценка ранжирования |
Полей может быть меньше, если в фиде нет бренда, старой цены или изображения. Не опирайтесь на то, что каждое поле всегда заполнено.
Рабочий пример карточек
js
const root = document.querySelector('#recommendations')
const unsubscribe = await ninja('onProductRecommendations', {
placementId: '11111111-1111-1111-1111-111111111111',
timeout: 5000,
onTimeoutExceeded: () => {
root?.replaceChildren()
},
handler: ({ status, items }) => {
if (!root) return
if (status === 'cleared') {
root.replaceChildren()
return
}
root.replaceChildren(
...items.map((item) => {
const card = document.createElement('a')
card.href = item.linkUrl || '#'
card.className = 'rec-card'
if (item.imageUrl) {
const image = document.createElement('img')
image.src = item.imageUrl
image.alt = item.title || ''
card.append(image)
}
const title = document.createElement('span')
title.textContent = item.title || ''
card.append(title)
if (item.brand) {
const brand = document.createElement('span')
brand.textContent = item.brand
card.append(brand)
}
if (item.priceAmount != null) {
const price = document.createElement('span')
const currency = item.priceCurrency || ''
price.textContent = item.oldPriceAmount
? `${item.priceAmount} ${currency} (было ${item.oldPriceAmount})`
: `${item.priceAmount} ${currency}`
card.append(price)
}
return card
})
)
},
})Подставьте свой placementId и контейнер. Стили карточек задаёт сайт.
Таймаут
Если витрина должна скрыть запасной блок, когда рекомендации не пришли, задайте timeout и onTimeoutExceeded. Таймаут относится к вашей подписке и не доказывает, что размещение сломано: страница могла не подойти под условия или фильтр исключил все товары.
js
await ninja('onProductRecommendations', {
placementId: '11111111-1111-1111-1111-111111111111',
handler: (payload) => { /* ... */ },
timeout: 2000,
onTimeoutExceeded: () => {
document.querySelector('#recommendations-fallback')?.removeAttribute('hidden')
},
})Показы и клики
В режиме готового блока Sales Ninja сам записывает показы видимых карточек и клики по ним.
В режиме JavaScript этого нет. Публичного метода «отправить показ» или «отправить клик» у onProductRecommendations нет. Если нужна аналитика сайта, считайте её в своём обработчике. Цели и товарные взаимодействия по-прежнему должны уходить в Sales Ninja обычным способом.
Ошибки
Аргументы проверяются до подписки. Неверные типы дают Invalid onProductRecommendations arguments. Вызов до init даёт Run init before onProductRecommendations. Ошибки внутри handler не обрывают остальные подписки: они пишутся в консоль.