Generador de ofertas (integración Market)

Última actualización: Aug 31, 2026Sección: Integraciones

Referencia completa de la API: https://api.mailoo.app/docs/v1

El generador de ofertas es una funcionalidad exclusiva de Market: los asistentes están asociados a una integración MARKET para que puedan resolver productos publicados y listas de precios del mismo catálogo. El almacenamiento de ofertas, SMTP y entrega saliente son autocontenidos (tablas y configuración de correo separadas de otros tipos de integración).

Para los endpoints de lectura de catálogo (tipos, productos, listas de precios), sigue usando ``market.external-read`` como se documenta en market-catalog-external-api{.interpreted-text role="doc"}.

Resumen

  • Panel de control: Proyecto → integración Market → pestaña Generador de ofertas --- listar, crear, editar, eliminar generadores; configurar JSON del asistente, IA opcional y SMTP; patrones de URL externa para pre-form.
  • API del propietario (JWT / session Bearer): Crear y actualizar generadores y configuración de correo bajo /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators….
  • API externa (``X-API-Key``): Mismo prefijo de ruta que las rutas externas de Market --- /api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/… --- con alcances separados de market.external-read.
  • Alcances:
  • ``offer-generator.external-read`` --- GET …/offer-generators/{generatorId} (JSON del asistente con bloques de catálogo resueltos solo para datos publicados).
  • ``offer-generator.submit`` --- POST …/offer-generators/{generatorId}/submissions (valida respuestas, IA opcional, persiste oferta, envía correo).
  • Persistencia: OfferGenerator, OfferGeneratorMailSettings, GeneratedOffer, OfferDelivery (Prisma). Nada se escribe en messages ni en outboundMail de la integración.

Configuración del asistente (versión 1)

Se almacena como JSON en ``OfferGenerator.wizardConfig``. El último paso debe ser un bloque ``final``.

Estructura del paso

  • id --- id string estable (referenciado por includeStepIds de bloques ai).
  • visibleWhen (opcional) --- ya sea una condición atómica simple o un OR de grupos AND:
  • Atómica --- objeto { "var": "<name>", "equals": "<value>" } o { "var": "<name>", "oneOf": ["…"] } (exactamente uno de equals o oneOf).
  • Compuesta --- { "or": [ { "and": [ <atom>, … ] }, … ] }. El paso es visible cuando cualquier array and coincide; dentro de un grupo cada átomo debe coincidir (mismas reglas de átomo que arriba). Úsalo cuando varias combinaciones independientes deban mostrar el mismo paso.
  • block --- discriminado por type:
  • ``text`` --- bodyMarkdown opcional; fields[] con name, label opcional, kind (text | textarea | email | phone | boolean | pinGroup), required. Para pinGroup, options[] (al menos dos pares value / label) es obligatorio; un valor se almacena en la variable name del campo. Cada campo puede establecer useMarkdownBody (booleano, predeterminado false): cuando es true, markdownBody (markdown) es el prompt del campo en lugar de label; los asistentes preparados incluyen markdownBodyHtml por campo cuando markdownBody está establecido. Los campos también pueden adjuntar listas de productos del catálogo (ver offer-generator-field-product-lists{.interpreted-text role="ref"}).
  • ``choice`` --- variable; options[] con value, label, setVariables map opcional (el cliente puede fusionar en las variables enviadas).
  • ``product`` --- productIds[]; showFields; showPrice opcional (predeterminado true). El GET externo embebe resolvedProducts. Cada fila incluye ``images``, ``previewMediaId`` y ``previewImageUrl`` cuando el producto del catálogo los define (mismo contrato de idioma fusionado que la API externa de producto de Market: sin locales, consulta opcional locale en GET …/offer-generators/{id}). Para cada clave en showFields que sea un atributo ``MD_TEXT`` del catálogo en el producto resuelto, el asistente preparado también añade "<key>Html" (HTML desde markdown, con URLs de imágenes de Mailoo normalizadas) junto al valor markdown/string sin procesar.
  • ``priceList`` --- priceListId; lineSkuFilter opcional; showFields; showPrice opcional. El GET externo embebe resolvedLines.
  • ``ai`` --- promptTemplate (soporta marcadores {{varName}}); outputVariable opcional (predeterminado aiText); includeStepIds opcional para contexto JSON adicional. Requiere configuración de IA en el generador (ver abajo).
  • ``final`` --- customerEmailVariable (debe coincidir con un nombre de variable enviado); submitLabel opcional, bodyMarkdown y fields[]. Los campos finales usan el mismo esquema y reglas de validación que text.fields[] y se envían en el mismo objeto variables.

Configuración de IA (opcional)

Se almacena en ``OfferGenerator.aiSettings`` como JSON openai-compatible: baseUrl, model y apiKeyEncrypted cifrado (mismo mecanismo de cifrado que los secretos SMTP). La API nunca devuelve la clave; las respuestas GET del propietario exponen solo hasApiKey.

Configuración de correo (obligatoria para entrega exitosa de correo)

PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- host, port, secure, user, from, replyTo opcional, notificationEmail opcional (alerta interna tipo BCC), smtpPassword opcional (solo escritura en PUT).

Si la configuración de correo falta o el envío SMTP falla, el envío sigue creando un ``GeneratedOffer`` con estado ``FAILED`` y diagnósticos; las filas de ``OfferDelivery`` registran intentos por canal (``CUSTOMER`` / ``NOTIFICATION``).

API del propietario (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 --- devuelve { data: null } cuando aún no existe fila; de lo contrario los mismos campos no secretos que después del PUT.
  • PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings

El cuerpo de creación POST incluye key (slug por integración), name opcional, status, wizardConfig, aiSettings opcional (apiKey es solo escritura cuando está presente).

API externa (X-API-Key)

Reemplaza los marcadores con tu UID de proyecto, id de integración MARKET e id del generador (CUID).

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

    Devuelve { data: { id, key, name, status, wizard } } donde wizard.steps[].block puede incluir bodyHtml, markdownBodyHtml del campo, resolvedProducts y resolvedLines para renderizado preparado. Consulta opcional ``locale`` selecciona el idioma del catálogo para productos embebidos (mismas reglas que el GET externo de Market GET …/products; predeterminado de la integración defaultCatalogLocale, sino en).

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

    Cuerpo: { "variables": { … } } --- todas las respuestas indexadas por nombres de variable de campo / elección. Respuesta: { data: { id, status, customerEmail } } con status COMPLETED o FAILED.

Patrón BFF / pre-form

Mantén dos claves restringidas en el servidor (nunca en el navegador):

  1. Clave de lectura --- offer-generator.external-read --- el servidor carga el asistente una vez (o lo cachea) y renderiza la UI.
  2. Clave de envío --- offer-generator.submit --- el servidor envía el JSON de variables final después de validar de tu lado.

Las búsquedas de catálogo desde el cliente siguen pasando por tu BFF usando ``market.external-read`` si necesitas datos de producto en vivo fuera del snapshot resolvedProducts embebido.

Listas de productos en campos {#offer-generator-field-product-lists}

Los campos tipo texto (text.fields[] y final.fields[]) pueden mostrar recomendaciones de productos junto a la entrada. Esto permite a un integrador construir un pre-form enriquecido sin añadir llamadas de catálogo personalizadas para cada campo.

Para campos no booleanos, adjunta 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"
  }
}

Para campos booleanos, usa listas separadas para cada estado:

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

En el GET externo, Mailoo prepara estas listas con resolvedProducts solo para productos publicados. Renderiza desde resolvedProducts cuando esté presente, y maneja con gracia cuando está vacío (no publicado, eliminado, filtrado o sin precio actual).

Patrones de UI de integración

La previsualización del panel de Mailoo es intencionalmente cercana a lo que un sitio externo puede implementar. Estructura recomendada para el cliente:

  • Carga el asistente preparado desde tu servidor/BFF con offer-generator.external-read y pasa solo el JSON del asistente al navegador.
  • Mantén un único objeto variables en el estado del navegador. Cada campo, elección, grupo de pins y salida de previsualización de IA escriben en ese objeto por nombre de variable.
  • Recalcula los pasos visibles desde visibleWhen cada vez que variables cambie.
  • Renderiza bodyHtml / markdownBodyHtml preparados cuando estén presentes; de lo contrario renderiza el texto markdown plano como respaldo.
  • Renderiza resolvedProducts / resolvedLines del asistente preparado, no exponiendo claves API en el navegador.
  • Al enviar, valida los pasos visibles localmente para UX, luego envía { "variables": variables } desde tu servidor/BFF usando la clave offer-generator.submit.

Helper de visibilidad mínimo:

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))

Los bloques de elección deben actualizar tanto su variable principal como los setVariables opcionales en un solo cambio de estado:

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

Los campos de texto y finales comparten reglas de renderizado:

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)}
    />
  )
}

Para recomendaciones de productos adjuntas a campos, usa la lista preparada:

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 ?? []
}

Patrón de envío:

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()
}

Notas de seguridad y renderizado

  • Nunca expongas claves offer-generator.external-read ni offer-generator.submit al navegador.
  • Trata bodyHtml y markdownBodyHtml preparados como HTML generado por la pipeline del servidor de Mailoo para el asistente que posees. No pases entrada arbitraria de visitantes a través de dangerouslySetInnerHTML.
  • Mantén la validación local del navegador solo como conveniencia. La API valida las variables enviadas de nuevo, incluyendo campos text y final visibles, valores de elección, formatos de teléfono/correo y customerEmailVariable.
  • Si la UI permite a los visitantes retroceder y cambiar respuestas anteriores, elimina o recalcula las variables de los pasos ocultos posteriores antes de enviar, o confía en la validación de pasos visibles del servidor para rechazar payloads inconsistentes.

Relacionado

  • market-catalog-external-api{.interpreted-text role="doc"} --- lecturas de productos y listas de precios.
  • market-catalog-csv{.interpreted-text role="doc"} --- ediciones masivas de catálogo en el panel.
  • outbound-smtp{.interpreted-text role="doc"} --- no se usa para correo del generador de ofertas (el generador de ofertas usa su propia tabla SMTP).