> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mindosoftware.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Widget web en Magento

> Integrar el widget de chat de MINDO en Magento 2: instalación sin código y identificación de clientes logueados sorteando el Full Page Cache.

Esta guía es específica de **Magento 2**. Para entender el widget en general —qué es,
cómo se crea el canal, qué hace la identidad firmada— empezá por la guía base:

<Card title="Widget de chat web" icon="comments" href="/guias/widget-web">
  El canal Web de punta a punta: snippet, identidad JWT y personalización.
</Card>

## Los dos niveles de integración

Magento admite las dos formas, y conviene decidir cuál necesita el proyecto **antes**
de escribir una línea de código:

|                            | Qué se logra                                                                                           | Qué cuesta                                           |
| -------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| **Nivel 1 — anónimo**      | El chat funciona, entra al Inbox, el agente responde                                                   | Pegar el snippet en el admin. Sin código, sin deploy |
| **Nivel 2 — identificado** | El chat sabe qué cliente es, unifica sus conversaciones entre dispositivos y las junta con su WhatsApp | Un módulo chico de Magento                           |

<Note>
  Si el objetivo es sólo atender consultas, **el Nivel 1 alcanza**. El Nivel 2 se
  justifica cuando querés que el chat del sitio y el de WhatsApp sean la misma
  persona, o que un cliente retome su conversación desde otro dispositivo.
</Note>

## Nivel 1 — Instalación sin código

<Steps>
  <Step title="Crear el canal Web en MINDO">
    En MINDO, **Configuración → Canales → Widget de chat web → + Agregar canal Web**.
    Copiá el snippet que te muestra.

    <Note>
      Si el Magento tiene **varios store views** (por marca, país o idioma), creá **un
      canal por store view**. Cada uno tiene su color, su agente y su bandeja, y las
      conversaciones no se mezclan.
    </Note>
  </Step>

  <Step title="Pegarlo en el admin de Magento">
    En el admin: **Content → Design → Configuration**. Editá la fila del store view
    donde querés el chat (no el default, si vas a diferenciar por store view) y abrí
    **HTML Head → Scripts and Style Sheets**.

    ```html theme={null}
    <script
      src="https://app.mindosoftware.com/widget.js"
      data-mindo-token="TU_TOKEN_DE_CANAL"
      async
    ></script>
    ```

    Si preferís que cargue al final del documento, usá **Footer → Miscellaneous HTML**
    en lugar de HTML Head. Da lo mismo para el widget: el script es `async` y
    autocontenido.
  </Step>

  <Step title="Limpiar caché y verificar">
    ```bash theme={null}
    bin/magento cache:flush
    ```

    Entrá al sitio y mirá la burbuja abajo a la derecha. Si no aparece, abrí la consola
    del navegador: el error más común es un `data-mindo-token` mal copiado.
  </Step>
</Steps>

Con esto ya está: cada visitante que escriba crea un chat con canal **Web** en el Inbox.

## Nivel 2 — Identificar clientes logueados

Acá es donde Magento tiene una particularidad que **hay que respetar**, y es la razón
por la que este nivel necesita un módulo.

### Por qué no se puede hacer de la forma simple

La guía base explica que la identidad viaja en un atributo del `<script>`:

```html theme={null}
data-mindo-identity="{{ identityToken }}"
```

<Warning>
  **En Magento eso está mal y es peligroso.** Magento sirve el catálogo y las páginas
  CMS desde **Full Page Cache / Varnish**: el mismo HTML para todos los visitantes. Si
  renderizás el token en un `.phtml`, se cachea el JWT del primer cliente que pasó y se
  le sirve a todos los siguientes.

  El modo de falla es silencioso: la firma es válida —la generó tu servidor, con el
  secreto correcto—, así que MINDO no tiene forma de detectarlo. Simplemente trata a
  todos los visitantes como el mismo cliente y mezcla las conversaciones en un solo
  contacto. Sin un error en los logs.
</Warning>

Es el mismo motivo por el que Magento no renderiza "Bienvenido, Ana" directo en el
HTML. Y la solución es la misma que ya usa Magento para eso.

### La solución: private content (customer data sections)

Magento tiene un mecanismo pensado exactamente para esto: datos por cliente sobre
páginas cacheadas. El HTML se cachea igual para todos; los datos personales llegan
después, por una petición aparte que **nunca** se cachea.

```
PHP   Mindo\ChatWidget\CustomerData\MindoIdentity
      lee la sesión del cliente y firma el JWT
        ↓
      Magento la expone en /customer/section/load/   (sin caché)
        ↓
JS    lee la section y llama a window.Mindo.setUser(token)
```

### El módulo, archivo por archivo

Todo vive en `app/code/Mindo/ChatWidget/`.

<Steps>
  <Step title="Guardar el secreto del canal">
    El **Secret HMAC** del canal es un secreto de servidor. Nunca en el theme, nunca en
    un `.phtml`, nunca en un repositorio.

    La forma limpia es una config encriptada. Declarala en `etc/adminhtml/system.xml`
    con el backend model de Magento para valores encriptados:

    ```xml theme={null}
    <field id="hmac_secret" translate="label" type="obscure" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
        <label>Secret HMAC del canal</label>
        <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
    </field>
    ```

    Y cargalo por CLI, sin que quede en la base en claro:

    ```bash theme={null}
    bin/magento config:set --lock-env mindo/chat_widget/hmac_secret "EL_SECRET_DEL_CANAL"
    ```

    <Note>
      Con `--lock-env` el valor queda en `app/etc/env.php`, que normalmente está fuera
      del repositorio. Es la opción más simple si no querés armar el `system.xml`.
    </Note>
  </Step>

  <Step title="Instalar la librería de JWT">
    ```bash theme={null}
    composer require firebase/php-jwt
    ```

    MINDO espera **HS256**, que es lo que esta librería usa por defecto con un secreto
    de texto.
  </Step>

  <Step title="La clase que firma">
    `app/code/Mindo/ChatWidget/CustomerData/MindoIdentity.php`

    ```php theme={null}
    <?php
    namespace Mindo\ChatWidget\CustomerData;

    use Firebase\JWT\JWT;
    use Magento\Customer\CustomerData\SectionSourceInterface;
    use Magento\Customer\Model\Session as CustomerSession;
    use Magento\Framework\App\Config\ScopeConfigInterface;
    use Magento\Framework\Encryption\EncryptorInterface;
    use Magento\Store\Model\ScopeInterface;

    class MindoIdentity implements SectionSourceInterface
    {
        /** Vida del token. MINDO rechaza cualquier `exp` a más de 7 días. */
        private const TTL_SECONDS = 86400;

        private const XML_PATH_SECRET = 'mindo/chat_widget/hmac_secret';

        public function __construct(
            private CustomerSession $customerSession,
            private ScopeConfigInterface $scopeConfig,
            private EncryptorInterface $encryptor
        ) {
        }

        public function getSectionData(): array
        {
            if (!$this->customerSession->isLoggedIn()) {
                return ['identity' => null];
            }

            $secret = $this->getSecret();
            if ($secret === '') {
                return ['identity' => null];
            }

            $customer = $this->customerSession->getCustomer();

            $payload = array_filter([
                'sub'   => (string) $customer->getId(),
                'name'  => trim((string) $customer->getName()),
                'email' => (string) $customer->getEmail(),
                'phone' => $this->resolvePhone($customer),
                'exp'   => time() + self::TTL_SECONDS,
            ], static fn ($value) => $value !== '' && $value !== null);

            return ['identity' => JWT::encode($payload, $secret, 'HS256')];
        }

        private function getSecret(): string
        {
            $raw = (string) $this->scopeConfig->getValue(
                self::XML_PATH_SECRET,
                ScopeInterface::SCOPE_STORE
            );

            return $raw === '' ? '' : (string) $this->encryptor->decrypt($raw);
        }

        /**
         * Magento no guarda el teléfono en el cliente: vive en su dirección de
         * facturación por defecto.
         */
        private function resolvePhone($customer): string
        {
            $address = $customer->getDefaultBillingAddress();

            return $address ? (string) $address->getTelephone() : '';
        }
    }
    ```

    <Warning>
      **El teléfono tiene que estar en formato internacional** (`+5491122334455`). Es el
      campo que une este contacto con el de WhatsApp, y MINDO lo descarta si no logra
      normalizarlo. Magento guarda el `telephone` tal como lo tipeó el cliente, así que
      si el checkout no lo valida, conviene normalizarlo acá antes de firmarlo.
    </Warning>
  </Step>

  <Step title="Registrar la section">
    `app/code/Mindo/ChatWidget/etc/frontend/di.xml` — le dice a Magento qué clase
    responde por esta section:

    ```xml theme={null}
    <type name="Magento\Customer\CustomerData\SectionPoolInterface">
        <arguments>
            <argument name="sectionSourceMap" xsi:type="array">
                <item name="mindo-identity" xsi:type="string">Mindo\ChatWidget\CustomerData\MindoIdentity</item>
            </argument>
        </arguments>
    </type>
    ```

    `app/code/Mindo/ChatWidget/etc/frontend/sections.xml` — cuándo se invalida:

    ```xml theme={null}
    <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd">
        <action name="customer/account/loginPost">
            <section name="mindo-identity"/>
        </action>
        <action name="customer/account/logout">
            <section name="mindo-identity"/>
        </action>
        <action name="customer/account/createPost">
            <section name="mindo-identity"/>
        </action>
    </config>
    ```

    <Note>
      Sin el `sections.xml` la section queda cacheada del lado del cliente y el token no
      se refresca al iniciar o cerrar sesión: el chat seguiría mostrando al cliente
      anterior hasta que venza el token.
    </Note>
  </Step>

  <Step title="El JavaScript que se lo pasa al widget">
    `app/code/Mindo/ChatWidget/view/frontend/web/js/identity.js`

    ```javascript theme={null}
    define(['Magento_Customer/js/customer-data'], function (customerData) {
        'use strict';

        /**
         * OJO con dos trampas del loader de MINDO:
         *
         * 1. `window.Mindo.q` se drena UNA sola vez, cuando widget.js termina de
         *    cargar. Pushear a la cola después de eso no hace nada. Por eso hay que
         *    preguntar si la API ya existe antes de elegir el camino.
         *
         * 2. Escribir localStorage NO sirve para avisarle al widget de ESTA pestaña:
         *    el evento `storage` sólo llega a las otras. Sirve para la próxima carga y
         *    para el login en otra pestaña, no para el "acabo de loguearme acá".
         */
        function apply(token) {
            window.Mindo = window.Mindo || { q: [] };

            if (typeof window.Mindo.setUser === 'function') {
                token ? window.Mindo.setUser(token) : window.Mindo.clearUser();
                return;
            }

            window.Mindo.q = window.Mindo.q || [];
            window.Mindo.q.push(token ? ['setUser', token] : ['clearUser']);
        }

        return function () {
            var section = customerData.get('mindo-identity');

            // Valor actual (la section puede venir del storage del navegador).
            var current = section();
            if (current) {
                apply(current.identity || null);
            }

            // Y cada refresco posterior: login, logout, alta de cuenta.
            section.subscribe(function (data) {
                apply((data && data.identity) || null);
            });
        };
    });
    ```

    Y el template que lo engancha, `view/frontend/templates/identity.phtml`:

    ```html theme={null}
    <script type="text/x-magento-init">
    {
        "*": {
            "Mindo_ChatWidget/js/identity": {}
        }
    }
    </script>
    ```

    declarado en `view/frontend/layout/default.xml`:

    ```xml theme={null}
    <referenceContainer name="before.body.end">
        <block class="Magento\Framework\View\Element\Template"
               name="mindo.chat.identity"
               template="Mindo_ChatWidget::identity.phtml"/>
    </referenceContainer>
    ```
  </Step>

  <Step title="Habilitar y verificar">
    ```bash theme={null}
    bin/magento module:enable Mindo_ChatWidget
    bin/magento setup:upgrade
    bin/magento setup:di:compile
    bin/magento cache:flush
    ```

    Para verificar, con un cliente logueado:

    1. En la consola del navegador, `window.Mindo` tiene que existir.
    2. En la pestaña Network, buscá `/customer/section/load/`: la respuesta debe traer
       `mindo-identity` con un `identity` que empiece en `eyJ`.
    3. En MINDO, escribí un mensaje desde el chat: el contacto tiene que aparecer con
       nombre real, no como "Visitante ####".
  </Step>
</Steps>

<Warning>
  Si hiciste el Nivel 1 **y** después el módulo, sacá el snippet del admin o hacé que el
  módulo no lo renderice. Con los dos activos se cargan **dos widgets** y aparecen dos
  burbujas.
</Warning>

## Preguntas frecuentes

<AccordionGroup>
  <Accordion title="¿Y si el sitio usa Hyvä en vez de Luma?">
    **Todo el lado PHP es idéntico**: la clase que firma, el `di.xml` y el `sections.xml`
    no cambian, porque las sections son un mecanismo del backend de Magento, no del
    theme.

    Lo que cambia es el JavaScript: Hyvä no usa Knockout ni el módulo
    `Magento_Customer/js/customer-data`, así que la lectura de la section se hace con su
    propio helper sobre Alpine. La lógica es la misma —leer `mindo-identity` y llamar a
    `window.Mindo.setUser()`— pero confirmá la API contra la versión de Hyvä del
    proyecto antes de escribirlo.

    Como alternativa independiente del theme, Magento dispara el evento
    `private-content-loaded` en el documento cuando las sections se actualizan; podés
    engancharte ahí y leer el valor del storage del navegador.
  </Accordion>

  <Accordion title="¿Funciona en Magento 1?">
    El **Nivel 1 sí**, igual que en cualquier sitio: es un `<script>` en el layout.

    El Nivel 2 también es posible, pero no hay sections: habría que exponer un endpoint
    propio que devuelva el JWT del cliente logueado y llamarlo desde el front. Tené en
    cuenta que Magento 1 está fuera de soporte desde 2020.
  </Accordion>

  <Accordion title="¿Por qué no firmo el token directamente en el .phtml?">
    Porque lo cachea el Full Page Cache y termina sirviéndole el token de un cliente a
    todos los demás. Está explicado arriba, en "Por qué no se puede hacer de la forma
    simple". Es el único punto de esta guía que, si se ignora, produce un problema de
    privacidad real y silencioso.
  </Accordion>

  <Accordion title="¿Qué pasa cuando el cliente cierra sesión?">
    El `sections.xml` invalida `mindo-identity` en el logout, la section vuelve con
    `identity: null` y el JS llama a `window.Mindo.clearUser()`. El visitante pasa a
    anónimo y el chat sigue funcionando.
  </Accordion>

  <Accordion title="¿Hace falta que el módulo renderice también el snippet?">
    No es obligatorio. Podés dejar el snippet cargado desde el admin (Nivel 1) y que el
    módulo se ocupe únicamente de la identidad. Es incluso más cómodo: quien administra
    la tienda puede cambiar de canal sin tocar código.
  </Accordion>

  <Accordion title="¿Cuánto dura el token y hay que renovarlo?">
    En el ejemplo dura 24 horas, y se emite de nuevo en cada refresco de la section. No
    hay que renovarlo a mano.

    El único límite duro es que **MINDO rechaza cualquier `exp` a más de 7 días**: un
    token con vencimiento más lejano se considera inválido y el visitante queda anónimo,
    sin ningún error visible.
  </Accordion>

  <Accordion title="Configuré todo y el contacto sigue apareciendo como Visitante">
    La firma falla en silencio a propósito —un widget caído es peor que un dato
    faltante—. Revisá en este orden:

    1. Que el secreto sea el del **mismo canal** que el token del snippet.
    2. Que `/customer/section/load/` devuelva `identity` con un valor, y no `null`.
    3. Que el `exp` esté en **segundos** y a menos de 7 días.
    4. Que el claim se llame `sub` y el algoritmo sea **HS256**.
  </Accordion>
</AccordionGroup>
