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

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

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

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

- установленный [Web-скрипт](/Integration/web_integration/script.md) и актуальный [контекст страницы](/Integration/web_integration/page_context.md);
- подключённый Shopping Assistant в той же секции `GF.section` с разрешёнными запросами из браузера покупателя;
- каталог и успешная проверка подбора в [«Превью»](../setup/preview.md);
- ID [товарного шаблона](/personalization/Campaigns/templates.md), который принимает набор SKU и отображает карточки со ссылками и трекингом.

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

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

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

Публикация и [таргетинг](/personalization/Campaigns/targeting_schedule.md) определяют, где запускается код. Один запуск вне целевой страницы не подтверждает, что кампания будет показана всем покупателям.

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

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

```javascript
(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.41.min.css';
      const js = document.createElement('script');
      js.src = 'https://cdn-01.gravityfield.ai/shopping-assistant/shopping-assistant.1.41.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('Нет секции, контекста или идентификатора пользователя');
    }

    const assistant = state.assistant ??= new ShoppingAssistant({
      autoInsertTrigger: false,
      resourceId: GF.userData.uid,
      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`.

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

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

На mobile можно прикрепить чат к `document.body`, а на desktop — к контейнеру вашего окна. Если используется [оверлей](/Integration/web_integration/client_side_api/other_helpful.md), создайте его через `GF.Overlay`, передайте его контейнер в `attachTo`, а при нажатии откройте сначала оверлей, затем ассистента.

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

Тексты, логотип и стартовые вопросы можно связать с [переменными вариации](/personalization/Campaigns/variables.md). Стартовые вопросы — объекты `{ title, sub? }`; они отличаются от строк `response.ui.suggests`, которые приходят после ответа.

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

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

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

Виджет отправляет события переписки через `GF.API`. Товарные показы и клики должен учитывать товарный шаблон; не дублируйте их ручной отправкой. Разделение ответственности описано в [трекинге](./tracking.md#готовый-web-виджет).

Пройдите [чеклист запуска](../troubleshooting.md#чеклист-запуска), включая контрольную группу, мобильный экран, повторный переход и ошибку загрузки ресурсов.
