Генератор предложений (интеграция Market)

Обновлено: Aug 31, 2026Раздел: Интеграции

Полная справка по API: https://api.mailoo.app/docs/v1

Генератор предложений --- функция, доступная только для Market: мастера привязаны к интеграции MARKET, поэтому они могут работать с опубликованными товарами и прайс-листами из того же каталога. Хранение предложений, SMTP и исходящая доставка --- автономны (отдельные таблицы и почтовые настройки от других типов интеграций).

Для эндпоинтов чтения каталога (типы, товары, прайс-листы) используйте ``market.external-read`` в соответствии с market-catalog-external-api{.interpreted-text role="doc"}.

Обзор

  • Панель управления: Проект → интеграция Market → вкладка Генератор предложений --- список, создание, редактирование, удаление генераторов; настройка JSON мастера, опционального ИИ и SMTP; внешние шаблоны URL для pre-form.
  • API владельца (JWT / Bearer сессии): Создание и обновление генераторов и почтовых настроек через /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators….
  • Внешний API (``X-API-Key``): Тот же префикс пути, что и у внешних маршрутов Market --- /api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/… --- с отдельными областями действия от market.external-read.
  • Области действия:
  • ``offer-generator.external-read`` --- GET …/offer-generators/{generatorId} (JSON мастера с блоками каталога, разрешёнными только для опубликованных данных).
  • ``offer-generator.submit`` --- POST …/offer-generators/{generatorId}/submissions (валидация ответов, опциональный ИИ, сохранение предложения, отправка письма).
  • Хранение: OfferGenerator, OfferGeneratorMailSettings, GeneratedOffer, OfferDelivery (Prisma). Ничего не записывается в messages или outboundMail интеграции.

Конфигурация мастера (версия 1)

Хранится как JSON в ``OfferGenerator.wizardConfig``. Последний шаг должен быть блоком ``final``.

Структура шага

  • id --- стабильный строковый идентификатор (ссылается из includeStepIds блоков ai).
  • visibleWhen (необязательно) --- одно атомарное условие или OR из AND-групп:
  • Атомарное --- объект { "var": "<name>", "equals": "<value>" } или { "var": "<name>", "oneOf": ["…"] } (строго одно из equals или oneOf).
  • Составное --- { "or": [ { "and": [ <atom>, … ] }, … ] }. Шаг виден, когда любая and-группа совпадает; внутри группы каждый атом должен совпадать (те же правила атомов). Используйте, когда несколько независимых комбинаций должны показывать один шаг.
  • block --- дискриминируется по type:
  • ``text`` --- Опциональный bodyMarkdown; fields[] с name, опциональным label, kind (text | textarea | email | phone | boolean | pinGroup), required. Для pinGroup обязателен options[] (минимум две пары value / label); одно значение сохраняется в переменную поля name. Каждое поле может задать useMarkdownBody (boolean, по умолчанию false): при true markdownBody (markdown) используется как подсказка поля вместо label; подготовленные мастера включают markdownBodyHtml для каждого поля, где markdownBody задан. Поля могут также содержать списки товаров каталога (см. offer-generator-field-product-lists{.interpreted-text role="ref"}).
  • ``choice`` --- variable; options[] с value, label, опциональной картой setVariables (клиент может объединить с отправляемыми переменными).
  • ``product`` --- productIds[]; showFields; опциональный showPrice (по умолчанию true). Внешний GET встраивает resolvedProducts. Каждая строка включает ``images``, ``previewMediaId`` и ``previewImageUrl``, если каталожный товар их определяет (тот же контракт с объединённой локалью, что и внешний API товаров Market: без locales, опциональный параметр locale в GET …/offer-generators/{id}). Для каждого ключа в showFields, являющегося каталожным атрибутом ``MD_TEXT`` на разрешённом товаре, подготовленный мастер также добавляет "<key>Html" (HTML из markdown, с нормализованными URL изображений Mailoo) рядом с исходным markdown/строковым значением.
  • ``priceList`` --- priceListId; опциональный lineSkuFilter; showFields; опциональный showPrice. Внешний GET встраивает resolvedLines.
  • ``ai`` --- promptTemplate (поддерживает плейсхолдеры {{varName}}); опциональный outputVariable (по умолчанию aiText); опциональные includeStepIds для дополнительного JSON-контекста. Требует настройки ИИ на генераторе (см. ниже).
  • ``final`` --- customerEmailVariable (должна совпадать с именем отправленной переменной); опциональные submitLabel, bodyMarkdown и fields[]. Поля final используют ту же схему и правила валидации, что и text.fields[], и отправляются в том же объекте variables.

Настройки ИИ (необязательно)

Хранятся в ``OfferGenerator.aiSettings`` как JSON openai-compatible: baseUrl, model и зашифрованный apiKeyEncrypted (тот же механизм шифрования, что и у SMTP-секретов). API никогда не возвращает ключ; GET-ответы владельца содержат только hasApiKey.

Почтовые настройки (обязательны для успешной доставки e-mail)

PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- host, port, secure, user, from, опциональный replyTo, опциональный notificationEmail (внутреннее уведомление в стиле BCC), опциональный smtpPassword (только для записи при PUT).

Если почтовые настройки отсутствуют или SMTP-отправка не удалась, отправка всё равно создаёт ``GeneratedOffer`` со статусом ``FAILED`` и диагностикой; строки ``OfferDelivery`` фиксируют попытки по каждому каналу (``CUSTOMER`` / ``NOTIFICATION``).

API владельца (Bearer)

  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators
  • POST /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators
  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • DELETE /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- возвращает { data: null } если строки ещё нет; иначе те же несекретные поля, что и после PUT.
  • PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings

Тело POST создания включает key (slug для интеграции), опциональные name, status, wizardConfig, опциональный aiSettings (apiKey --- только для записи при наличии).

Внешний API (X-API-Key)

Замените плейсхолдеры на UID проекта, id интеграции MARKET и id генератора (CUID).

  • GET {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}

    Возвращает { data: { id, key, name, status, wizard } } где wizard.steps[].block может содержать bodyHtml, markdownBodyHtml полей, resolvedProducts и resolvedLines для подготовленного рендеринга. Опциональный параметр ``locale`` выбирает язык каталога для встроенных товаров (те же правила, что и у внешнего GET …/products Market; по умолчанию из defaultCatalogLocale интеграции, иначе en).

  • POST {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/submissions

    Тело: { "variables": { … } } --- все ответы, сгруппированные по именам переменных полей / выбора. Ответ: { data: { id, status, customerEmail } } со status COMPLETED или FAILED.

Паттерн BFF / pre-form

Держите два ограниченных ключа на сервере (никогда в браузере):

  1. Ключ чтения --- offer-generator.external-read --- сервер загружает мастер один раз (или кеширует) и рендерит UI.
  2. Ключ отправки --- offer-generator.submit --- сервер отправляет итоговый JSON variables после валидации на вашей стороне.

Запросы каталога от клиента по-прежнему проходят через ваш BFF с ``market.external-read``, если вам нужны актуальные данные товаров за пределами встроенного снимка resolvedProducts.

Списки товаров в полях {#offer-generator-field-product-lists}

Текстовые поля (text.fields[] и final.fields[]) могут показывать рекомендации товаров рядом с полем ввода. Это позволяет интегратору построить насыщенную pre-form без индивидуальных запросов к каталогу для каждого поля.

Для не-boolean полей прикрепите products:

{
  "name": "useCase",
  "label": "What do you need?",
  "kind": "textarea",
  "required": true,
  "products": {
    "productIds": ["product_cuid_1", "product_cuid_2"],
    "showFields": ["name", "sku"],
    "showPrice": true,
    "priceKindId": "price_kind_cuid"
  }
}

Для boolean полей используйте отдельные списки для каждого состояния:

{
  "name": "installation",
  "label": "Include installation?",
  "kind": "boolean",
  "required": false,
  "productsWhenTrue": {
    "productIds": ["installation_service_id"],
    "showFields": ["name", "sku"],
    "showPrice": true,
    "priceKindId": "price_kind_cuid"
  }
}

При внешнем GET Mailoo подготавливает эти списки с resolvedProducts только для опубликованных товаров. Рендерите из resolvedProducts когда он присутствует, и обрабатывайте случай пустого списка (снят с публикации, удалён, отфильтрован или нет текущей цены).

Паттерны интерфейса интеграции

Предпросмотр мастера в панели управления Mailoo намеренно близок к тому, что может реализовать внешний сайт. Рекомендуемая клиентская структура:

  • Загрузите подготовленный мастер с сервера/BFF с offer-generator.external-read и передайте в браузер только JSON мастера.
  • Храните единственный объект variables в состоянии браузера. Каждое поле, выбор, группа пинов и вывод ИИ записывает в этот объект по имени переменной.
  • Пересчитывайте видимые шаги из visibleWhen при каждом изменении variables.
  • Рендерите подготовленные bodyHtml / markdownBodyHtml когда доступны; иначе рендерите обычный markdown как фоллбэк.
  • Рендерите resolvedProducts / resolvedLines из подготовленного мастера, не раскрывая API-ключи в браузере.
  • При отправке валидируйте видимые шаги локально для UX, затем отправьте { "variables": variables } с сервера/BFF с ключом offer-generator.submit.

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

type Vars = Record<string, string | number | boolean | null>

function asString(value: unknown): string | undefined {
  if (value === null || value === undefined) return undefined
  if (typeof value === 'string') return value
  if (typeof value === 'number' || typeof value === 'boolean') {
    return String(value)
  }
  return undefined
}

function atomMatches(
  atom: { var: string; equals?: string; oneOf?: string[] },
  vars: Vars
): boolean {
  const current = asString(vars[atom.var])
  if (atom.equals !== undefined) {
    return current === asString(atom.equals)
  }
  if (Array.isArray(atom.oneOf)) {
    return current !== undefined && atom.oneOf.includes(current)
  }
  return false
}

function isVisible(step: { visibleWhen?: any }, vars: Vars): boolean {
  const when = step.visibleWhen
  if (!when) return true
  if (Array.isArray(when.or) && when.or.length > 0) {
    return when.or.some(
      (group: { and?: unknown }) =>
        Array.isArray(group.and) &&
        group.and.length > 0 &&
        group.and.every((atom: any) => atomMatches(atom, vars))
    )
  }
  return atomMatches(when, vars)
}

const visibleSteps = wizard.steps.filter((step) => isVisible(step, vars))

Блоки выбора должны обновлять одновременно основную variable и опциональные setVariables:

function chooseOption(block: any, option: any) {
  setVars((prev) => ({
    ...prev,
    [block.variable]: option.value,
    ...(option.setVariables ?? {}),
  }))
}

Текстовые и финальные поля имеют общие правила рендеринга:

function renderField(field: any) {
  const name = field.name
  const value = String(vars[name] ?? '')

  if (field.kind === 'boolean') {
    return (
      <input
        type="checkbox"
        checked={value.toLowerCase() === 'true'}
        onChange={(e) => setVar(name, e.target.checked ? 'true' : 'false')}
      />
    )
  }

  if (field.kind === 'pinGroup') {
    return field.options.map((option: any) => (
      <button type="button" onClick={() => setVar(name, option.value)}>
        {option.label}
      </button>
    ))
  }

  return (
    <input
      type={field.kind === 'email' ? 'email' : field.kind === 'phone' ? 'tel' : 'text'}
      value={value}
      onChange={(e) => setVar(name, e.target.value)}
    />
  )
}

Для рекомендаций товаров, прикреплённых к полям, используйте подготовленный список:

function fieldProductsFor(field: any) {
  if (field.kind === 'boolean') {
    const checked = String(vars[field.name] ?? '').toLowerCase() === 'true'
    return checked
      ? field.productsWhenTrue?.resolvedProducts ?? []
      : field.productsWhenFalse?.resolvedProducts ?? []
  }
  return field.products?.resolvedProducts ?? []
}

Паттерн отправки:

async function submitOffer() {
  // Browser posts to your own server route. The server route adds X-API-Key.
  const res = await fetch('/api/offer-generator/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ variables: vars }),
  })
  if (!res.ok) throw new Error('Offer submission failed')
  return res.json()
}

Безопасность и рендеринг

  • Никогда не раскрывайте ключи offer-generator.external-read или offer-generator.submit в браузере.
  • Обрабатывайте подготовленные bodyHtml и markdownBodyHtml как HTML, сгенерированный серверным конвейером Mailoo для принадлежащего вам мастера. Не пропускайте произвольный пользовательский ввод через dangerouslySetInnerHTML.
  • Локальная валидация в браузере --- только для удобства UX. API повторно валидирует отправленные переменные, включая видимые поля text и final, значения выбора, форматы телефона/e-mail и customerEmailVariable.
  • Если UI позволяет посетителям возвращаться и менять ранние ответы, удалите или пересчитайте переменные скрытых шагов перед отправкой, или полагайтесь на серверную валидацию видимых шагов для отклонения противоречивых данных.

Связанные материалы

  • market-catalog-external-api{.interpreted-text role="doc"} --- чтение товаров и прайс-листов.
  • market-catalog-csv{.interpreted-text role="doc"} --- массовое редактирование каталога в панели управления.
  • outbound-smtp{.interpreted-text role="doc"} --- не используется для почты генератора предложений (генератор использует собственную таблицу SMTP).