# SDK Gravity Field (iOS)

**Gravity Field SDK (iOS)** — клиентская библиотека для интеграции iOS-приложений с платформой персонализации и A/B-тестирования Gravity Field.

SDK позволяет:

- передавать просмотры экранов и действия пользователя в Gravity Field;
- получать контент кампаний по `selector`;
- автоматически показывать in-app кампании поддерживаемых форматов;
- встраивать inline-кампании через готовые SwiftUI/UIKit-компоненты;
- вручную отправлять content/product engagement для manual rendering сценариев;
- использовать собственный `ProductViewBuilder` для карточек товаров в product-based in-app кампаниях.

Документация предназначена для iOS-разработчиков, которые интегрируют SDK в приложение и хотят пройти базовый сценарий интеграции без обращения в поддержку.

Если вам нужно настроить сами форматы и сценарии в кабинете, смотрите пользовательский раздел [Кампании в приложении](/personalization/Campaigns/app_campaigns/getting_started.md).

[Репозиторий Gravity Field SDK для iOS](https://github.com/Gravity-Field/gravity-sdk-ios)

## Установка

SDK подключается через Swift Package Manager.

Минимальная версия платформы: `iOS 14`.

1. В Xcode откройте `File -> Add Package Dependencies...`.
2. Укажите URL репозитория с iOS SDK.
3. Выберите продукт `GravitySDK` и добавьте его в target приложения.
4. Импортируйте SDK в коде:

```swift
import GravitySDK
```

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

### Шаг 1. Инициализация SDK

Инициализируйте SDK при старте приложения, обычно в `App` или `AppDelegate`.

```swift
import GravitySDK
import SwiftUI
import UIKit

@main
struct MyApp: App {
    init() {
        GravitySDK.initialize(
            apiKey: "YOUR_API_KEY",
            section: "YOUR_SECTION_ID",
            gravityEventCallback: { event in
                if let event = event as? FollowUrlEvent,
                   let url = URL(string: event.url) {
                    UIApplication.shared.open(url)
                }

                if let event = event as? FollowDeeplinkEvent {
                    // Выполните навигацию по deeplink в приложении.
                    print(event.deeplink)
                }
            },
            productViewBuilder: MyProductBuilder(),
            productFilter: nil
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

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

После успешной авторизации рекомендуется отправить `LoginEvent`, чтобы связать анонимный и авторизованный профили.

```swift
let loginEvent = LoginEvent(
    cuid: sha256Hex(normalizePhone(rawPhoneNumber)),
    cuidType: "phone_hash"
)

GravitySDK.instance.triggerEvent(
    events: [loginEvent],
    pageContext: PageContext(
        type: .other,
        data: [],
        location: "app://login"
    )
)
```

Если приложение само управляет `userId` и `sessionId`, используйте `setUser(userId:sessionId:)`. Подробности — в разделе [Идентификация пользователя](#идентификация-пользователя).

### Шаг 3. Передача просмотров экранов

Используйте `trackView(...)`, чтобы передать SDK факт просмотра экрана. Этот вызов нужен и для трекинга поведения пользователя, и для сценариев, в которых показ кампании зависит от текущего контекста.

```swift
GravitySDK.instance.trackView(
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)
```

> Важно: если `PageContext` содержит значения из товарного фида, передавайте их без изменений относительно фида. Это относится к SKU, иерархии категорий, `lng` и другим feed-derived значениям. Регистр является частью значения: не приводите его к upper/lower case.

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

Используйте `triggerEvent(...)`, чтобы передать в SDK действие пользователя. Этот вызов нужен и для аналитики, и для сегментации, и для сценариев, где кампания реагирует на событие.

```swift
GravitySDK.instance.triggerEvent(
    events: [
        AddToCartEvent(
            value: 1990,
            productId: "sku-123",
            quantity: 1,
            currency: "RUB"
        )
    ],
    pageContext: PageContext(
        type: .product,
        data: ["sku-123"],
        location: "app://product/sku-123"
    )
)
```

> Важно: если в событии или `PageContext` передаются значения, которые должны матчиться с товарным фидом, используйте их в точности как в фиде. Это относится к `productId`, `cart[*].productId`, SKU в `data`, категориям, `lng` и другим feed-derived значениям. Регистр должен совпадать с фидом.

### Шаг 5. Отображение inline-кампаний

Для SwiftUI используйте `GravityInlineSwiftUIView`.

```swift
GravityInlineSwiftUIView(
    selector: "homepage-recommendations",
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)
```

Для UIKit используйте `GravityInlineView`.

```swift
let inlineView = GravityInlineView(selector: "homepage-recommendations")
inlineView.initialize(
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)
```

Готовые inline-компоненты сами загружают контент по `selector`, рендерят поддерживаемые элементы и отправляют базовые события показа.

### Шаг 6. Ручная загрузка контента по `selector`

Для собственного UI или headless-сценариев загрузите контент через `getContentBySelector(...)`.

```swift
Task {
    let response = await GravitySDK.instance.getContentBySelector(
        selector: "homepage-recommendations",
        pageContext: PageContext(
            type: .homepage,
            data: [],
            location: "app://homepage"
        )
    )

    if let campaign = response?.data.first,
       let variation = campaign.payload.first,
       let content = variation.contents.first {
        print(content.contentType)
    }
}
```

## Инициализация и конфигурация

### `initialize(...)`

```swift
public static func initialize(
    apiKey: String,
    section: String,
    gravityEventCallback: GravityEventCallback? = nil,
    productViewBuilder: ProductViewBuilder? = nil,
    productFilter: ProductFilter? = nil,
    uiSettings: UISettings? = nil,
    logLevel: LogLevel = .error
)
```

Параметры:

- `apiKey` — API key проекта.
- `section` — идентификатор секции проекта.
- `gravityEventCallback` — опциональный callback для событий SDK и действий пользователя внутри кампаний.
- `productViewBuilder` — шаблон карточки товара, который приложение передает SDK. Обязателен для отображения товарных рекомендаций.
- `productFilter` — клиентский фильтр слотов товаров. SDK применяет его при декодировании `Products.slots`, поэтому отфильтрованные товары не попадут в готовые product-based блоки и manual response.
- `uiSettings` — опциональные настройки UI SDK, например кастомный шрифт через `UISettings(fontName:)`.
- `logLevel` — уровень логирования внутреннего SDK-логгера. По умолчанию `.error`.

Что важно учесть:

- `initialize(...)` рекомендуется выполнить до первого обращения к `GravitySDK.instance`;
- `gravityEventCallback` используется для обработки действий пользователя внутри кампаний и tracking-событий поддерживаемых сценариев;
- если в приложении отображаются товарные рекомендации, передайте `productViewBuilder` уже на этапе инициализации.

### Логирование SDK

iOS SDK поддерживает параметр `logLevel` в `initialize(...)`. Он управляет только внутренними логами SDK и не влияет на `gravityEventCallback`, отправку событий или логи приложения.

```swift
GravitySDK.initialize(
    apiKey: "YOUR_API_KEY",
    section: "YOUR_SECTION_ID",
    gravityEventCallback: { event in
        print(event)
    },
    logLevel: .debug
)
```

Доступные уровни:

- `.debug` — включает HTTP request/response debug-логи и ошибки SDK;
- `.info` — включает `info` и `error` сообщения SDK;
- `.error` — оставляет только ошибки SDK;
- `.none` — полностью отключает внутренний вывод SDK.

Логи пишутся через `os_log` с префиксом `GravitySDK`.

Если нужно передавать внутренние логи SDK в приложение, используйте `setLogListener(...)`.

```swift
public typealias GravityLogListener = @Sendable (LogLevel, String) -> Void

public func setLogListener(_ listener: GravityLogListener?)
```

Пример:

```swift
GravitySDK.instance.setLogListener { level, message in
    print("[GravitySDK][\(level)] \(message)")
}
```

Обработчик получает только те сообщения, которые проходят текущий `logLevel`.

### `setOptions(options:contentSettings:proxyUrl:)`

```swift
public func setOptions(
    options: Options?,
    contentSettings: ContentSettings?,
    proxyUrl: String?
)
```

Метод задает runtime-настройки для последующих запросов `trackView(...)`, `triggerEvent(...)` и `getContentBySelector(...)`.

```swift
public struct Options: Codable {
    public let isReturnCounter: Bool
    public let isReturnUserInfo: Bool
    public let isReturnAnalyticsMetadata: Bool
    public let isImplicitPageview: Bool
    public let isImplicitImpression: Bool
}

public struct ContentSettings: Codable {
    public let skusOnly: Bool
    public let fields: [String]?
}
```

Пример:

```swift
GravitySDK.instance.setOptions(
    options: Options(
        isReturnCounter: false,
        isReturnUserInfo: true,
        isReturnAnalyticsMetadata: false,
        isImplicitPageview: false,
        isImplicitImpression: true
    ),
    contentSettings: ContentSettings(
        skusOnly: false,
        fields: ["id", "name", "price"]
    ),
    proxyUrl: nil
)
```

Что настраивается через `Options`:

- `isReturnCounter` — добавляет счетчики в response, если они нужны приложению;
- `isReturnUserInfo` — включает возврат `user` в response;
- `isReturnAnalyticsMetadata` — включает возврат аналитических метаданных;
- `isImplicitPageview` — управляет неявной отправкой pageview на backend;
- `isImplicitImpression` — управляет неявной отправкой impression для поддерживаемых сценариев.

Что настраивается через `ContentSettings`:

- `skusOnly` — возвращает только SKU вместо полного товарного объекта там, где это поддерживает backend-сценарий;
- `fields` — ограничивает набор полей, возвращаемых по товарам.

`proxyUrl` используется, если проект работает через собственный прокси к backend Gravity Field.

### `setUser(userId:sessionId:)`

```swift
public func setUser(userId: String, sessionId: String)
```

Метод включает ручное управление идентификаторами пользователя и сессии. После вызова SDK использует переданные значения в последующих запросах.

### `setNotificationPermissionStatus(status:)`

```swift
public func setNotificationPermissionStatus(
    status: NotificationPermissionStatus
)
```

Передает в SDK текущий статус push-разрешения для таргетинга и аналитики.

Возможные значения:

- `.granted`
- `.denied`
- `.unknown`

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

SDK поддерживает два режима идентификации: SDK-managed и ручной.

### Стандарт CUID на основе телефона (`phone_hash`)

Во всех интеграциях рекомендуется использовать единый идентификатор пользователя — SHA-256 хеш нормализованного телефона.

Правила:

- номер очищается от всех символов, кроме цифр;
- для РФ и КЗ номер приводится к формату `7XXXXXXXXXX`;
- хеш считается по UTF-8 строке;
- результат передается в lowercase hex;
- в `LoginEvent.cuidType` используется строка `"phone_hash"`.

### SDK-managed режим

Если `setUser(...)` не вызывать:

- `uid` сохраняется в `UserDefaults`;
- `ses` обновляется из ответов backend и хранится в runtime;
- последующие запросы используют эти значения автоматически.

После логина рекомендуется отправить `LoginEvent`, чтобы связать анонимный и авторизованный профили.

### Ручной режим

Если приложение само управляет идентификаторами, задайте их явно:

```swift
GravitySDK.instance.setUser(
    userId: "external-user-id",
    sessionId: "session-id"
)
```

## PageContext

`PageContext` описывает, где находится пользователь в приложении и в каком бизнес-контексте выполняется запрос.

Этот объект используется в ключевых вызовах iOS SDK:

- [`trackView(...)`](#trackviewpagecontextviewcontroller) для передачи просмотров экранов;
- [`triggerEvent(...)`](#triggereventeventspagecontextviewcontroller) для передачи действий пользователя;
- [`getContentBySelector(...)`](#getcontentbyselectorselectorpagecontext) для ручной загрузки контента.

Корректное заполнение `PageContext` влияет и на аналитику, и на таргетинг, и на подбор контента.

### Структура `PageContext`

```swift
public struct PageContext: Codable {
    public let type: ContextType
    public let data: [String]
    public let location: String
    public let lng: String?
    public let pageNumber: Int?
    public let referrer: String?
    public let utm: [String: String]?
    public let attributes: [String: String]
}
```

| Поле | Обязательность | Описание |
| :--- | :--- | :--- |
| `type` | Обязательно | Тип экрана. |
| `data` | Обязательно | Контекстные данные для выбранного типа экрана. |
| `location` | Обязательно | Уникальный идентификатор экрана, маршрута или deeplink. |
| `lng` | Опционально | Региональный код для мультирегиональности. |
| `pageNumber` | Опционально | Номер страницы в пагинации. |
| `referrer` | Опционально | Источник перехода. |
| `utm` | Опционально | UTM-метки. |
| `attributes` | Опционально | Дополнительные атрибуты таргетинга. |

Бизнес-логика заполнения `PageContext` соответствует [Page context](/Integration/web_integration/page_context.md).

Рекомендуемая схема заполнения:

- `HOMEPAGE`: `data = []`.
- `PRODUCT`: в `data` передается SKU товара ровно как в товарном фиде, без нормализации и с тем же регистром.
- `CART`: в `data` передаются SKU всех товаров, которые сейчас находятся в корзине, ровно как в товарном фиде, без нормализации и с тем же регистром.
- `CATEGORY`: в `data` передается полная иерархия категорий от самой широкой до самой узкой ровно как в товарном фиде, без нормализации и с тем же регистром.
- `SEARCH`: в `data` передается поисковый запрос одной строкой. Для пустого поиска передавайте пустой список.
- `OTHER`: используйте только для экранов, которые не подходят под остальные типы.

Для любых значений в `PageContext`, которые должны матчиться с товарным фидом, действует единое правило: передавайте их идентично фиду. Нельзя менять написание, переименовывать значения или приводить их к upper/lower case.

### Поле `lng`

`lng` используется только для мультирегиональности. Это региональный код, который позволяет отдавать пользователю корректные региональные данные по товарам: цены, доступность и остатки.

Например, если пользователь находится в Новосибирске, значение `lng` можно использовать для того, чтобы в рекомендациях участвовали только товары, реально доступные в Новосибирске.

Важно:

- значения `lng` в `PageContext` и в товарном фиде должны совпадать полностью, включая регистр;
- `lng`, как и любые другие feed-derived значения, нужно передавать без преобразования к upper/lower case;
- передавайте `lng` только если проект действительно использует региональные варианты данных.

### Поле `attributes`

SDK автоматически дополняет `attributes` служебными значениями:

- `app_version`
- `sdk_version`
- `app_platform`

### Примечания по текущей версии iOS SDK

- `sdk_version` передается из текущей версии SDK; для релиза `1.0.6` это `"1.0.6"`;
- `referrer` в публичной модели `PageContext` корректно передается через инициализатор;
- перед использованием `pageNumber` и `referrer` как обязательной основы для production-таргетинга всё равно стоит дополнительно проверить фактическое поведение backend на вашем проекте.

См. также:

- [`trackView(...)`](#trackviewpagecontextviewcontroller)
- [`triggerEvent(...)`](#triggereventeventspagecontextviewcontroller)
- [`getContentBySelector(...)`](#getcontentbyselectorselectorpagecontext)

## Отслеживание просмотров экранов

### `trackView(pageContext:viewController:)`

```swift
public func trackView(
    pageContext: PageContext,
    viewController: UIViewController? = nil
)
```

`trackView(...)` передает в Gravity Field факт просмотра экрана. Этот вызов используется и для трекинга пользовательского поведения, и для сценариев, в которых показ кампании зависит от контекста страницы.

Пример:

```swift
GravitySDK.instance.trackView(
    pageContext: PageContext(
        type: .product,
        data: ["sku-123"],
        location: "app://product/sku-123"
    )
)
```

Что важно учесть:

- для корректной бизнес-логики требуется корректно заполненный [`PageContext`](#pagecontext);
- если `viewController` не передан, SDK пытается найти top-most `UIViewController` автоматически;
- для автопоказа in-app нужен доступный `UIViewController`, уже находящийся в `window`;
- при успешной загрузке контента для найденных кампаний SDK автоматически отправляет content load tracking;
- метод запускает асинхронную работу внутри `Task` и не возвращает результат вызывающему коду напрямую.

## Трекинг событий

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

Бизнес-логика заполнения событий соответствует [Настройке передачи событий](/Integration/web_integration/events.md), а iOS SDK предоставляет типы и метод для их отправки.

Для e-commerce сценариев события `PurchaseEvent` и `AddToCartEvent` обязательны.

Для всех полей событий, которые должны матчиться с товарным фидом, действует то же правило, что и для `PageContext`: передавайте значения идентично фиду. Это относится к `productId`, `cart[*].productId` и другим feed-derived значениям. Регистр является частью значения.

### `triggerEvent(events:pageContext:viewController:)`

```swift
public func triggerEvent(
    events: [TriggerEvent],
    pageContext: PageContext,
    viewController: UIViewController? = nil
)
```

`triggerEvent(...)` передает в Gravity Field пользовательские действия. Этот вызов нужен и для аналитики, и для сегментации, и для сценариев, в которых событие может активировать кампанию.

Пример:

```swift
GravitySDK.instance.triggerEvent(
    events: [
        AddToCartEvent(
            value: 1990,
            productId: "sku-123",
            quantity: 1,
            currency: "RUB"
        )
    ],
    pageContext: PageContext(
        type: .product,
        data: ["sku-123"],
        location: "app://product/sku-123"
    )
)
```

Что важно учесть:

- как и для `trackView(...)`, для корректной бизнес-логики нужен корректно заполненный [`PageContext`](#pagecontext);
- для автопоказа in-app нужен доступный `UIViewController`, уже находящийся в `window`;
- при успешной загрузке контента для найденных кампаний SDK автоматически отправляет content load tracking;
- метод запускает асинхронную работу внутри `Task` и не возвращает результат вызывающему коду напрямую.

### Основные типы `TriggerEvent`

| Событие | Основные параметры |
| :--- | :--- |
| `AddToCartEvent` | `value`, `productId`, `quantity`, `currency?`, `cart?` |
| `PurchaseEvent` | `uniqueTransactionId`, `value`, `cart`, `currency?` |
| `RemoveFromCartEvent` | `value`, `productId`, `quantity`, `currency?`, `cart?` |
| `SyncCartEvent` | `value`, `currency?`, `cart?` |
| `AddToWishlistEvent` | `value`, `productId` |
| `SignUpEvent` | `hashedEmail?`, `cuid?`, `cuidType?` |
| `LoginEvent` | `hashedEmail?`, `cuid?`, `cuidType?` |
| `CustomEvent` | `type`, `name`, `customProps?` |

### Правила заполнения e-commerce событий

#### `PurchaseEvent`

- отправляйте после успешного завершения заказа;
- `uniqueTransactionId` должен быть уникальным для каждой покупки;
- `value` — полная сумма заказа;
- `cart` — фактический состав заказа;
- `cart[*].productId` должен совпадать со SKU в товарном фиде полностью, включая регистр;
- `currency` обязательна для мультивалютных проектов.

```swift
let purchase = PurchaseEvent(
    uniqueTransactionId: "ORDER-1001",
    value: 3580,
    cart: [
        CartItem(productId: "sku-123", quantity: 1, itemPrice: 1990),
        CartItem(productId: "sku-555", quantity: 1, itemPrice: 1590)
    ],
    currency: "RUB"
)
```

#### `AddToCartEvent`

- отправляйте в момент фактического добавления товара в корзину;
- `value` — сумма, добавляемая этим действием;
- `quantity` — количество единиц, добавленных именно этим действием;
- `productId` должен совпадать со SKU в товарном фиде полностью, включая регистр.

```swift
let addToCart = AddToCartEvent(
    value: 1990,
    productId: "sku-123",
    quantity: 1,
    currency: "RUB"
)
```

#### `RemoveFromCartEvent` и `SyncCartEvent`

- `RemoveFromCartEvent` отправляйте в момент удаления товара из корзины или уменьшения количества;
- `value` в `RemoveFromCartEvent` — сумма удаляемых единиц товара;
- `productId` в `RemoveFromCartEvent` должен совпадать со SKU в товарном фиде полностью, включая регистр;
- `SyncCartEvent` используйте, когда нужно передать полное актуальное состояние корзины;
- `value` в `SyncCartEvent` — общая стоимость актуального состава корзины.
- `cart[*].productId` в `RemoveFromCartEvent` и `SyncCartEvent`, если передается `cart`, должен совпадать со SKU в товарном фиде полностью, включая регистр.

#### `LoginEvent`

```swift
let login = LoginEvent(
    cuid: sha256PhoneHash,
    cuidType: "phone_hash"
)
```

#### `AddToWishlistEvent`

```swift
let addToWishlist = AddToWishlistEvent(
    value: 1990,
    productId: "sku-123"
)
```

`productId` в `AddToWishlistEvent` должен совпадать со SKU в товарном фиде полностью, включая регистр.

#### `CustomEvent`

```swift
let custom = CustomEvent(
    type: "survey-completed-v1",
    name: "Survey Completed",
    customProps: ["score": "9", "variant": "A"]
)
```

## Передача статуса Push-уведомлений

Если таргетинг кампаний зависит от статуса push-разрешения, передавайте его в SDK через `setNotificationPermissionStatus(status:)`.

```swift
GravitySDK.instance.setNotificationPermissionStatus(status: .granted)
```

Отдельно учитывайте поведение `requestPush` внутри in-app кампаний:

- iOS SDK сам вызывает `UNUserNotificationCenter.requestAuthorization(...)`;
- при отказе SDK может открыть системные настройки приложения;
- SDK дополнительно передает `RequestPushEvent` в `gravityEventCallback`.

## Работа с контентом

iOS SDK поддерживает три основных режима работы с контентом:

- автоматический показ in-app кампаний;
- готовые inline-компоненты для SwiftUI/UIKit;
- ручную загрузку контента по `selector` для собственного UI.

### In-App кампании

SDK автоматически показывает кампании с `deliveryMethod`:

- `fullScreen`
- `modal`
- `bottomSheet`
- `snackBar`

Что требуется для автопоказа:

- подходящая активная кампания на backend;
- корректный [`PageContext`](#pagecontext);
- доступный `UIViewController` в момент вызова `trackView(...)` или `triggerEvent(...)`.

<a id="presentation-lock"></a>
#### Блокировка показа in-app кампаний

Используйте presentation lock, когда приложение показывает собственную модалку, bottom sheet, onboarding, checkout или другой приоритетный слой интерфейса, который не должен перекрываться кампанией Gravity.

```swift
func showCheckoutSheet() {
    GravitySDK.instance.lockPresentation()

    let checkoutViewController = CheckoutViewController()
    checkoutViewController.onDismiss = {
        GravitySDK.instance.unlockPresentation()
    }

    present(checkoutViewController, animated: true)
}
```

Пока блокировка активна, SDK может загрузить контент после `trackView(...)` или `triggerEvent(...)`, но не будет показывать новую in-app кампанию поверх текущего интерфейса. Уже открытый Gravity-контент этим вызовом не закрывается.

Блокировка также применяется к переходам между шагами кампании через `openStep`. Ручная загрузка selector-контента через `getContentBySelector(...)` не блокируется: приложение продолжает само управлять показом manual rendering сценариев.

Если приложению нужно отслеживать состояние блокировки, подпишитесь на изменения:

```swift
GravitySDK.instance.setPresentationLockListener { locked in
    // Обновите состояние приложения, если это нужно вашему UI.
}
```

`lockPresentation()` и `unlockPresentation()` можно вызывать повторно.

### Inline-компоненты

iOS SDK предоставляет два публичных компонента для selector-based inline-кампаний:

- `GravityInlineSwiftUIView` — для SwiftUI-экранов;
- `GravityInlineView` — для UIKit-экранов, включая ячейки `UITableView`.

SwiftUI:

```swift
GravityInlineSwiftUIView(
    selector: "homepage-recommendations",
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)
```

UIKit:

```swift
let inlineView = GravityInlineView(selector: "homepage-recommendations")

inlineView.initialize(
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)
```

Что важно учесть:

- `selector` должен совпадать с selector кампании на backend;
- `PageContext` должен соответствовать таргетингу кампании;
- если backend не вернул подходящий контент, компонент останется пустым;
- для UIKit-компонента нужно корректно настроить constraints и вызвать `initialize(pageContext:)`;
- высота inline-блока может обновляться по `frameUI.container.style.size.height`, если это значение пришло с backend.

### `getContentBySelector(selector:pageContext:)`

```swift
public func getContentBySelector(
    selector: String,
    pageContext: PageContext
) async -> ContentResponse?
```

Используйте этот метод для manual rendering и кастомного UI.

```swift
let response = await GravitySDK.instance.getContentBySelector(
    selector: "homepage-recommendations",
    pageContext: PageContext(
        type: .homepage,
        data: [],
        location: "app://homepage"
    )
)

if let response {
    // Разберите response.data и постройте собственный UI.
}
```

Для вызова требуется корректно заполненный [`PageContext`](#pagecontext).

Что важно учесть:

- успешный `getContentBySelector(...)` автоматически запускает content load tracking для всех `contents`, пришедших в response;
- при сетевой ошибке или ошибке декодирования метод возвращает `nil` и пишет SDK-лог;
- это означает, что `ContentLoadEvent` может прийти в `gravityEventCallback` сразу после загрузки контента, даже если UI еще не был показан пользователю;
- impression / visible impression / click для manual widget SDK автоматически не отправляет: приложение по-прежнему должно вызвать `sendContentEngagement(...)` и `sendProductEngagement(...)` самостоятельно.

### Пример: server-driven recommendation widget

Используйте этот сценарий, если приложение хочет само рендерить inline recommendation widget, а backend должен продолжать управлять содержимым и структурой виджета через `getContentBySelector(...)`.

Разделение ответственности в этом flow:

- клиент вызывает `getContentBySelector(...)` с нужными `selector` и `PageContext`;
- backend возвращает готовую конфигурацию виджета;
- клиент интерпретирует ответ SDK и строит native UI;
- клиент сам обрабатывает `onClick` и вручную отправляет engagement.

#### Что именно управляется с сервера

- заголовок и дополнительные текстовые блоки приходят как `Element(type = text)` внутри `content.variables.elements`;
- изображения, кнопки, spacer и другие UI-элементы тоже приходят в `elements`;
- подборка товаров, порядок карточек, стратегия и fallback приходят в `content.products`;
- тип товарного блока задается через `products-container` и `style.productContainerType`;
- внешний контейнер виджета приходит в `content.variables.frameUI?.container`;
- сервер также управляет визуальной кастомизацией через `frameUI.container.style` и `elements[*].style`.

#### Как читать ответ SDK

| Поле | Что означает | Как использовать |
| :--- | :--- | :--- |
| `response.data[*]` | `campaign` | Верхний уровень ответа SDK. Используется для engagement и доступа к variation. |
| `campaign.payload[*]` | variation / decision | Содержит набор `contents` для выбранной кампании. |
| `payload.contents[*]` | `CampaignContent` | Конкретный блок, который нужно рендерить. |
| `content.variables.frameUI?.container` | Внешний контейнер виджета | Фон, скругление, padding, margin, size и опциональный `onClick` для всего блока. |
| `content.variables.elements` | Ordered server-driven UI schema | Рендерите элементы строго в том порядке, в котором они пришли с backend. |
| `Element(type = text)` | Текстовый блок | Если это первый `text` до `products-container`, его можно трактовать как заголовок виджета. |
| `Element(type = products-container)` | Место вставки товарного блока | В этой точке UI нужно отрисовать `content.products?.slots`. |
| `element.style.productContainerType` | Тип recommendation widget | Значение `row` или `grid` приходит с backend и не должно хардкодиться в приложении. |
| `element.style.gridColumns` | Число колонок для `grid` | Если поле отсутствует, используйте дефолт клиента, например `3`. |
| `content.products?.slots[*]` | Отранжированные товары | Используйте порядок слотов как есть, без локальной перестановки. |
| `slot.item[...]` | Данные товара | Источник `name`, `price`, `imageUrl`, `url` и любых кастомных полей из фида. |

`products.strategyId`, `products.name` и `products.fallback` показывают, какая рекомендательная стратегия была выбрана backend и был ли использован fallback.

#### Что клиент не должен делать

- не хардкодить заголовок, если он уже приходит в `elements`;
- не перестраивать порядок товаров, если backend уже вернул готовое ранжирование;
- не игнорировать `productContainerType`, если backend переключил layout с `row` на `grid` или обратно;
- не передавать вручную `decisionId` или engagement URL: используйте `campaign`, `content` и `slot` из ответа SDK.

#### Полный пример на SwiftUI

Ниже пример server-driven виджета. Он:

- загружает `CampaignContent` через `getContentBySelector(...)`;
- рендерит `elements` в том порядке, который вернул backend;
- берет товары из `content.products?.slots`;
- выбирает `row` или `grid` по `products-container.style.productContainerType`;
- вручную отправляет `ContentImpressionEngagement`, `ContentVisibleImpressionEngagement`, `ProductVisibleImpressionEngagement` и `ProductClickEngagement`.

Для краткости `RemoteImageView` использует `AsyncImage`. Если в проекте уже есть свой image loader, замените только эту часть.

```swift
import SwiftUI
import UIKit
import GravitySDK

struct ServerDrivenRecommendationWidget: View {
    let selector: String
    let pageContext: PageContext
    let onOpenUrl: (URL) -> Void
    let onOpenDeeplink: (String) -> Void
    let onOpenProduct: ([String: Any?]) -> Void

    @State private var campaign: Campaign?
    @State private var content: CampaignContent?
    @State private var isLoading = false
    @State private var hasSentContentImpression = false
    @State private var hasSentContentVisibleImpression = false
    @State private var visibleProductKeys = Set<String>()

    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else if let campaign, let content {
                VisibilityObserver {
                    sendContentVisibleImpressionIfNeeded(content: content, campaign: campaign)
                } content: {
                    widgetBody(content: content, campaign: campaign)
                }
                .task(id: content.contentId) {
                    sendContentImpressionIfNeeded(content: content, campaign: campaign)
                }
            } else {
                EmptyView()
            }
        }
        .task {
            await loadContent()
        }
    }

    @ViewBuilder
    private func widgetBody(content: CampaignContent, campaign: Campaign) -> some View {
        let container = content.variables.frameUI?.container
        let containerStyle = container?.style ?? .empty
        let alignment = horizontalAlignment(from: containerStyle.contentAlignment)

        VStack(alignment: alignment, spacing: 0) {
            ForEach(Array(content.variables.elements.enumerated()), id: \.offset) { _, element in
                renderElement(
                    element,
                    content: content,
                    campaign: campaign
                )
            }
        }
        .padding(edgeInsets(from: containerStyle.padding))
        .frame(
            width: containerStyle.size?.width,
            height: containerStyle.size?.height,
            alignment: .topLeading
        )
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(containerStyle.backgroundColor ?? Color.clear)
        .clipShape(
            RoundedRectangle(cornerRadius: containerStyle.cornerRadius ?? 0)
        )
        .padding(edgeInsets(from: containerStyle.margin))
    }

    @ViewBuilder
    private func renderElement(
        _ element: Element,
        content: CampaignContent,
        campaign: Campaign
    ) -> some View {
        switch element.type {
        case .text:
            Text(element.text ?? "")
                .font(.system(
                    size: element.style.fontSize ?? 16,
                    weight: fontWeight(from: element.style.fontWeight)
                ))
                .foregroundColor(element.style.textColor)
                .multilineTextAlignment(
                    element.style.contentAlignment?.toTextAlignment() ?? .leading
                )
                .frame(maxWidth: .infinity, alignment: alignment(from: element.style.contentAlignment))
                .padding(edgeInsets(from: element.style.margin))
                .onTapGesture {
                    handleOnClick(element.onClick)
                }

        case .image:
            if let src = element.src {
                RemoteImageView(
                    urlString: src,
                    height: element.style.size?.height ?? 120
                )
                .frame(maxWidth: .infinity)
                .padding(edgeInsets(from: element.style.margin))
                .onTapGesture {
                    handleOnClick(element.onClick)
                }
            }

        case .button:
            Button {
                handleOnClick(element.onClick)
            } label: {
                Text(element.text ?? "")
                    .font(.system(
                        size: element.style.fontSize ?? 16,
                        weight: fontWeight(from: element.style.fontWeight)
                    ))
                    .foregroundColor(element.style.textColor ?? .white)
                    .frame(maxWidth: .infinity)
                    .padding(edgeInsets(from: element.style.padding))
                    .background(element.style.backgroundColor ?? Color.blue)
                    .clipShape(
                        RoundedRectangle(cornerRadius: element.style.cornerRadius ?? 12)
                    )
            }
            .padding(edgeInsets(from: element.style.margin))

        case .spacer:
            Color.clear
                .frame(height: element.style.size?.height ?? 12)

        case .productsContainer:
            if let slots = content.products?.slots {
                switch element.style.productContainerType ?? .row {
                case .row:
                    ScrollView(.horizontal, showsIndicators: false) {
                        LazyHStack(spacing: 12) {
                            ForEach(Array(slots.enumerated()), id: \.offset) { itemIndex, slot in
                                productCard(
                                    slot: slot,
                                    key: productKey(slot: slot, index: itemIndex),
                                    content: content,
                                    campaign: campaign
                                )
                            }
                        }
                        .padding(edgeInsets(from: element.style.padding))
                    }
                    .padding(edgeInsets(from: element.style.margin))

                case .grid:
                    let columns = Array(
                        repeating: GridItem(.flexible(), spacing: 12),
                        count: max(element.style.gridColumns ?? 3, 1)
                    )

                    LazyVGrid(columns: columns, spacing: 12) {
                        ForEach(Array(slots.enumerated()), id: \.offset) { itemIndex, slot in
                            productCard(
                                slot: slot,
                                key: productKey(slot: slot, index: itemIndex),
                                content: content,
                                campaign: campaign
                            )
                        }
                    }
                    .padding(edgeInsets(from: element.style.padding))
                    .padding(edgeInsets(from: element.style.margin))
                }
            }

        default:
            EmptyView()
        }
    }

    @ViewBuilder
    private func productCard(
        slot: Slot,
        key: String,
        content: CampaignContent,
        campaign: Campaign
    ) -> some View {
        VisibilityObserver {
            sendProductVisibleImpressionIfNeeded(
                for: key,
                slot: slot,
                content: content,
                campaign: campaign
            )
        } content: {
            VStack(alignment: .leading, spacing: 8) {
                if let imageUrl = stringValue("imageUrl", in: slot.item) {
                    RemoteImageView(urlString: imageUrl, height: 160)
                }

                Text(stringValue("name", in: slot.item) ?? "Unknown product")
                    .font(.headline)
                    .foregroundColor(.primary)

                if let price = stringValue("price", in: slot.item) {
                    Text(price)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }
            .padding(12)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.white)
            .clipShape(RoundedRectangle(cornerRadius: 16))
            .onTapGesture {
                GravitySDK.instance.sendProductEngagement(
                    engagement: ProductClickEngagement(
                        slot: slot,
                        content: content,
                        campaign: campaign
                    )
                )
                onOpenProduct(slot.item)
            }
        }
    }

    @MainActor
    private func loadContent() async {
        isLoading = true
        defer { isLoading = false }

        let response = await GravitySDK.instance.getContentBySelector(
            selector: selector,
            pageContext: pageContext
        )

        campaign = response?.data.first
        content = campaign?.payload.first?.contents.first
        hasSentContentImpression = false
        hasSentContentVisibleImpression = false
        visibleProductKeys.removeAll()
    }

    private func sendContentImpressionIfNeeded(
        content: CampaignContent,
        campaign: Campaign
    ) {
        guard !hasSentContentImpression else { return }
        hasSentContentImpression = true

        GravitySDK.instance.sendContentEngagement(
            engagement: ContentImpressionEngagement(
                content: content,
                campaign: campaign
            )
        )
    }

    private func sendContentVisibleImpressionIfNeeded(
        content: CampaignContent,
        campaign: Campaign
    ) {
        guard !hasSentContentVisibleImpression else { return }
        hasSentContentVisibleImpression = true

        GravitySDK.instance.sendContentEngagement(
            engagement: ContentVisibleImpressionEngagement(
                content: content,
                campaign: campaign
            )
        )
    }

    private func sendProductVisibleImpressionIfNeeded(
        for key: String,
        slot: Slot,
        content: CampaignContent,
        campaign: Campaign
    ) {
        guard !visibleProductKeys.contains(key) else { return }
        visibleProductKeys.insert(key)

        GravitySDK.instance.sendProductEngagement(
            engagement: ProductVisibleImpressionEngagement(
                slot: slot,
                content: content,
                campaign: campaign
            )
        )
    }

    private func handleOnClick(_ onClick: OnClickModel?) {
        guard let onClick else { return }

        switch onClick.action {
        case .followUrl:
            guard
                let rawUrl = onClick.url,
                let url = URL(string: rawUrl)
            else { return }
            onOpenUrl(url)

        case .followDeeplink:
            guard let deeplink = onClick.deeplink else { return }
            onOpenDeeplink(deeplink)

        case .copy:
            UIPasteboard.general.string = onClick.copyData

        default:
            break
        }
    }

    private func productKey(slot: Slot, index: Int) -> String {
        slot.slotId
            ?? stringValue("sku", in: slot.item)
            ?? stringValue("id", in: slot.item)
            ?? "slot-\(index)"
    }

    private func stringValue(_ key: String, in item: [String: Any?]) -> String? {
        if let value = item[key] as? String {
            return value
        }
        if let value = item[key] as? NSNumber {
            return value.stringValue
        }
        return nil
    }

    private func edgeInsets(from padding: GravityPadding?) -> EdgeInsets {
        EdgeInsets(
            top: padding?.top ?? 0,
            leading: padding?.left ?? 0,
            bottom: padding?.bottom ?? 0,
            trailing: padding?.right ?? 0
        )
    }

    private func edgeInsets(from margin: GravityMargin?) -> EdgeInsets {
        EdgeInsets(
            top: margin?.top ?? 0,
            leading: margin?.left ?? 0,
            bottom: margin?.bottom ?? 0,
            trailing: margin?.right ?? 0
        )
    }

    private func alignment(from value: GravityContentAlignment?) -> Alignment {
        switch value {
        case .center:
            return .center
        case .end:
            return .trailing
        default:
            return .leading
        }
    }

    private func horizontalAlignment(from value: GravityContentAlignment?) -> HorizontalAlignment {
        switch value {
        case .center:
            return .center
        case .end:
            return .trailing
        default:
            return .leading
        }
    }

    private func fontWeight(from value: Double?) -> Font.Weight {
        switch value {
        case 700:
            return .bold
        case 600:
            return .semibold
        case 500:
            return .medium
        default:
            return .regular
        }
    }
}

private struct RemoteImageView: View {
    let urlString: String
    let height: Double

    var body: some View {
        if let url = URL(string: urlString) {
            AsyncImage(url: url) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .scaledToFill()
                default:
                    ZStack {
                        Color.gray.opacity(0.1)
                        ProgressView()
                    }
                }
            }
            .frame(height: height)
            .clipShape(RoundedRectangle(cornerRadius: 12))
        }
    }
}

private struct VisibilityObserver<Content: View>: View {
    let onVisible: () -> Void
    let content: () -> Content

    @State private var hasFired = false
    @State private var workItem: DispatchWorkItem?

    var body: some View {
        content()
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .onAppear {
                            evaluate(frame: geometry.frame(in: .global))
                        }
                        .onChange(of: geometry.frame(in: .global)) { newFrame in
                            evaluate(frame: newFrame)
                        }
                        .onDisappear {
                            workItem?.cancel()
                            workItem = nil
                        }
                }
            )
    }

    private func evaluate(frame: CGRect) {
        guard !hasFired else { return }

        let screen = UIScreen.main.bounds
        let intersection = screen.intersection(frame)
        let visibleArea = intersection.width * intersection.height
        let totalArea = frame.width * frame.height

        guard totalArea > 0 else { return }

        if visibleArea < totalArea * 0.5 {
            workItem?.cancel()
            workItem = nil
            return
        }

        workItem?.cancel()
        let task = DispatchWorkItem {
            hasFired = true
            onVisible()
        }
        workItem = task
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: task)
    }
}
```

Если backend передает `frameUI.container.onClick`, его можно повесить на wrapper виджета тем же способом, что и `element.onClick` в примере выше.

#### Engagement для manual widget

При использовании публичного `getContentBySelector(...)` SDK только получает контент. Engagement для такого manual widget приложение отправляет само:

- `ContentImpressionEngagement` — один раз, когда виджет впервые отрисован;
- `ContentVisibleImpressionEngagement` — один раз, когда минимум 50% виджета находится в видимой области не менее 1 секунды;
- `ProductVisibleImpressionEngagement` — один раз на карточку товара по тому же правилу;
- `ProductClickEngagement` — на тап по карточке товара до навигации;
- публичного `ContentClickEngagement` в текущем iOS SDK нет.

Для manual rendering это означает, что `campaign`, `content` и `slot` нужно сохранить из ответа SDK и использовать повторно при отправке engagement.

#### Дополнительная кастомизация из backend

Нативный widget можно стилизовать по полям, которые уже приходят в моделях SDK:

- container: `backgroundColor`, `cornerRadius`, `padding`, `margin`, `size`;
- text: `fontSize`, `fontWeight`, `textColor`, `contentAlignment`;
- image: `src`, `fit`, `size`;
- layout: `gridColumns`, `productContainerType`;
- элементы: `button`, `image`, `text`, `spacer`, `products-container`.

### `ProductViewBuilder`

`ProductViewBuilder` нужен для отображения карточек товара в product-based или recommendation-блоках. Это обязательная часть интеграции товарных рекомендаций: приложение должно передать SDK шаблон карточки, иначе SDK не знает, какой UI использовать для товара в вашем приложении.

SDK получает блок рекомендаций, управляет контейнером, списком слотов и tracking-контекстом, но сам шаблон товарной карточки задает приложение. Разработчик реализует `ProductViewBuilder` или `LegacyProductViewBuilder` и передает его в `GravitySDK.initialize(...)` через параметр `productViewBuilder`.

После этого SDK использует переданный шаблон для каждого товара в блоке: вызывает builder для каждого `slot` и передает в него `slot`, `content` и `campaign`. Поэтому карточка отображается как native UI приложения, а не как готовая карточка, нарисованная SDK.

`slot` содержит данные одного рекомендованного товара:

- `slot.item` - словарь с полями товара из фида или из запрошенных `fields`: например `sku`, `name`, `price`, `old_price`, `image_url`, `url`, `brand`, `currency` и другие кастомные поля;
- `slot.strId` и `slot.slotId` - идентификаторы позиции товара в выдаче;
- `slot.fallback` - признак, что товар пришел из fallback-логики;
- `slot.events` - tracking-данные товара, которые SDK использует при отправке engagement.

Названия ключей в `slot.item` должны совпадать с вашим фидом и настройкой `fields`. Например, если в ответе приходит `imageUrl`, читайте `imageUrl`; если используется поле фида `image_url`, читайте `image_url`.

Фактический протокол:

```swift
public protocol ProductViewBuilder {
    @ViewBuilder
    func build(
        slot: Slot,
        content: CampaignContent,
        campaign: Campaign
    ) -> AnyView
}

public protocol LegacyProductViewBuilder: ProductViewBuilder {
    func createView(
        slot: Slot,
        content: CampaignContent,
        campaign: Campaign
    ) -> UIView
}
```

Пример шаблона карточки:

```swift
import SwiftUI
import GravitySDK

struct MyProductBuilder: ProductViewBuilder {
    func build(
        slot: Slot,
        content: CampaignContent,
        campaign: Campaign
    ) -> AnyView {
        let product = slot.recommendedProduct

        return AnyView(
            VStack(alignment: .leading, spacing: 8) {
                if let imageUrl = product.imageUrl,
                   let url = URL(string: imageUrl) {
                    AsyncImage(url: url) { phase in
                        switch phase {
                        case .success(let image):
                            image
                                .resizable()
                                .scaledToFit()
                        default:
                            Color.gray.opacity(0.1)
                        }
                    }
                    .frame(height: 140)
                }

                Text(product.name)
                    .font(.headline)
                    .lineLimit(2)

                if let oldPrice = product.oldPrice {
                    Text(oldPrice)
                        .font(.caption)
                        .foregroundColor(.secondary)
                        .strikethrough()
                }

                if let price = product.price {
                    Text(price)
                        .font(.subheadline)
                        .fontWeight(.semibold)
                }
            }
            .frame(width: 164, alignment: .leading)
            .padding(8)
            .onTapGesture {
                GravitySDK.instance.sendProductEngagement(
                    engagement: ProductClickEngagement(
                        slot: slot,
                        content: content,
                        campaign: campaign
                    )
                )
                openProduct(url: product.url, sku: product.sku)
            }
        )
    }

    private func openProduct(url: String?, sku: String) {
        // Откройте экран товара в приложении или deeplink по url/sku.
    }
}

private struct RecommendedProduct {
    let sku: String
    let name: String
    let price: String?
    let oldPrice: String?
    let imageUrl: String?
    let url: String?
}

private extension Slot {
    var recommendedProduct: RecommendedProduct {
        RecommendedProduct(
            sku: stringValue("sku", "id") ?? slotId ?? String(strId),
            name: stringValue("name") ?? "Товар",
            price: stringValue("price"),
            oldPrice: stringValue("old_price", "oldPrice"),
            imageUrl: stringValue("image_url", "imageUrl"),
            url: stringValue("url")
        )
    }

    func stringValue(_ keys: String...) -> String? {
        for key in keys {
            guard let value = item[key] ?? nil else {
                continue
            }

            if let value = value as? String, !value.isEmpty {
                return value
            }

            return String(describing: value)
        }

        return nil
    }
}
```

Что важно учесть:

- `ProductViewBuilder` получает `slot`, `content` и `campaign`, поэтому карточка товара может учитывать контекст кампании и content block;
- если в приложении уже есть UIKit-карточка товара, используйте `LegacyProductViewBuilder` и верните `UIView` из `createView(...)`;
- SDK автоматически отслеживает visible impression товара во встроенном блоке, а tap по кастомной карточке приложение отправляет вручную через `sendProductEngagement(...)` перед навигацией;
- `productFilter` применяется к `Products.slots` при декодировании ответа SDK. Используйте его только для простого клиентского исключения слотов, когда это действительно нужно в приложении.

### Трекинг взаимодействий

Для manual rendering и кастомного UI iOS SDK предоставляет публичные методы:

```swift
public func sendContentEngagement(engagement: ContentEngagement)

public func sendProductEngagement(engagement: ProductEngagement)
```

Поддерживаемые content engagement-типы:

- `ContentImpressionEngagement`
- `ContentVisibleImpressionEngagement`
- `ContentCloseEngagement`

Поддерживаемые product engagement-типы:

- `ProductClickEngagement`
- `ProductVisibleImpressionEngagement`

#### Откуда брать `slot`, `content` и `campaign`

В manual rendering сценарии эти объекты обычно берутся из ответа `getContentBySelector(...)`:

```swift
let response = await GravitySDK.instance.getContentBySelector(
    selector: "homepage-recommendations",
    pageContext: pageContext
)

if let campaign = response?.data.first,
   let payload = campaign.payload.first,
   let content = payload.contents.first,
   let slot = content.products?.slots?.first {

    GravitySDK.instance.sendContentEngagement(
        engagement: ContentImpressionEngagement(
            content: content,
            campaign: campaign
        )
    )

    GravitySDK.instance.sendProductEngagement(
        engagement: ProductClickEngagement(
            slot: slot,
            content: content,
            campaign: campaign
        )
    )
}
```

Если вы используете `ProductViewBuilder`, SDK передает в билдер `slot`, `content` и `campaign`. Для manual engagement сценариев всё равно сохраняйте эти же объекты из response или из контекста билдера и используйте их при вызове `sendContentEngagement(...)` / `sendProductEngagement(...)`.

## Обработка обратных вызовов (Callbacks)

### `gravityEventCallback`

`gravityEventCallback` передается в `initialize(...)` опционально. Если приложению нужно обрабатывать действия пользователя внутри кампаний, передайте callback при инициализации SDK.

В SDK определены модели `TrackingEvent`, включая:

- `ContentLoadEvent`
- `ContentImpressionEvent`
- `ContentVisibleImpressionEvent`
- `ContentCloseEvent`
- `CopyEvent`
- `CancelEvent`
- `FollowUrlEvent`
- `FollowDeeplinkEvent`
- `RequestPushEvent`
- `ProductImpressionEvent`
- `ProductClickEvent`

SDK передает эти события в `gravityEventCallback` и выполняет вызов на главном потоке.

Практический вывод:

- `ContentLoadEvent` может приходить после `getContentBySelector(...)`, а также после внутренней загрузки контента для auto-rendered in-app кампаний;
- используйте `gravityEventCallback` для обработки URL, deeplink и других действий пользователя внутри кампаний;
- SDK сам не выполняет переходы по `FollowUrlEvent` и `FollowDeeplinkEvent`, это ответственность приложения;
- manual engagement через `sendContentEngagement(...)` / `sendProductEngagement(...)` не генерирует callback-событие автоматически;
- `ProductClickEvent` может приходить из встроенного rendering flow SDK, но не из ручного вызова `sendProductEngagement(...)`.

## FAQ / Troubleshooting

### Вызов SDK до `GravitySDK.initialize(...)`

Если публичный метод SDK вызван до `GravitySDK.initialize(...)`, приложение не падает. SDK игнорирует вызов и пишет error-лог:

```text
called before GravitySDK.initialize() — call ignored
```

Что делать:

1. Инициализируйте SDK на раннем этапе старта приложения.
2. Проверьте порядок вызовов по логам SDK или через `setLogListener(...)`.

### Кампании не показываются после `trackView(...)` или `triggerEvent(...)`

Проверьте:

1. корректность `apiKey` и `section`;
2. корректность [`PageContext`](#pagecontext);
3. наличие доступного `UIViewController` для показа;
4. активность кампаний в Gravity Field для этого контекста.

### `getContentBySelector(...)` возвращает пустой `data`

Проверьте:

1. верный `selector`;
2. активность кампании;
3. соответствие [`PageContext`](#pagecontext) условиям таргетинга;
4. сетевые ограничения и `proxyUrl`, если он используется.
5. SDK-логи через `logLevel: .debug` или `setLogListener(...)`, если метод вернул `nil`.

### Почему кнопка с URL или deeplink внутри in-app ничего не делает?

Скорее всего, в приложении не обработаны `FollowUrlEvent` или `FollowDeeplinkEvent` в `gravityEventCallback`. iOS SDK передает эти события в callback, но сам переход не выполняет.

### Почему inline-кампания не отображается автоматически?

Проверьте `selector`, соответствие [`PageContext`](#pagecontext) условиям таргетинга, активность кампании и наличие контента в response. Для `GravityInlineView` дополнительно проверьте constraints, высоту ячейки/контейнера и то, что `initialize(pageContext:)` был вызван после установки `selector`.

### Почему не получается вручную отправить content/product engagement?

Проверьте, что вы используете публичные типы `ContentEngagement` / `ProductEngagement` и передаете в них корректные `content`, `campaign` и `slot` из ответа `getContentBySelector(...)`. Для content click отдельного публичного типа в iOS SDK сейчас нет.

### Можно ли использовать `pageNumber` и `referrer` для таргетинга?

`referrer` в `PageContext` заполняется корректно. Тем не менее, если вы хотите строить на `pageNumber` или `referrer` критичный production-таргетинг, сначала проверьте end-to-end поведение на своем проекте через реальные запросы и кампании.
