Наличие товара и остатки
ShopStory работает с двумя разными состояниями товара, и их важно не смешивать.
Каталог — какие товары показаны в эфире, с какими названием, изображением и ценой. Источник каталога всегда товарный фид.
Наличие — можно ли купить товар прямо сейчас.
Подключений два, и они сильно разной сложности. Определите своё по двум проверкам ниже.
Какой вариант ваш
| Вариант A | Вариант B | |
|---|---|---|
| Цена товара | одна для всех покупателей | зависит от региона, зоны или канала |
| Версий выгрузки каталога | одна | несколько |
| Что показывает наличие | признак available из фида | остаток магазина покупателя на момент эфира |
| Работа ритейлера | отдать фид | отдать фид и добавить одну функцию на сайт |
| Новый код на стороне ShopStory | не нужен | не нужен: обработчик входит в опубликованный контракт SDK |
Проверок две, и они независимы. Достаточно одной сработавшей, чтобы ваш вариант был B.
- Цены. Если каталог выгружается в нескольких версиях по регионам или ценовым зонам — это вариант B. Плеер показывает цену подключённой версии всем зрителям, поэтому версию нужно выбирать осознанно: Несколько версий фида.
- Наличие. Откройте выгрузку и поищите
available="false". Если в вашем фиде нет ни одногоavailable="false", признак наличия в нём отсутствует — так устроены рекламные выгрузки вроде Yandex smart feed и Google Merchant: отсутствующий товар просто не попадает в файл. Тогда вариант A вам не подойдёт независимо от цен, и увеличение частоты синхронизации ничего не изменит.
Вариант A. Один фид и единые цены
Ничего дополнительно подключать не нужно.
ShopStory синхронизирует фид по согласованному расписанию и берёт из него название, изображение, цену и признак available. Товар с available="false" остаётся в эфире, но действие покупки заблокировано. Проверку в момент покупки выполняет ваша корзина: Добавление в корзину.
Этого достаточно, когда наличие меняется медленнее, чем синхронизируется фид, и не зависит от того, где находится покупатель. Ограничение варианта одно и его важно принять сознательно: между синхронизациями карточка показывает снимок, а не текущий остаток.
Если сработала только проверка про цены, вам нужна из варианта B лишь его первая часть — выбор версии выгрузки. Обработчик наличия при этом не требуется.
Вариант B. Разные цены и живые остатки
Вариант состоит из двух независимых частей, и подключать их можно по отдельности.
Цена и состав каталога приходят из той версии выгрузки, которая подключена к проекту. Обработчик наличия их не меняет. Выбор версии и данные для него — в разделе Несколько версий фида.
Наличие приходит от обработчика наличия — функции на стороне сайта ритейлера, которая обращается к его собственному stock API с выбранным магазином покупателя. Это штатная возможность Web SDK: отдельная разработка на стороне ShopStory для неё не нужна.
Что делает каждая сторона
| Кто | Что делает |
|---|---|
| Сайт ритейлера | Определяет магазин покупателя своими существующими средствами |
| ShopStory | Передаёт список идентификаторов товаров эфира в обработчик |
| Сайт ритейлера | Обращается к своему stock API и возвращает карту «идентификатор → доступен» |
| ShopStory | Применяет карту к кнопкам покупки |
| Сайт ритейлера | Сообщает о смене магазина вызовом refreshProductsAvailability() |
Код обработчика живёт на сайте ритейлера, рядом с инициализацией плеера. Изменений в товарном фиде, новых полей в каталоге и серверной интеграции с ShopStory он не требует.
Требование к stock API одно: он должен отвечать по всем товарам эфира внутри дедлайна обработчика. Если API отвечает по одному товару за запрос, посчитайте: число товаров в эфире, делённое на допустимую параллельность, умноженное на время ответа. Не укладывается — понадобится пакетный метод или обёртка над существующим.
Чего интеграция не требует:
- открывать stock API во внешнюю сеть — запрос идёт из браузера покупателя к тому же origin, что и остальные запросы сайта;
- передавать ShopStory учётные данные, ключи или адреса внутренних сервисов;
- отдавать ShopStory остатки, справочник магазинов или привязку покупателей к ним;
- менять товарный фид, добавлять в него поля или увеличивать частоту выгрузки;
- поддерживать отдельный контракт для записей: live и VOD работают одинаково.
Как устроен процесс
Ключевое свойство схемы: запрос остатка не покидает периметр ритейлера. Он выполняется кодом его сайта, с его пользовательской сессией, к его API. ShopStory получает только результат — доступен товар или нет.
resolveProductsAvailability, сигнал обновления — это refreshProductsAvailability(). SDK не обращается к API ритейлера напрямую и не хранит его остатки.Кто определяет магазин покупателя
ShopStory не определяет город, регион и магазин покупателя, не запрашивает геолокацию и не хранит выбор магазина.
Магазин определяет сайт ритейлера — теми же средствами, которыми он уже пользуется на собственных карточках товара: выбор города в шапке, cookie, профиль покупателя, определение по IP. Плеер встроен в эту же страницу и попадает в тот же контекст.
Обработчик наличия — это функция в коде сайта ритейлера. Она читает то же состояние, что и остальные его страницы, и сама подставляет магазин в свой запрос. Контекст покупателя не является аргументом обработчика, в ответе не возвращается и на серверы ShopStory не уходит.
stockIdВ конфигурации SDK есть необязательное поле config.stockId. Это другой, более ранний механизм: постоянный для проекта идентификатор склада или магазина, по которому SDK читает региональное наличие из поля options товарного фида. Он задаётся один раз при инициализации, не меняется при смене города покупателем и к обработчику наличия отношения не имеет.
Если для проекта заданы оба механизма, доступность считается по логическому И: товар покупаем, когда его открыл фид, и не закрыл региональный options, и обработчик не вернул false. Ни один из источников не возвращает в продажу то, что закрыл другой.
| Что определяется | Кто определяет |
|---|---|
| Город, регион и магазин покупателя | Сайт ритейлера, его существующими средствами |
| Список товаров текущего эфира | ShopStory |
| Запрос к stock API и подстановка магазина | Код сайта ритейлера, с его пользовательской сессией |
| Сопоставление ответа с товарами эфира | ShopStory, по feedProductId |
| Состояние кнопки покупки | ShopStory, по ответу обработчика |
Сопоставление на стороне ShopStory только товарное: ключи карты ответа сверяются с feedProductId из фида. Это тот же идентификатор, который уже используется в действии корзины, поэтому отдельного сопоставления покупателей, магазинов или сессий не возникает.
Там, где размещён плеер, магазин уже должен быть определён сайтом. Если на этой странице выбор магазина ещё не сделан, обработчику нечего подставить: наличие остаётся по снимку фида, покупка не блокируется.
Контракт обработчика
Обработчик передаётся вместе с действиями корзины в вызов ShopStorySDK.show().
type ProductAvailabilityMap = Record<string, boolean>;
type ClientActions = {
resolveProductsAvailability?: (
feedProductIds: string[],
callback: (availability: ProductAvailabilityMap) => void,
) => void;
// Обработчики корзины передаются в тот же объект actions
// и описаны отдельно: ./add-to-cart.md#web-sdk-callback
};
feedProductIds — массив строковых идентификаторов в том же формате, что и в фиде. Обрабатывайте ровно тот список, который пришёл в аргументе, и не додумывайте его состав.
Обычно это все товары текущего эфира. Но когда товары добавляются в уже идущий эфир, SDK передаёт только добавленные идентификаторы, чтобы не перезапрашивать то, что уже известно. Полный перечень поводов для вызова — в разделе Когда SDK запрашивает наличие.
Чего не бывает никогда: вызова на один товар при показе карточки. Обработчик всегда получает список.
Контекст покупателя в аргументы не входит — см. Кто определяет магазин покупателя.
Карта ответа
Верните объект, ключи которого — идентификаторы из запроса:
| Значение по ключу | Что делает SDK |
|---|---|
true | Действие покупки доступно |
false | Карточка переходит в состояние «Нет в наличии», кнопка покупки заблокирована, callback корзины не вызывается |
| Ключ отсутствует в ответе | Наличие неизвестно; сохраняется состояние по снимку фида |
Возвращайте ключ только для тех товаров, по которым получен достоверный ответ. Не подставляйте false вместо неизвестного результата: ошибка API — это не подтверждённое отсутствие товара.
Обратное тоже важно. Отсутствие ключа означает «не удалось узнать», а не «товар покупателю не положен». Если вы знаете, что товар недоступен — не входит в ассортимент его региона, снят с продажи, недоступен для выбранного способа получения, — возвращайте false. Пропущенный товар останется доступным к покупке по снимку фида.
Ключи, которых не было в запросе, игнорируются. Повторный вызов callback игнорируется.
Пример
// Пример для stock API, который отвечает по одному товару за запрос.
// Если у вашего API есть пакетный метод, замените checkOne и очередь
// одним запросом со всем списком — остальная часть обработчика не изменится.
const STOCK_CONCURRENCY = 4;
const STOCK_DEADLINE_MS = 4000;
function checkOne(feedProductId, storeId, signal) {
return fetch(
'/api/stock/' + encodeURIComponent(feedProductId) + '?store=' + encodeURIComponent(storeId),
{ credentials: 'same-origin', signal },
)
.then(function (response) {
if (!response.ok) return null;
return response.json();
})
.then(function (data) {
// null => «не смогли узнать»: ключ в карту не попадёт
return data && typeof data.inStock === 'boolean' ? data.inStock : null;
})
.catch(function () {
return null;
});
}
ShopStorySDK.show({
containerElement: document.getElementById('shopstory'),
config: { clientId: 'assigned-application-id' },
cartUrl: '/cart/',
actions: {
resolveProductsAvailability: function (feedProductIds, callback) {
const storeId = readSelectedStoreId();
const controller = new AbortController();
const deadline = setTimeout(function () { controller.abort(); }, STOCK_DEADLINE_MS);
const availability = {};
const queue = feedProductIds.slice();
function worker() {
const id = queue.shift();
if (id === undefined) return Promise.resolve();
return checkOne(id, storeId, controller.signal).then(function (inStock) {
if (inStock !== null) availability[String(id)] = inStock;
return worker();
});
}
const workers = [];
for (let i = 0; i < STOCK_CONCURRENCY; i += 1) workers.push(worker());
Promise.all(workers)
.catch(function () { /* частичный результат сохраняем */ })
.then(function () {
clearTimeout(deadline);
callback(availability);
});
},
},
});
Обратите внимание: обработчик вызывает callback один раз, отдавая то, что успел собрать к дедлайну. Товары, по которым ответа не пришло, в карту не попадают и остаются в состоянии по фиду. Поэтому дедлайн внутри обработчика должен быть меньше тайм-аута SDK — иначе весь ответ потеряется целиком.
Если ваш stock API отвечает по одному товару, оцените заранее: число товаров в эфире, делённое на допустимую параллельность, умноженное на время ответа, должно укладываться в этот дедлайн. Не укладывается — нужен пакетный метод или backend-for-frontend, который его изобразит.
AbortController и setTimeout использованы вместо AbortSignal.timeout() намеренно: последний недоступен в Safari до 16.4, а исключение внутри обработчика SDK не отличит от отказа API.
В этом примере показан только обработчик наличия. Обработчик корзины передаётся в тот же объект actions.
callback({}) — корректный ответ «данных нет». Он не ломает плеер и не помечает товары отсутствующими.
Адаптируйте пример к своей платформе: имена полей, защиту сессии и способ получения выбранного магазина. Пример рассчитан на same-origin endpoint ритейлера. Если stock API находится на другом origin или требует серверной авторизации, вызывайте его через backend-for-frontend ритейлера: учётные данные не должны попадать в JavaScript или в конфигурацию ShopStory.
Смена магазина
Когда покупатель меняет магазин, регион или способ получения, сайт сообщает об этом SDK:
function onStoreChanged() {
if (window.ShopStorySDK && window.ShopStorySDK.refreshProductsAvailability) {
window.ShopStorySDK.refreshProductsAvailability();
}
}
Метод делает три вещи: снимает уже применённые ответы, отменяет незавершённые запросы и вызывает обработчик заново. Ответы прежнего магазина на карточку попасть не могут — ни те, что были показаны, ни те, что придут с опозданием.
До прихода новых данных карточки показывают состояние по снимку фида.
Метод ничего не возвращает и безопасен для повторных вызовов — быстрое переключение города подряд не создаёт гонки. Если обработчик не задан, вызов не делает ничего. Обновляйте выбранный магазин на своей стороне до вызова метода, а не после.
Метод относится к плееру, созданному вызовом show(). Контракт рассчитан на один такой экземпляр на странице; сценарий с двумя одновременными плеерами на одной странице не поддержан.
Когда SDK запрашивает наличие
| Момент | Поведение |
|---|---|
| Открытие эфира или записи | Один вызов со списком товаров этого эфира |
| Добавление товаров в идущий эфир | Один вызов со списком добавленных идентификаторов |
refreshProductsAvailability() | Один вызов со списком товаров текущего эфира |
Других поводов нет. Обработчик не вызывается при показе или открытии отдельной карточки, не опрашивается по расписанию и не работает при закрытом плеере. Единственный внутренний таймер — тайм-аут запроса.
Полученный ответ живёт до смены магазина, смены эфира или закрытия плеера. Срока давности у него нет: ответ не «протухает» сам по себе, потому что состояние, придуманное таймером, было бы хуже честного ответа мерчанта. Если наличие должно обновляться чаще, вызывайте refreshProductsAvailability() из своего кода.
Тайм-аут обработчика — 5 секунд, значение фиксировано. Он ограничивает ожидание одного вызова, не частоту вызовов.
Приоритет источников
Обработчик может только снять доступность. Товар, закрытый фидом, остаётся недоступным независимо от ответа обработчика; товар, открытый фидом, обработчик может закрыть.
| Снимок фида | Ответ обработчика | Действие покупки |
|---|---|---|
| Доступен | true | Доступно |
| Доступен | false | Заблокировано, «Нет в наличии» |
| Доступен | ключ отсутствует | Доступно, по снимку фида |
| Недоступен | любой | Заблокировано |
Правило одностороннее намеренно. Оно исключает случай, когда live-ответ возвращает в продажу позицию, снятую с ассортимента или ограниченную к обороту.
Если available в вашей выгрузке уже привязан к одному складу или городу, обработчик не сможет открыть товар покупателю из другого места: фид закрыл позицию, и правило это не отменяет.
Разделение ответственности здесь жёсткое: фид передаёт ассортиментную доступность, а привязку к магазину покупателя — обработчик. Если ваша выгрузка смешивает их в одном поле, маппинг фида корректируется при подключении. Проверьте это до оценки работ.
Каталог тоже остаётся за фидом: обработчик не может добавить товар в эфир, изменить его цену, название или изображение. Он влияет только на состояние действия покупки.
Это важно для каталогов с ценовыми зонами: если у вас несколько версий выгрузки, плеер показывает цену и состав той версии, которая подключена к проекту. Обработчик наличия этого не меняет — см. Несколько версий фида.
Ошибки и неизвестное наличие
Проверка наличия не должна ломать просмотр. Поведение рассчитано на то, что stock API ритейлера временно недоступен.
| Что произошло | Поведение SDK |
|---|---|
| Обработчик не задан | Наличие берётся из фида; ничего не вызывается |
| Обработчик выбросил синхронное исключение | Перехвачено, состояние карточек не меняется, видео продолжается |
Обработчик вернул отклонённый промис и не вызвал callback | Перехватить это SDK не может: сработает тайм-аут. Обрабатывайте отказы внутри обработчика и всегда вызывайте callback |
callback не вызван за тайм-аут | Ответ считается отсутствующим; карточки остаются в состоянии по фиду |
| Возвращена пустая карта | Состояние по фиду сохраняется |
| Часть ключей отсутствует | Применяются только полученные значения |
callback вызван повторно | Учитывается первый вызов, остальные игнорируются |
| Ответ пришёл после тайм-аута | Отбрасывается, даже если он корректный |
Ответ пришёл после refreshProductsAvailability() | Отбрасывается целиком |
| Ответ пришёл после смены эфира или закрытия плеера | Отбрасывается |
Обновление наличия не отменяет уже запущенное добавление в корзину. Начатая операция доводится до своего результата — успеха, ошибки или тайм-аута, — даже если в это время карточка сменила состояние доступности.
Карточки отрисовываются сразу по данным фида и обновляются при получении ответа. Первый показ плеера и старт видео не ждут stock API.
Между показом карточки и оформлением заказа товар может закончиться. Обработчик наличия повышает точность интерфейса, но окончательное решение принимает система ритейлера при изменении корзины и оформлении заказа: Добавление в корзину.
Что ShopStory не делает
Эти границы важны для оценки нагрузки и информационной безопасности:
- не обращается к stock API ритейлера напрямую и не хранит его учётные данные;
- не запрашивает и не хранит остатки магазинов и товаров за пределами текущего эфира;
- не записывает пользовательский остаток в общее поле
feedProductAvailable: ответ обработчика живёт в памяти открытой вкладки, относится только к этому покупателю, не уходит на серверы ShopStory и не сохраняется; - не передаёт в обработчик персональные данные покупателя и не получает их в ответе — ответ это карта «идентификатор → доступность», и ничего больше;
- не требует открывать stock API во внешнюю сеть и не требует сетевого доступа со стороны ShopStory;
- не опрашивает API по таймеру и не создаёт фоновый трафик при закрытом плеере.
Верхняя граница нагрузки на stock API считается просто: один вызов обработчика на открытие эфира, один на смену магазина и один на добавление товаров в идущий эфир. Размер вызова — число товаров, по которым запрашивается наличие. Вызовов при показе отдельной карточки нет.
Поверхности
| Поверхность | Live-проверка наличия |
|---|---|
Web SDK, каталог и плеер через show() | Обработчик в actions, метод refreshProductsAvailability() |
| Public API без SDK | Интерфейс реализует клиент; наличие проверяет его собственный код |
| Product mini-player, standalone PiP bundle | Обработчик задаётся при инициализации bundle; объект actions из show() в него не наследуется |
| Mobile SDK | Приложение возвращает наличие по контракту поставленной версии пакета: Mobile SDK |
Обработчик не переносится между поверхностями: заданный в show() на одной странице, он не действует в плеере, открытом из standalone PiP, и в нативном приложении. Каждая поверхность получает его в своей точке инициализации, которая фиксируется в integration handoff проекта.
Public API без SDK
GET /v3/streams возвращает снимок фида в body.products[].feedProductAvailable. Значение содержит булев признак и не передаёт количество единиц, остаток магазина или время обновления товара; serverTime — время ответа API, а не время синхронизации.
Отдельного endpoint проверки остатков в Public API нет: остаток по магазину — данные ритейлера, и правильное место их запроса — его собственный код. При интеграции через Public API проверку наличия выполняет интерфейс клиента до показа действия покупки.
Данные для подключения
Обработчик — штатная возможность SDK, но стыковка с конкретным stock API требует согласования:
| Что нужно | Зачем |
|---|---|
| Соответствие идентификаторов | feedProductId должен однозначно определять позицию, которую принимает stock API: товар, вариант или SKU |
| Пакетный метод API | Проверка списка товаров одним запросом; при его отсутствии ограничение параллельных запросов остаётся в адаптере сайта |
| Источник выбранного магазина | Откуда код сайта берёт текущий магазин, регион или способ получения |
| Значение результата | Какое поле ответа означает возможность купить; что означают ноль, отрицательное значение и отсутствие позиции в ответе |
Семантика available в фиде | Что означает false в вашей выгрузке; от этого зависят правила импорта, а не поведение обработчика |
| Лимиты и время ответа | Для настройки тайм-аута и срока актуальности |
| Тестовые данные | Товары с наличием и без него минимум в двух магазинах |
Точные URL, схемы запросов, учётные данные, лимиты и правила магазинов не публикуются: они фиксируются в закрытом приложении к интеграции. Порядок согласования — в разделе Кастомные commerce-интеграции.
Проверка перед production
- Обработчик получает те же строковые идентификаторы, что переданы в фиде, без числового преобразования и потери ведущих нулей.
- Товар с наличием показывает доступное действие покупки, товар без наличия — состояние «Нет в наличии».
- Товар, закрытый фидом, остаётся заблокированным, даже когда обработчик вернул
true. - Смена магазина обновляет карточки, а ответ для прежнего магазина на них не попадает.
- Недоступный stock API не блокирует покупку целиком, не показывает ложное «Нет в наличии» и не прерывает видео.
- Тайм-аут обработчика не задерживает первый показ карточек и старт воспроизведения.
- Добавление в корзину во время обновления наличия завершается ожидаемым результатом.
- Endpoint остатков не отдаёт данные без действующей пользовательской сессии и не раскрывает внутренние ошибки.
- Поведение проверено на desktop и mobile web в целевых браузерах проекта.
Диагностика
| Симптом | Что проверить |
|---|---|
| Все товары показаны как недоступные | Возвращает ли обработчик false вместо пустой карты при ошибке API |
| Наличие не обновляется | Задан ли resolveProductsAvailability в том же вызове show(), что и остальные actions |
| Наличие не меняется при смене магазина | Вызывается ли refreshProductsAvailability() в обработчике смены магазина на сайте |
| Показано наличие чужого магазина | Порядок вызова: сначала обновите выбранный магазин на сайте, затем вызовите метод |
| Часть товаров не обновилась | Совпадение ключей ответа с переданными идентификаторами и их строковый тип |
| Товар доступен по API, но кнопка заблокирована | Значение available в фиде: обработчик не возвращает в продажу закрытый фидом товар |
| Карточки обновляются с заметной задержкой | Время ответа stock API и размер пакетного запроса |
При обращении в поддержку передайте URL страницы, applicationId, время, идентификаторы проверяемых товаров и выбранный магазин. Учётные данные, cookies и персональные данные покупателя в обращение не включайте.