Web-интеграция Shopping Assistant

Встройте готовый чат в сайт через Web-кампанию Gravity Field. Покупатель открывает чат, отправляет вопрос и видит товары в шаблоне вашего магазина. Виджет отвечает за переписку, код кампании — за точку входа и вставку товарных карточек.

1. Подготовьте секцию и шаблон

До начала нужны:

  • установленный Web-скрипт и актуальный контекст страницы;
  • подключённый Shopping Assistant в той же секции GF.section с разрешёнными запросами из браузера покупателя;
  • каталог и успешная проверка подбора в «Превью»;
  • ID товарного шаблона, который принимает набор SKU и отображает карточки со ссылками и трекингом.

На странице должны быть доступны GF.userData.uid и GF.pageContext. uid — идентификатор пользователя Gravity Field; он передаётся как resourceId. Не оставляйте значение виджета по умолчанию "api request": иначе разные покупатели будут использовать один идентификатор.

Виджет 1.42 читает секцию и контекст через window.SL, а события отправляет через SL.API. Если сайт подключён через GF, перед созданием виджета добавьте совместимость: window.SL = window.GF. Это должна быть ссылка на тот же объект, а не копия его полей: тогда виджет увидит обновлённый контекст на SPA-переходах. Если SL уже существует и отличается от GF, сначала устраните конфликт интеграций. В примере ниже эта проверка включена.

2. Создайте Web-кампанию

  1. Создайте Web-кампанию типа «Пользовательский код».
  2. Добавьте сценарий и выберите страницы, аудитории и условия показа. Для первого теста используйте тестовую аудиторию.
  3. Создайте вариацию с ассистентом. Вставьте JavaScript из следующего шага и замените ID товарного шаблона.
  4. Если используете контрольную группу, оставьте её без кода, создающего кнопку или чат.
  5. Активируйте нужный сценарий и кампанию, затем опубликуйте изменения.

Публикация и таргетинг определяют, где запускается код. Один запуск вне целевой страницы не подтверждает, что кампания будет показана всем покупателям.

3. Подключите виджет и товары

Пример использует JS/CSS версии 1.42, собственную кнопку и один экземпляр чата на вкладку. В нём включены гостевой токен, продолжение разговора после перезагрузки и список диалогов. По умолчанию эти возможности выключены. Это рабочий каркас для вариации; ID шаблона нужно заменить на значение вашей секции. Оформление кнопки и чата настраивается CSS вариации.

Гостевой режим создаёт отдельную сессию с тем же resourceId. История прежнего подключения без токена в неё не переносится. Виджет сам получает и сохраняет токен; вручную добавлять его в код кампании не нужно. Правила доступа: Гостевые токены.

(async () => {
  const state = window.gfShoppingAssistantState ??= {};
  const run = state.run = (state.run ?? 0) + 1;
  state.unmount?.();
  state.spaToken ??= GF.PubSub.on('spa', () => {
    state.run += 1;
    state.unmount?.();
  });

  function loadAssets() {
    return state.assets ??= new Promise((resolve, reject) => {
      const css = document.createElement('link');
      css.rel = 'stylesheet';
      css.href = 'https://cdn-01.gravityfield.ai/shopping-assistant/shopping-assistant.1.42.min.css';
      const js = document.createElement('script');
      js.src = 'https://cdn-01.gravityfield.ai/shopping-assistant/shopping-assistant.1.42.min.js';
      let ready = 0;
      const loaded = () => { if (++ready === 2) resolve(); };
      css.onload = js.onload = loaded;
      css.onerror = js.onerror = () => {
        css.remove(); js.remove();
        state.assets = null;
        reject(new Error('Не удалось загрузить виджет'));
      };
      document.head.append(css, js);
    });
  }

  try {
    await Promise.all([loadAssets(), GF.waitForVariable('GF.userData.uid')]);
    if (state.run !== run) return;
    if (!GF.section || !GF.pageContext || !GF.userData.uid) {
      throw new Error('Нет секции, контекста или идентификатора пользователя');
    }
    if (window.SL && window.SL !== window.GF) {
      throw new Error('SL и GF ссылаются на разные объекты интеграции');
    }
    window.SL = window.GF;

    const assistant = state.assistant ??= new ShoppingAssistant({
      autoInsertTrigger: false,
      resourceId: GF.userData.uid,
      auth: { mode: 'guest' },
      resumeDialog: true,
      resumeDialogTtlHours: 24,
      dialogHistory: true,
      startScreenTitle: 'Помощник магазина',
      startScreenDescription: 'Помогу выбрать подходящий товар.',
      placeholder: 'Задайте вопрос',
      quickQuestions: [{ title: 'Помогите выбрать подарок' }],
      focusInputOnOpen: false
    });
    assistant.attachTo(document.body);

    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'gf-assistant-entry';
    button.textContent = 'Помощник магазина';
    const open = () => {
      // После смены пользователя начинаем отдельный разговор.
      if (assistant.config.resourceId !== GF.userData.uid) {
        assistant.startNewChat();
        assistant.config.resourceId = GF.userData.uid;
      }
      assistant.open();
    };
    button.addEventListener('click', open);

    const onProducts = ({ detail }) => {
      const { skus, uniqueId } = detail;
      if (!skus.length) return;
      // Событие приходит до вставки контейнера: ждём завершения текущего вызова.
      queueMicrotask(() => {
        if (state.run !== run || !detail.element.isConnected) return;
        const order = new Map(skus.map((sku, index) => [String(sku), index]));
        GF.Templates.insertRecs('YOUR_PRODUCT_TEMPLATE_ID', {
          selector: '#' + uniqueId,
          identifier: 'gf-chat-' + uniqueId,
          insertMethod: 3,
          maxProducts: Math.min(50, skus.length),
          title: detail.title ?? '',
          realtimeRules: [{
            id: 1, type: 'include', slots: [],
            query: { conditions: [{
              field: 'sku',
              arguments: skus.map(sku => ({ action: 'equal', value: sku }))
            }] }
          }],
          prerenderSlotsHandler: slots => {
            const seen = new Set();
            return slots.filter(slot => {
              const sku = String(slot.item.sku);
              if (!order.has(sku) || seen.has(sku)) return false;
              seen.add(sku);
              return true;
            }).sort((a, b) => order.get(String(a.item.sku)) - order.get(String(b.item.sku)));
          },
          campaignObj: {
            var: '${slVariationId}', ver: '${slVersionId}',
            exp: '${slExperienceId}', cam: '${slTagId}'
          }
        });
      });
    };
    document.addEventListener('shopping-assistant-recs', onProducts);
    document.body.append(button);

    state.unmount = () => {
      button.removeEventListener('click', open);
      button.remove();
      document.removeEventListener('shopping-assistant-recs', onProducts);
      assistant.close();
      state.unmount = null;
    };
  } catch (error) {
    console.error('Shopping Assistant:', error.message);
  }
})();

YOUR_PRODUCT_TEMPLATE_ID — ID товарного шаблона, а не ID кампании или стратегии. Имена ${slVariationId}, ${slVersionId}, ${slExperienceId} и ${slTagId} — служебные подстановки Web-кампании; сохраняйте их без переименования. Код кампании обращается к JS SDK через GF; совместимость с SL нужна самому виджету 1.42.

Проверьте, что шаблон не добавляет fallback-товары и дубли. При отсутствии части SKU показывайте только доступные позиции из ответа. maxProducts ограничивает число отображаемых товаров; пример рассчитан на подборки до 50 SKU. Сопоставление SKU должно учитывать правила регистра вашего фида.

4. Настройте интерфейс

На mobile можно прикрепить чат к document.body, а на desktop — к контейнеру вашего окна. Если используется оверлей, создайте его через GF.Overlay, передайте его контейнер в attachTo, а при нажатии откройте сначала оверлей, затем ассистента.

Для встроенной кнопки задайте autoInsertTrigger: true, вызовите attachTo и уберите собственную кнопку. Параметры и методы: Web-виджет.

Тексты, логотип и стартовые вопросы можно связать с переменными вариации. Стартовые вопросы — объекты { title, sub? }; они отличаются от строк response.ui.suggests, которые приходят после ответа. Для уже созданного экземпляра заголовок, описание и стартовые вопросы обновляйте через updateStartScreen(params); простое изменение assistant.config не перерисует стартовый экран.

Виджет учитывает messageFormat из ответа API: поддерживает ограниченный Markdown и копирование без разметки. Кнопки follow_url открывают HTTP(S)-ссылки; переход можно перехватить событием shopping-assistant-follow-url. Настройка и ограничения: формат ответов и кнопки-ссылки.

5. Проверьте SPA и события

Пример повторно использует чат, а на событии spa снимает внешние обработчики и кнопку. При следующем запуске целевой вариации они создаются заново. Если пользователь попал в контрольную группу или ушёл с целевой страницы, прежняя кнопка не остаётся видимой.

Не создавайте новый экземпляр на каждом SPA-переходе: destroy() версии 1.42 удаляет контейнер, но не снимает все внутренние обработчики. При смене пользователя пример очищает переписку через startNewChat() и обновляет resourceId. Если приложение меняет пользователя при открытом чате, выполните эту смену до следующей отправки сообщения.

После полной перезагрузки resumeDialog восстанавливает последние сообщения через history API при первом открытии чата. Обработчик shopping-assistant-recs должен быть установлен до open(): он нужен и для товарных блоков из истории. Оценка ответа в 1.42 сохраняет текущий threadId; новый разговор начинает кнопка «Новый чат».

Виджет отправляет события переписки через SL.API — в примере это тот же API, что GF.API. Товарные показы и клики должен учитывать товарный шаблон; не дублируйте их ручной отправкой. Разделение ответственности и ограничения передачи threadId описаны в трекинге.

Пройдите чеклист запуска, включая контрольную группу, мобильный экран, повторный переход и ошибку загрузки ресурсов.