# Гайд: Прямая API-интеграция для Flutter

Этот гайд предназначен для Flutter-разработчиков, которым нужен полный контроль над HTTP-вызовами, состоянием, рендерингом и трекингом кампаний Gravity Field без использования готового Flutter SDK UI-слоя.

Под "прямой API-интеграцией" здесь понимается подход, в котором ваше приложение:

- само вызывает `/visit`, `/event`, `/choose` и tracking URL;
- само хранит `uid` и `ses`;
- само рендерит in-app или inline-контент;
- само отправляет engagement-события.

Если вам нужен готовый UI-слой, автоматический показ in-app кампаний и встроенная обработка tracking, используйте [Flutter SDK](./ask_flutter.md). Если вам нужен SDK без автопоказа и с кастомным UI, смотрите [Headless-режим](./flutter_headless_guide.md).

Пользовательская настройка кампаний в кабинете описана в разделе [Кампании в приложении](/personalization/Campaigns/app_campaigns/getting_started.md).

## 1. Быстрый старт

Этот раздел повторяет паттерн других SDK-гайдов: сначала короткая последовательность шагов, по которой можно запустить первую working integration, а подробные объяснения и edge cases разобраны ниже.

### Шаг 1: Настройка API-клиента

Сначала настройте HTTP-клиент с `Authorization`, `sec`, базовым URL и короткими таймаутами.

```dart
final dio = Dio(
  BaseOptions(
    baseUrl: 'https://evs-01.gravityfield.ai/v2',
    connectTimeout: const Duration(milliseconds: 300),
    receiveTimeout: const Duration(milliseconds: 800),
    sendTimeout: const Duration(milliseconds: 300),
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
  ),
);

const sectionId = 'YOUR_SECTION_ID';
```

На этом шаге цель простая: получить рабочий transport-слой, через который вы будете вызывать `/visit`, `/event`, `/choose` и tracking URL.

### Шаг 2: Идентификация пользователя

На этом шаге важно понять, в каком режиме идентификации работает приложение. В direct API есть два слоя:

- `uid` и `ses` - базовый анонимный слой идентификации на уровне текущего устройства и сессии;
- `cuid` и `cuidType` - слой омниканальной идентификации для склейки профиля между web, mobile app и офлайном.

Практически у Flutter-приложения есть три режима работы:

1. **Анонимный режим**
   Приложение ещё не знает постоянного идентификатора клиента. На первом запросе можно не передавать `uid`, сервер сам создаст его и вернёт в `user`. После этого:
   - `uid` нужно сохранить между сессиями;
   - `ses` нужно хранить только в памяти текущего запуска приложения.
2. **Авторизованный режим через `login-v1` / `signup-v1`**
   Это рекомендуемый режим для mobile app. После логина приложение отправляет `login-v1` с `cuid` + `cuidType`. Для основной интеграции рекомендуйте `phone_hash` как `cuidType`.
3. **Режим с внешним ID приложения**
   Если у приложения есть собственный устойчивый user ID, его можно использовать внутри своей системы и, при необходимости, передавать во внешний идентификатор профиля. В v2 direct API для этого используется поле `user.custom`; в общей server-side терминологии ту же роль часто описывают как `user.id` или `extUid`. Но этот ID не заменяет `cuid` как основной механизм омниканальной склейки между каналами.

Рекомендуемая модель по умолчанию:

- пользователь новый и не логинился -> работаем в анонимном режиме;
- пользователь залогинился -> отправляем `login-v1` и включаем омниканальную идентификацию;
- если приложение знает только локальный user ID -> не подменяем им `cuid`, а используем отдельно как внешний ID приложения.

Бизнес-смысл:

- `uid` нужен для непрерывности поведения на одном устройстве;
- `cuid` нужен для объединения поведения одного и того же клиента между web, mobile и офлайном;
- без `login-v1` приложение может корректно работать, но останется только в device-level идентификации.

Техническое правило хранения при этом простое:

- `uid` сохранить в persistent storage;
- `ses` держать только в memory;
- `uid` и `ses` обновлять из объекта `user` после ключевых ответов API.

```dart
Future<void> saveUserFromResponse(
  Map<String, dynamic> response,
  GravitySessionStore store,
) async {
  final user = response['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);
}
```

Минимальный пример перехода из анонимного режима в авторизованный:

```dart
final loginEvent = {
  'type': 'login-v1',
  'name': 'Login',
  'cuid': hashedPhone,
  'cuidType': 'phone_hash',
};
```

Подробно про роль `uid`, `ses`, `cuid`, `cuidType` и внешнего user ID см. ниже в разделе про общие примитивы интеграции.

### Шаг 3: Передача просмотров экранов через `/visit`

Когда пользователь открывает экран, отправьте `screenview` через `/visit` с корректным `PageContext`. См. [OpenAPI `/visit`](https://openapi-v2.gravityfield.ai/store-visit-event-14002043e0).

```dart
await dio.post(
  '/visit',
  data: {
    'sec': sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': {
      'type': 'PRODUCT',
      'data': ['sku-123'],
      'location': 'app://product/sku-123',
    },
    'device': device,
    'type': 'screenview',
  },
);
```

`PageContext` должен совпадать с таргетингом и продуктовым фидом. Если кампания настроена на `PRODUCT`, а приложение отправляет `OTHER`, контент просто не сработает. Подробно про контексты и требования к значениям см. ниже.

### Шаг 4: Передача пользовательских событий через `/event`

Когда пользователь совершает значимое действие, отправьте бизнес-событие через `/event`. См. [OpenAPI `/event`](https://openapi-v2.gravityfield.ai/store-event-14003491e0).

```dart
await dio.post(
  '/event',
  data: {
    'sec': sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': {
      'type': 'OTHER',
      'data': [],
      'location': 'app://login',
    },
    'device': device,
    'data': [
      {
        'type': 'login-v1',
        'name': 'Login',
        'cuid': hashedPhone,
        'cuidType': 'phone_hash',
      },
      {
        'type': 'survey-completed-v1',
        'name': 'Survey Completed',
        'customProps': {
          'surveyId': 'summer-2025-feedback',
        },
      },
    ],
  },
);
```

`login-v1` нужен не только для аналитики, а для омниканальной склейки профилей. Для кастомных событий в v2 API используйте `customProps`, а не `properties`.

### Шаг 5: Получение контента

См. [OpenAPI `/choose`](https://openapi-v2.gravityfield.ai/get-campaign-14039236e0).

На этом шаге выберите один из двух флоу:

1. `in-app`: сначала `/visit` или `/event`, затем взять `campaignId` из ответа и вызвать `/choose(campaignId)`.
2. `inline` / API / A/B: если placement известен заранее, вызвать `/choose(selector)` напрямую.

```dart
// in-app
await dio.post('/choose', data: {
  'sec': sectionId,
  'user': {'uid': uid, 'ses': ses},
  'ctx': ctx,
  'device': device,
  'options': {'isBuildEngagementUrl': true},
  'data': [
    {'campaignId': campaignId}
  ],
});

// inline / API / A/B
await dio.post('/choose', data: {
  'sec': sectionId,
  'user': {'uid': uid, 'ses': ses},
  'ctx': ctx,
  'device': device,
  'options': {'isBuildEngagementUrl': true},
  'data': [
    {'selector': 'homepage-recommendations'}
  ],
});
```

Если вам нужно встроить конкретный placement, selector flow обычно проще. Если кампания должна активироваться действием или screenview, нужен `campaignId` flow. Подробное различие между режимами описано ниже.

После `/choose` следующий обязательный шаг - передать данные в ваш UI-слой. Минимальный путь до рендеримого блока такой:

- `data[0]` - кампания;
- `data[0].payload[0]` - выбранная вариация;
- `data[0].payload[0].contents[0]` - блок контента для рендера;
- `data[0].payload[0].decisionId` - идентификатор решения;
- `contents[0].events` - content-level tracking;
- `contents[0].products.slots` - товары, если в контенте есть recommendation-блок.

Важно: ответ `/choose` тоже может вернуть обновлённый `user`. Сохраняйте `uid` и `ses` из этого ответа так же, как после `/visit` и `/event`, чтобы не потерять консистентность сессии.

### Шаг 6: Ручной engagement и обработчики `on*`

После рендера вы обязаны вручную отправлять engagement:

- `onImpression`
- `onVisibleImpression`
- `element.onClick`
- `slot.events[]` для карточек товаров

```dart
final variables = content['variables'] as Map<String, dynamic>?;
final onImpression = variables?['onImpression'] as Map<String, dynamic>?;
final action = onImpression?['action'] as String?;

if (action == null) return;

final urls = findContentTrackingUrls(
  events: content['events'] as List<dynamic>?,
  action: action,
);

if (urls.isNotEmpty) {
  await triggerTrackingUrls(urls);
}
```

Для product cards используйте отдельный источник tracking - `slot.events[]`. Без этого у кампаний не будет корректной статистики, а результаты A/B тестов и CTR будут искажены. Подробно про `events[]`, `slot.events[]`, `onVisibleImpression` и другие обработчики см. в отдельном разделе ниже.

### Схема первого запуска

```text
screenview -> /visit -> campaigns -> /choose(campaignId) -> render -> engagement
event -> /event -> campaigns -> /choose(campaignId) -> render -> engagement
selector -> /choose(selector) -> render -> engagement
```

После этого быстрого старта переходите к подробным разделам ниже:

- про OpenAPI, MCP и основные источники контракта;
- про различие `campaignId`-flow и `selector`-flow;
- про `uid` / `ses` / `cuid`;
- про engagement, `content.events[]` и `slot.events[]`.

## 2. Источники спецификации и зачем они нужны

При реализации прямой API-интеграции ориентируйтесь сразу на несколько источников:

- [OpenAPI v2](https://openapi-v2.gravityfield.ai/) - официальный wire contract API: какие endpoints доступны, какие поля принимаются и что возвращается в ответе.
- `SDK API` MCP - быстрый способ посмотреть актуальные path/schema details без ручного поиска по Swagger.
- `gravity-sdk-flutter` - reference implementation клиентского поведения: как обрабатываются `priority`, `delayTime`, `onVisibleImpression`, `content.events[]` и `slot.events[]`.

### 2.1. MCP для просмотра API

Если вы используете AI-ассистента или IDE с MCP, можно подключить сервер `SDK API`:

```json
{
  "mcpServers": {
    "SDK API": {
      "command": "npx",
      "args": [
        "-y",
        "apidog-mcp-server@latest",
        "--site-id=748185"
      ]
    }
  }
}
```

Это полезно для быстрой сверки актуальной OpenAPI-спецификации: какие поля реально принимает `/visit`, `/event`, `/choose`, как называются options и какие схемы возвращаются в ответе.

## 3. Два режима работы с кампаниями

В прямой API-интеграции есть два разных сценария, и их важно не смешивать.

| Сценарий | Когда использовать | Какой первый запрос | Как получается контент |
| :--- | :--- | :--- | :--- |
| `in-app` activation flow | Кампания должна сработать на screenview или бизнес-событие | `/visit` или `/event` | `/choose` по `campaignId` |
| `inline` / API / A/B selector flow | Placement известен заранее, приложение само решает, где рендерить контент | `/choose` по `selector` | Контент возвращается сразу |

### 3.1. In-app через активацию

Этот сценарий нужен для modal, full screen, bottom sheet, snackbar и любых кампаний, где сначала должна сработать логика активации:

- по экрану;
- по действию пользователя;
- по сегменту;
- по частотным ограничениям;
- по приоритету кампаний.

Цепочка выглядит так:

1. Приложение отправляет `/visit` или `/event`.
2. API возвращает `user` и список `campaigns`.
3. Клиент выбирает подходящую кампанию, учитывая `priority` и `delayTime`.
4. Приложение вызывает `/choose` по `campaignId`.
5. Приложение рендерит контент и отправляет engagement.

### 3.2. Inline / API / A/B по selector

Этот сценарий нужен, когда placement на экране заранее известен:

- inline-блок рекомендаций;
- встраиваемый banner slot;
- API-кампания типа Custom JSON;
- selector-based A/B тест;
- headless use case, где приложение само решает, как использовать response.

Цепочка выглядит так:

1. Приложение знает `selector`.
2. Приложение вызывает `/choose` по `selector`.
3. API возвращает контент для этого placement-а.
4. Приложение рендерит контент и отправляет engagement.

Важно: selector flow не заменяет activation flow для in-app сценариев. Если кампания должна запускаться по `screenview` или по событию, сначала нужен `/visit` или `/event`.

## 4. Общие примитивы интеграции

### 4.1. `uid`, `ses`, `cuid`, `cuidType`

Для прямой интеграции важно понимать разницу между идентификаторами:

- `uid` - внутренний идентификатор пользователя в Gravity Field. Его нужно сохранять между сессиями.
- `ses` - идентификатор текущей сессии. Его нужно хранить в памяти на время жизни app session.
- `cuid` и `cuidType` - устойчивый идентификатор клиента для омниканальной склейки профилей. Обычно используется в `login-v1` или `signup-v1`.

Бизнес-смысл:

- `uid` и `ses` нужны для стабильного трекинга внутри приложения;
- `cuid` нужен для склейки профиля между web, mobile app, backend и офлайн-данными.

Рекомендуемый сценарий идентификации:

1. До логина приложение работает с анонимным `uid`.
2. После логина приложение отправляет `login-v1`.
3. В событии передается `cuid` и `cuidType`.
4. Gravity Field склеивает анонимный профиль с омниканальным профилем клиента.

Этого достаточно для direct API интеграции: анонимный `uid`/`ses` для device-level continuity и `login-v1` с `cuid` для омниканальной склейки.

### 4.2. `PageContext`

`PageContext` нужен не только для transport-уровня. Это ключевая часть бизнес-логики:

- по нему работает таргетинг кампаний;
- по нему запускаются in-app кампании;
- по нему подбираются рекомендации;
- его значения должны совпадать с данными продуктового фида.

Основные типы контекста:

| `ctx.type` | Когда использовать | Что передавать в `ctx.data` |
| :--- | :--- | :--- |
| `HOMEPAGE` | Главный экран | `[]` |
| `PRODUCT` | Карточка товара | `[sku]` |
| `CATEGORY` | Категория | Иерархию категорий из фида |
| `CART` | Корзина | SKU всех товаров в корзине |
| `SEARCH` | Поиск | Поисковый запрос |
| `OTHER` | Любой другой экран | `[]` или технический маркер |

Практические правила:

- `productId`, SKU, категории и `lng` должны совпадать с продуктовым фидом;
- не меняйте регистр значений, если они берутся из фида;
- `location` должен быть стабилен и уникально описывать экран или placement.

`PageContext` должен быть согласован с тем, как у вас настроены таргетинг и продуктовый фид. В этом гайде дальше используются только актуальные для v2 direct API поля `ctx.type`, `ctx.data` и `ctx.location`.

### 4.3. `options` и `contentSettings`

В v2 API для `/choose` и частично для `/visit`/`/event` используются `options`.

Практически важные поля:

- `isReturnCounter`
- `isReturnUserInfo`
- `isReturnAnalyticsMetadata`
- `isImplicitPageview`
- `isBuildEngagementUrl`

Для прямой API-интеграции обычно важно явно включать:

- `isBuildEngagementUrl: true`, если вы хотите получить tracking URL в `events[].urls[]`;
- `isReturnAnalyticsMetadata: true`, если вам нужны дополнительные аналитические метаданные.

Для campaign request data в `/choose` можно передавать content options, например:

- `skusOnly`
- `fields`

Это полезно для inline recommendations, если вам нужно ограничить набор полей товара.

### 4.4. Пример базового API-клиента

```dart
import 'package:dio/dio.dart';

class GravityApiClient {
  GravityApiClient({
    required this.apiKey,
    required this.sectionId,
  }) : dio = Dio(
          BaseOptions(
            baseUrl: 'https://evs-01.gravityfield.ai/v2',
            connectTimeout: const Duration(milliseconds: 300),
            receiveTimeout: const Duration(milliseconds: 800),
            sendTimeout: const Duration(milliseconds: 300),
            headers: const {
              'Content-Type': 'application/json',
            },
          ),
        ) {
    dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          options.headers['Authorization'] = 'Bearer $apiKey';
          handler.next(options);
        },
      ),
    );
  }

  final Dio dio;
  final String apiKey;
  final String sectionId;
}
```

### 4.5. Хранение `uid` и `ses`

```dart
abstract class GravitySessionStore {
  Future<String?> readUid();
  Future<void> writeUid(String uid);

  String? readSessionId();
  void writeSessionId(String sessionId);
  void clearSessionId();
}
```

Рекомендуемая стратегия:

- `uid` хранить между перезапусками приложения;
- `ses` хранить только в памяти процесса;
- после cold start `ses` начинать с `null`, чтобы сервер выдал новую сессию.

## 5. Сценарий A: in-app через `/visit` или `/event`

### 5.1. Когда использовать `/visit`

`/visit` нужен для передачи screenview/pageview контекста, используйте `type: screenview`.

Это основной запрос, если кампания должна срабатывать:

- при открытии экрана;
- при переходе на PDP, cart, homepage, category;
- при попадании пользователя в контекст, на который настроен таргетинг.

### 5.2. Пример `/visit`

```dart
Future<Map<String, dynamic>> postVisit({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'type': 'screenview',
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
    },
  };

  final response = await api.dio.post('/visit', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}
```

### 5.3. Когда использовать `/event`

`/event` нужен для бизнес-событий:

- `purchase-v1`
- `add-to-cart-v1`
- `remove-from-cart-v1`
- `login-v1`
- любые кастомные события

Это важно не только для аналитики. Эти события участвуют в логике активации кампаний и сегментации.

### 5.4. Пример `/event`

```dart
Future<Map<String, dynamic>> postEvent({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required List<Map<String, dynamic>> events,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'data': events,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
    },
  };

  final response = await api.dio.post('/event', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}
```

#### Пример `login-v1` для склейки профилей

```dart
final loginEvent = {
  'type': 'login-v1',
  'name': 'Login',
  'cuid': hashedPhone,
  'cuidType': 'phone_hash',
};
```

#### Пример кастомного события

```dart
final customEvent = {
  'type': 'survey-completed-v1',
  'name': 'Survey Completed',
  'customProps': {
    'surveyId': 'summer-2025-feedback',
    'rating': '5',
  },
};
```

### 5.5. Как выбрать кампанию из `campaigns`

После `/visit` или `/event` вы получаете массив `campaigns`.

Практическое правило, повторяющее поведение Flutter SDK:

1. Отсортируйте кампании по `priority` по убыванию.
2. Для каждой кампании попробуйте получить контент через `/choose(campaignId)`.
3. Если у кампании есть `delayTime > 0`, дождитесь задержки перед показом.
4. Используйте первую кампанию, для которой реально пришёл контент.

```dart
Map<String, dynamic>? pickCampaignToRender(List<dynamic> campaigns) {
  final sorted = [...campaigns]..sort(
    (a, b) => (b['priority'] as int).compareTo(a['priority'] as int),
  );

  return sorted.isEmpty ? null : sorted.first as Map<String, dynamic>;
}
```

### 5.6. Пример `/choose` по `campaignId`

```dart
Future<Map<String, dynamic>> chooseByCampaignId({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required String campaignId,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
      'isReturnAnalyticsMetadata': true,
      'isImplicitPageview': false,
      'isBuildEngagementUrl': true,
    },
    'data': [
      {
        'campaignId': campaignId,
        'option': {
          'skusOnly': false,
          'fields': ['name', 'price', 'imageUrl'],
        },
      },
    ],
  };

  final response = await api.dio.post('/choose', data: payload);
  return response.data as Map<String, dynamic>;
}
```

### 5.7. Полный flow

```mermaid
sequenceDiagram
    participant App as Flutter App
    participant API as Gravity API

    App->>API: POST /visit или POST /event
    API-->>App: user + campaigns[]
    App->>App: sort by priority, apply delayTime
    App->>API: POST /choose { campaignId }
    API-->>App: data[] + payload[] + contents[] + events[]
    App->>App: render custom UI
    App->>API: GET tracking URL(s)
```

## 6. Сценарий B: inline, Custom JSON и selector-based A/B через `/choose(selector)`

Этот режим нужен, когда приложение заранее знает placement:

- "Вам может понравиться" на homepage;
- блок рекомендаций на PDP;
- selector-based A/B тест кнопки, баннера или блока;
- Custom JSON для BDUI и API-кампаний.

Бизнес-смысл совпадает с другими разделами документации про personalization и hybrid A/B: selector flow используется там, где приложение или backend заранее знает точку встраивания контента.

### 6.1. Пример `/choose` по `selector`

```dart
Future<Map<String, dynamic>> chooseBySelector({
  required GravityApiClient api,
  required GravitySessionStore store,
  required Map<String, dynamic> device,
  required Map<String, dynamic> ctx,
  required String selector,
}) async {
  final payload = {
    'sec': api.sectionId,
    'user': {
      'uid': await store.readUid(),
      'ses': store.readSessionId(),
    },
    'ctx': ctx,
    'device': device,
    'options': {
      'isReturnCounter': false,
      'isReturnUserInfo': false,
      'isReturnAnalyticsMetadata': true,
      'isImplicitPageview': false,
      'isBuildEngagementUrl': true,
    },
    'data': [
      {
        'selector': selector,
        'option': {
          'skusOnly': false,
          'fields': ['name', 'price', 'imageUrl'],
        },
      },
    ],
  };

  final response = await api.dio.post('/choose', data: payload);
  final data = response.data as Map<String, dynamic>;

  final user = data['user'] as Map<String, dynamic>?;
  final uid = user?['uid'] as String?;
  final ses = user?['ses'] as String?;

  if (uid != null) await store.writeUid(uid);
  if (ses != null) store.writeSessionId(ses);

  return data;
}
```

### 6.2. Что делать с ответом

Практически полезный путь до контента:

- `data[0]` - кампания;
- `data[0].payload[0]` - выбранная вариация;
- `data[0].payload[0].contents[0]` - блок контента;
- `data[0].payload[0].decisionId` - идентификатор решения;
- `contents[0].events` - content-level tracking;
- `contents[0].products.slots[*].events` - product-level tracking.

### 6.3. Fallback

Для inline и API/A/B placement-ов всегда закладывайте fallback:

- если `/choose` вернул пустой `data`;
- если товарная кампания вернула пустые `slots`;
- если запрос не успел уложиться в таймаут;
- если контент не удалось распарсить.

Обычно fallback выглядит так:

- не отображать блок;
- показать заглушку;
- показать стандартный banner;
- показать статический набор товаров.

## 7. Engagement, `events[]` и обработчики `on*`

Это критически важная часть прямой интеграции. Без engagement у кампаний не будет корректной статистики, а A/B результаты будут неполными или неверными.

Ниже описан рекомендуемый для v2 direct API подход: использовать tracking URL из `events[].urls[]` и `slot.events[].urls[]`, а не выносить tracking-логику в отдельный reference-поток.

### 7.1. Главная концепция

В ответе `/choose` есть два источника tracking:

- `content.events[]` - tracking для всего контентного блока;
- `products.slots[].events[]` - tracking для отдельных товарных карточек.

Принцип работы всегда один и тот же:

1. UI получает `action` из `variables.onLoad`, `onImpression`, `onVisibleImpression`, `onClose` или `element.onClick`.
2. По этому `action` приложение находит подходящий объект в `content.events[]`.
3. Приложение вызывает все URL из `urls[]`.

Для товарных карточек логика такая же, но источник событий другой: `slot.events[]`.

Важно:

- у content-level модели нет универсального обязательного `click`; она строится вокруг `action`;
- для product-level tracking модель проще: `impression`, `visible_impression`, `click`.

### 7.2. Что и когда отправлять

| Что отправлять | Когда |
| :--- | :--- |
| `onLoad` | После успешной загрузки контента |
| `onImpression` | После первого фактического рендера блока |
| `onVisibleImpression` | Один раз, когда блок реально стал видим пользователю |
| `onClose` | Когда пользователь закрыл in-app блок |
| `slot.visible_impression` | Один раз, когда карточка товара стала видимой |
| `slot.click` | На тап по карточке товара |

Для `visible_impression` придерживайтесь той же модели, что и SDK: отправляйте событие один раз, когда элемент достиг как минимум `50%` видимости.

### 7.3. Короткий пример content tracking

```dart
Future<void> triggerTrackingUrls(List<String> urls) async {
  final trackingDio = Dio();

  for (final url in urls) {
    try {
      await trackingDio.get(url);
    } catch (_) {
      // Не ломаем UI из-за tracking ошибки.
    }
  }
}

Future<void> sendContentTracking({
  required Map<String, dynamic> content,
  required String action,
}) async {
  final events = content['events'] as List<dynamic>?;
  if (events == null) return;

  for (final rawEvent in events) {
    final event = rawEvent as Map<String, dynamic>;
    if (event['type'] == action && event['urls'] is List) {
      final urls = (event['urls'] as List).map((e) => e.toString()).toList();
      await triggerTrackingUrls(urls);
      return;
    }
  }
}
```

Практически это выглядит так:

- `variables.onImpression.action` -> `sendContentTracking(...)`
- `variables.onVisibleImpression.action` -> `sendContentTracking(...)`
- `element.onClick.action` -> сначала `sendContentTracking(...)`, потом ваше UI-действие

### 7.4. Короткий пример product tracking

```dart
Future<void> sendProductTracking({
  required Map<String, dynamic> slot,
  required String action,
}) async {
  final events = slot['events'] as List<dynamic>?;
  if (events == null) return;

  for (final rawEvent in events) {
    final event = rawEvent as Map<String, dynamic>;
    if (event['type'] == action && event['urls'] is List) {
      final urls = (event['urls'] as List).map((e) => e.toString()).toList();
      await triggerTrackingUrls(urls);
      return;
    }
  }
}
```

Обычно этого достаточно:

- `sendProductTracking(slot: slot, action: 'visible_impression')`
- `sendProductTracking(slot: slot, action: 'click')`

## 8. Практические рекомендации

### 8.1. Таймауты и fallback

- Для inline placement-ов и A/B используйте строгие таймауты.
- Не блокируйте UI из-за `/choose`.
- Если API не ответил вовремя, показывайте fallback.

### 8.2. Не отправляйте engagement из `build()`

`impression` и `visible_impression` нужно защищать флагами и отправлять один раз. Иначе вы получите дубли.

### 8.3. Не меняйте значения из фида

Если `ctx.data`, `productId`, категории или `lng` берутся из фида, передавайте их без нормализации и без смены регистра.

### 8.4. Отдельно тестируйте пустые ответы

Нормальная ситуация:

- `/visit` вернул пустой `campaigns`;
- `/event` вернул пустой `campaigns`;
- `/choose` вернул пустой `data`;
- товарный блок вернул пустые `slots`.

Это не ошибка интеграции само по себе. В таких случаях приложение должно корректно показывать fallback.
