/* hero-v7-full.jsx — PORT NATIVO del template V7 completo
   Extraído tal cual de evidence/VER-PRINCIPAL-WHATSAPP-GMAIL-PRODUCT-V7/index.html
   (sin modificar funcionalidad). Wrapped en IIFE para no chocar con
   los `const { useState, useEffect } = React;` de app.jsx y sections.jsx.
   El componente App original se renombró a HeroDemoV7Full y se expone
   como window.HeroDemoV7Full para que sections.jsx lo monte dentro del
   hero post-loader (envuelto en un div.ng-stage que respeta el scope CSS).
*/
(function () {
const { useEffect, useRef, useState, useMemo, useCallback } = React;

/* ════════════════════════════════════════════════════════════════════
   TIMELINES V4 — 10 flujos reales documentados por Codex
   - Inmobiliaria (4): audio_principal · seguimiento_link · buceo_desambiguacion · compartir_hermes
   - Club (6): inscripcion · socio_horario · reaviso · ingles_premium · portugues_premium · solicita_humano
   Event kinds:
     user_msg, bot_msg, bot_typing, user_audio, user_image, user_pdf,
     user_link, bot_link, capability, date_divider, time_jump,
     follow_up, follow_up_update, email_notice, email,
     human_msg, bot_silent
   Outputs:
     'email' · 'follow_up' · 'follow_up_then_email' · 'email_then_human'
   ════════════════════════════════════════════════════════════════════ */

const CASES_V4 = {

  inmobiliaria: {
    label: 'Inmobiliaria',
    /* V3 polish ADDENDUM 41 (2026-05-19) — Nuevo primer caso:
       "Link de propiedad · visita". El usuario manda el link
       www.tuinmobiliaria.com/4545, el bot lo matchea con su CRM,
       confirma disponibilidad, precio y garantías ANDA/CGN,
       direcciona a visita y captura preferencia "sábado de tarde".
       Genera email al equipo con propiedad + estado + precio +
       garantía CGN + preferencia + próximo paso. */
    defaultFlow: 'propiedad_link',
    flows: {

      /* ─── 1. LINK DE PROPIEDAD · VISITA (nuevo primer caso) ─── */
      propiedad_link: {
        name: 'Link de propiedad · visita',
        sub: 'usuario manda link · bot matchea propiedad · informa precio y garantías · direcciona a visita · captura preferencia',
        chat: { name: 'Galaxia Propiedades', sub: 'en línea', initials: 'GP' },
        duration: 21500,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Vi esta propiedad en www.tuinmobiliaria.com/4545 me interesa', tm:'15:08', linkify:true },
          { at:  1400, row:'r1', k:'capability', icon:'✦', tone:'cyan', slot:'entendio', label:'Detectó link',
            text:'Detectó link de propiedad en la página y va a obtener la info en tu CRM' },
          { at:  2400, row:'r1', k:'capability', icon:'✦', tone:'cyan', label:'Matcheó propiedad',
            text:'Matcheó el link con la propiedad 4545 en tu CRM' },
          { at:  3700, row:'r3', k:'bot_typing' },
          { at:  4900, row:'r3', k:'bot_msg', text:'Genial, esa la tengo disponible. $27.000 + $2.500 G.C. Acepta ANDA y CGN. ¿Querés coordinar una visita?', tm:'15:08' },
          { at:  5700, row:'r3', k:'capability', icon:'✦', tone:'cyan', slot:'consulto', label:'Verificó disponible',
            text:'Verificó disponibilidad en tu CRM y respondió con datos al cliente' },
          { at:  6500, row:'r3', k:'capability', icon:'✦', tone:'cyan', slot:'detecto', label:'Informó precio y garantías',
            text:'Informó precio, gastos comunes y garantías aceptadas (ANDA / CGN)' },
          { at:  7700, row:'r4', k:'user_msg', text:'Sí, sería genial ir a verla. Tengo CGN!', tm:'15:09' },
          { at:  8600, row:'r4', k:'capability', icon:'✦', tone:'cyan', slot:'pidio', label:'Direccionó visita',
            text:'Direccionó la conversación a coordinar visita y registró que el cliente tiene CGN' },
          { at:  9800, row:'r5', k:'bot_typing' },
          { at: 11000, row:'r5', k:'bot_msg', text:'Buenísimo. ¿Qué día y hora te queda mejor?', tm:'15:09' },
          { at: 11800, row:'r5', k:'capability', icon:'✦', tone:'cyan', label:'Pidió preferencia',
            text:'Pidió día y horario de preferencia para coordinar la visita' },
          { at: 13100, row:'r6', k:'user_msg', text:'Sábado de tarde', tm:'15:10' },
          { at: 14000, row:'r6', k:'capability', icon:'✦', tone:'cyan', label:'Extrajo preferencia',
            text:'Extrajo preferencia de visita: sábado de tarde' },
          { at: 15300, row:'r7', k:'bot_msg', text:'Perfecto, reviso la agenda y nos comunicamos para definir día y hora. Cualquier otra duda estoy a la orden.', tm:'15:10' },
          { at: 16100, row:'r7', k:'capability', icon:'⚡', tone:'violet', slot:'aviso', label:'Generó aviso interno',
            text:'Cierra con expectativa clara y genera aviso interno al equipo con los datos del lead' },
          { at: 17400, k:'email_notice', label:'Nuevo aviso interno · coordinar visita 4545' },
          { at: 19400, k:'email' },
        ],
        summary: [
          'Detectó link de propiedad',
          'Matcheó con CRM',
          'Verificó disponible',
          'Informó precio y garantías',
          'Direccionó visita',
          'Extrajo preferencia sábado de tarde',
          'Generó aviso interno',
        ],
        milestoneKeys: ['entendio', 'consulto', 'detecto', 'pidio', 'aviso'],
        whatItDid: 'Detectó el link, matcheó la propiedad con el CRM, informó precio y garantías ANDA/CGN, direccionó la visita y capturó la preferencia del cliente para que el equipo solo cierre día y hora.',
        potential: 'Cada lead que llega con link entra ya validado contra el CRM: precio, garantía aceptada, disponibilidad y preferencia de visita listos para que el equipo confirme.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · inmobiliaria',
          labels: [{ k:'LEAD CON VISITA', tone:'ok' }, { k:'CGN', tone:'default' }],
          subject: 'Nueva visita solicitada · propiedad 4545',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Lead interesado en la propiedad www.tuinmobiliaria.com/4545. La propiedad está disponible, precio $27.000 + $2.500 G.C. El usuario indicó que tiene CGN y prefiere coordinar visita el sábado de tarde.',
          fields: [
            ['Propiedad', 'www.tuinmobiliaria.com/4545'],
            ['Estado', 'Disponible'],
            ['Precio', '$27.000 + $2.500 G.C.'],
            ['Garantía', 'CGN'],
            ['Preferencia visita', 'Sábado de tarde'],
            ['Próximo paso', 'Coordinar día y horario'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 2. AUDIO PRINCIPAL ─── */
      audio_principal: {
        name: 'Audio · 2 propiedades',
        sub: 'usuario manda audio mencionando dos propiedades · una no disponible · alternativa + visita',
        chat: { name: 'Galaxia Propiedades', sub: 'en línea', initials: 'GP' },
        duration: 18800,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_audio', dur:'0:14', tm:'10:23',
            transcript:'Hola, vi algunas propiedades en su página web que podrían interesarme. Me interesa la de Ramallo y Av. Italia y la de 8 de Octubre y Comercio.' },
          { at:  1500, row:'r1', k:'capability', icon:'✦', tone:'cyan', slot:'entendio',
            text:'Entendió el audio y se conectó a tu CRM para verificar disponibilidad' },
          { at:  3600, row:'r3', k:'bot_typing' },
          { at:  4800, row:'r3', k:'bot_msg', text:'Hola! La de 8 de Octubre y Comercio ya no está disponible, pero la de Ramallo y Av. Italia sigue disponible. ¿Querés coordinar una visita?', tm:'10:23' },
          { at:  5500, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Se conecta a tu CRM y responde ambas preguntas de disponibilidad' },
          { at:  6200, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detectó una propiedad no disponible · ofreció la alternativa disponible' },
          { at:  7000, row:'r4', k:'user_msg', text:'Sí, me interesa verla.', tm:'10:24' },
          { at:  7900, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Detectó intención de visita' },
          { at:  9000, row:'r5', k:'bot_typing' },
          { at: 10000, row:'r5', k:'bot_msg', text:'Perfecto. ¿Qué día y franja horaria te quedaría cómoda para visitarla?', tm:'10:24' },
          { at: 10800, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Pide datos para aumentar posibilidades de contacto exitoso' },
          { at: 12100, row:'r6', k:'user_msg', text:'Podría ser mañana de tarde.', tm:'10:25' },
          { at: 12900, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Extrajo preferencia de día y franja horaria' },
          { at: 14100, row:'r7', k:'bot_msg', text:'Genial. Te contactamos para coordinar la visita por Ramallo y Av. Italia.', tm:'10:25' },
          { at: 14900, row:'r7', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra con expectativa clara y genera aviso interno para una persona del equipo' },
          { at: 15800, k:'email_notice', label:'Nuevo aviso interno · coordinar visita' },
          { at: 17600, k:'email' },
        ],
        summary: [
          'Entendió el audio',
          'Consultó CRM',
          'Ofreció alternativa',
          'Detectó intención de visita',
          'Extrajo día y franja',
          'Generó aviso interno',
        ],
        milestoneKeys: ['entendio', 'consulto', 'detecto', 'pidio', 'aviso'],
        whatItDid: 'Entendió el audio, consultó el CRM, detectó una propiedad no disponible y dejó la visita lista para coordinar.',
        potential: 'El equipo recibe consultas filtradas, con contexto y próximo paso claro, sin revisar manualmente cada mensaje.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · inmobiliaria',
          labels: [{ k:'LEAD CON VISITA', tone:'ok' }],
          subject: 'Lead solicita visita · Ramallo y Av. Italia',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Cliente consultó por audio por dos propiedades...',
          fields: [
            ['Disponible', 'Ramallo y Av. Italia'],
            ['No disponible', '8 de Octubre y Comercio'],
            ['Preferencia', 'Mañana de tarde'],
            ['Próximo paso', 'Coordinar visita'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 2. SEGUIMIENTO + LINK (follow_up con actualización) ─── */
      seguimiento_link: {
        name: 'Link + seguimiento 24h',
        sub: 'consulta por link · queda pendiente · seguimiento automático 24h · luego nuevo seguimiento próxima semana',
        chat: { name: 'Galaxia Propiedades', sub: 'en línea', initials: 'GP' },
        duration: 21500,
        output: 'follow_up',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, ¿cómo están? Vi esta publicación www.linkweb.com/4495 y quería saber si está disponible.', tm:'14:08', linkify:true },
          { at:  1400, row:'r1', k:'capability', icon:'✦', tone:'cyan', slot:'entendio', label:'Entendió link · CRM',
            text:'Detecta que el link es de nuestra propiedad y va a obtener la info a tu CRM' },
          { at:  3500, row:'r3', k:'bot_typing' },
          { at:  4700, row:'r3', k:'bot_msg', text:'Todo bien y vos? Sí, está disponible para alquilar. Tiene 2 cuartos, patio, acepta mascota, y se puede ingresar con garantía ANDA o Contaduría.', tm:'14:09' },
          { at:  5600, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde disponibilidad y datos clave de la propiedad' },
          { at:  7000, row:'r4', k:'user_msg', text:'Me interesa, apenas pueda me contacto.', tm:'14:10' },
          { at:  7900, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta interés sin decisión inmediata' },
          { at:  9000, row:'r5', k:'bot_msg', text:'Ok, quedamos a la orden.', tm:'14:10' },
          { at:  9600, row:'r5', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra sin presionar · agenda seguimiento automático para 24h' },
          { at: 10200, k:'follow_up',
            data: {
              title: 'Seguimiento agendado',
              motivo: 'Cliente interesado dijo que se contactaba después',
              propiedad: 'Publicación 4495',
              cuando: 'En 24 horas',
              estado: 'Activo',
              condicion: 'Se cancela si el usuario escribe antes',
            }
          },
          { at: 11800, k:'time_jump', label:'24 HORAS DESPUÉS' },
          { at: 12600, row:'r6', k:'bot_typing' },
          { at: 13800, row:'r6', k:'bot_msg', text:'¿Cómo estás? ¿Tenés alguna duda con respecto a la propiedad del link que nos pasaste?', tm:'14:08' },
          { at: 14600, row:'r6', k:'capability', icon:'✦', tone:'cyan', label:'Recordó contexto',
            text:'Recordó contexto y ejecutó seguimiento' },
          { at: 16000, row:'r7', k:'user_msg', text:'Sí me interesa, la semana que viene me contacto.', tm:'14:11' },
          { at: 16900, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta interés diferido y nueva ventana temporal' },
          { at: 18100, row:'r8', k:'bot_msg', text:'Perfecto, esperamos tu mensaje. Cualquier cosa estamos a la orden mientras tanto!', tm:'14:11' },
          { at: 18800, row:'r8', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra con tono natural · agenda nuevo seguimiento para la próxima semana' },
          { at: 19600, k:'follow_up_update',
            data: {
              title: 'Nuevo seguimiento agendado',
              motivo: 'Cliente dice que se contacta la semana que viene',
              propiedad: 'Publicación 4495',
              cuando: 'Próxima semana',
              estado: 'Activo',
              condicion: 'Se cancela si el usuario escribe antes',
            }
          },
        ],
        summary: [
          'Entendió el link',
          'Identificó la propiedad',
          'Memoria de contexto',
          'Seguimiento 24h',
          'Retomó al día siguiente',
          'Reagendó próxima semana',
        ],
        milestoneKeys: ['entendio', 'consulto', 'recordo', 'seguimiento'],
        whatItDid: 'El agente entendió un link, identificó la propiedad, respondió disponibilidad y, cuando el lead quedó pendiente, agendó un seguimiento automático. Al día siguiente retomó el contexto y reagendó para la próxima semana.',
        potential: 'Permite no perder leads que dicen "después te aviso" y mantener la conversación viva sin que el equipo tenga que hacer follow-up manual.',
      },

      /* ─── 3. BUCEO · desambiguación ─── */
      buceo_desambiguacion: {
        name: 'Buceo · desambiguación',
        sub: 'consulta incompleta · agente afina · manda dos opciones · entiende "la primera" · deriva visita',
        chat: { name: 'Galaxia Propiedades', sub: 'en línea', initials: 'GP' },
        duration: 22500,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, tengo algunas dudas de la propiedad que tienen en Buceo.', tm:'16:32' },
          { at:  1400, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta intención de búsqueda sobre una propiedad en Buceo' },
          { at:  2500, row:'r2', k:'bot_typing' },
          { at:  3800, row:'r2', k:'bot_msg', text:'Buenísimo. ¿Era por un alquiler o una compra? Contame un poco más así ubico de qué propiedad me hablás.', tm:'16:32' },
          { at:  4700, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Pregunta para afinar la búsqueda porque hay varios resultados posibles' },
          { at:  6200, row:'r3', k:'user_msg', text:'Era por una compra. Tiene 1 cuarto, terraza, vista al mar.', tm:'16:33' },
          { at:  7100, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Extrae operación, zona y características' },
          { at:  8200, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Filtra resultados con los datos entregados por el usuario' },
          { at:  9300, row:'r5', k:'bot_typing' },
          { at: 10500, row:'r5', k:'bot_msg', text:'Tengo 2 con esas características. Te paso las 2 opciones y me decís cuál es.', tm:'16:33' },
          { at: 11300, row:'r6', k:'bot_msg', text:'Opción 1: www.linkweb.com/8812', tm:'16:33', linkify:true },
          { at: 11900, row:'r7', k:'bot_msg', text:'Opción 2: www.linkweb.com/9140', tm:'16:33', linkify:true },
          { at: 12700, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Explica que hay más de una coincidencia y manda los dos links' },
          { at: 14000, row:'r8', k:'user_msg', text:'Es la primera, ¿puedo ir a verla este sábado?', tm:'16:35' },
          { at: 15000, row:'r8', k:'capability', icon:'✦', tone:'cyan',
            text:'Entiende que "la primera" refiere a la Opción 1 · detecta intención de visita' },
          { at: 16400, row:'r9', k:'bot_msg', text:'Dale, dejame revisar la agenda y te confirmo a la brevedad.', tm:'16:35' },
          { at: 17200, row:'r9', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra expectativa y genera aviso interno para coordinar visita' },
          { at: 18800, k:'email_notice', label:'Nuevo aviso interno · coordinar visita Buceo' },
          { at: 20600, k:'email' },
        ],
        summary: [
          'Detectó búsqueda en Buceo',
          'Afinó alquiler/compra',
          'Filtró por características',
          'Mandó 2 links',
          'Entendió "la primera"',
          'Derivó visita al equipo',
        ],
        milestoneKeys: ['entendio', 'consulto', 'detecto', 'pidio', 'aviso'],
        whatItDid: 'El agente entendió una consulta incompleta sobre una zona, pidió datos para afinar (alquiler/compra, características), envió dos opciones concretas, interpretó la respuesta "la primera" y dejó la visita lista para coordinar.',
        potential: 'Permite calificar leads aunque escriban con poca información, sin perderlos por preguntas mal ordenadas. El equipo recibe el caso ya afinado.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · inmobiliaria',
          labels: [{ k:'LEAD CON VISITA', tone:'ok' }, { k:'COMPRA', tone:'default' }],
          subject: 'Lead solicita visita · Buceo compra opción 1',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Cliente consultó por una propiedad en Buceo · eligió Opción 1 tras desambiguación.',
          fields: [
            ['Zona', 'Buceo · compra'],
            ['Propiedad elegida', 'Opción 1 · linkweb.com/8812'],
            ['Características', '1 cuarto · terraza · vista al mar'],
            ['Próximo paso', 'Coordinar visita el sábado'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 4. HERMES · compartir venta B2B ─── */
      compartir_hermes: {
        name: 'Hermes · compartir venta',
        sub: 'otra inmobiliaria escribe por una propiedad · bot detecta B2B · matchea link · guarda horario · email',
        chat: { name: 'Galaxia Propiedades', sub: 'en línea', initials: 'GP' },
        duration: 19700,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, te escribo de Hermes Propiedades.', tm:'10:14' },
          { at:  1400, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta tipo de usuario: otra inmobiliaria · extrae nombre Hermes Propiedades' },
          { at:  2500, row:'r2', k:'bot_typing' },
          { at:  3700, row:'r2', k:'bot_msg', text:'Qué tal, ¿cómo están? ¿Era para compartir alguna propiedad?', tm:'10:15' },
          { at:  4500, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde acorde al tipo de usuario y anticipa la intención probable' },
          { at:  5900, row:'r3', k:'user_msg', text:'Sí exacto, es la propiedad que tienen publicada en www.linkweb.com/7321, ¿la comparten?', tm:'10:15', linkify:true },
          { at:  6900, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta intención de compartir propiedad y extrae el link' },
          { at:  8000, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Consulta la publicación 7321 en la base interna · matchea y confirma información' },
          { at:  9200, row:'r5', k:'bot_typing' },
          { at: 10400, row:'r5', k:'bot_msg', text:'Sí, esa la compartimos. Dejame tus datos y en qué horario nos comunicamos que te contactamos.', tm:'10:16' },
          { at: 11200, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Confirma disponibilidad para compartir y pide datos de contacto y horario' },
          { at: 12500, row:'r6', k:'user_msg', text:'Lunes a viernes hasta las 18hs estoy!', tm:'10:17' },
          { at: 13300, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Guarda preferencia horaria de contacto' },
          { at: 14500, row:'r7', k:'bot_msg', text:'Perfecto, nos comunicamos!', tm:'10:17' },
          { at: 15300, row:'r7', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra expectativa y dispara aviso interno para que una persona contacte a Hermes' },
          { at: 16400, k:'email_notice', label:'Nuevo aviso interno · B2B' },
          { at: 18200, k:'email' },
        ],
        summary: [
          'Detectó otra inmobiliaria',
          'Confirmó intención compartir',
          'Matcheó link con base interna',
          'Guardó preferencia horaria',
          'Cerró expectativa',
          'Generó aviso B2B al equipo',
        ],
        milestoneKeys: ['detecto', 'consulto', 'pidio', 'aviso'],
        whatItDid: 'El agente detectó que escribía otra inmobiliaria (Hermes Propiedades), entendió la intención de compartir, matcheó el link con la base interna, guardó la preferencia horaria de contacto y dejó la coordinación lista para el equipo.',
        potential: 'Permite manejar el flujo B2B sin que el equipo gaste tiempo en consultas de colegas: el caso llega filtrado con datos, link e intención clara.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · inmobiliaria',
          labels: [{ k:'B2B · COMPARTIR', tone:'violet' }],
          subject: 'Inmobiliaria busca compartir propiedad · Hermes Propiedades',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Hermes Propiedades consulta si se comparte la propiedad publicada · matcheada con base interna.',
          fields: [
            ['Tipo de contacto', 'Otra inmobiliaria · Hermes Propiedades'],
            ['Propiedad', 'linkweb.com/7321 · se comparte'],
            ['Horario de contacto', 'Lun-Vie hasta 18hs'],
            ['Próximo paso', 'Equipo contacta a Hermes'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },
    },
  },

  club: {
    label: 'Club',
    defaultFlow: 'inscripcion',
    flows: {

      /* ─── 5. INSCRIPCIÓN con documentos ─── */
      inscripcion: {
        name: 'Inscripción · documentos',
        sub: 'consulta general · DNI frente · dorso · constancia PDF · extracción · email al equipo',
        chat: { name: 'Club Galaxia · Inscripciones', sub: 'en línea', initials: 'CG' },
        duration: 32500,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, ¿cómo va? Quería saber info del club.', tm:'14:15' },
          { at:  1300, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta consulta general sobre el club' },
          { at:  2400, row:'r2', k:'bot_typing' },
          { at:  3700, row:'r2', k:'bot_msg', text:'Hola! Todo bien! Te cuento, estamos en zona Palermo, de lunes a viernes de 9 a 18hs. ¿Te interesaba inscribirte o tenías alguna duda puntual?', tm:'14:15' },
          { at:  4600, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde información cargada y pregunta para entender la intención' },
          { at:  6000, row:'r3', k:'user_msg', text:'Quería saber qué necesito para inscribirme!', tm:'14:16' },
          { at:  6900, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta intención de inscripción' },
          { at:  8100, row:'r4', k:'bot_msg', text:'Es fácil, te cuento: nos mandás foto de DNI ambos lados y constancia de domicilio.', tm:'14:16' },
          { at:  9000, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Inicia flujo de documentación y solicita requisitos' },
          { at: 10300, row:'r5', k:'user_image', name:'dni_frente.jpg', size:'180 KB', tm:'14:17' },
          { at: 11200, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Reconoce la imagen · detecta frente de DNI argentino · extrae datos' },
          { at: 12500, row:'r6', k:'bot_msg', text:'Perfecto, ahora el dorso.', tm:'14:17' },
          { at: 13300, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Valida primer documento · pide el lado faltante' },
          { at: 14500, row:'r7', k:'user_image', name:'dni_dorso.jpg', size:'175 KB', tm:'14:18' },
          { at: 15400, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Reconoce el dorso del DNI y completa la validación del documento' },
          { at: 16600, row:'r8', k:'bot_msg', text:'Genial, solo falta la constancia.', tm:'14:18' },
          { at: 17400, row:'r8', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta que falta la constancia de domicilio' },
          { at: 18700, row:'r9', k:'user_pdf', name:'constancia_domicilio.pdf', size:'420 KB', tm:'14:19' },
          { at: 19600, row:'r9', k:'capability', icon:'✦', tone:'cyan',
            text:'Reconoce el PDF · extrae dirección · valida documento faltante' },
          { at: 20900, row:'r10', k:'bot_typing' },
          { at: 22300, row:'r10', k:'bot_msg', text:'Impecable, ya tenemos todo. Te contactamos a la brevedad para finalizar la inscripción. ¿Alguna franja horaria o día que prefieras que te llamemos?', tm:'14:20' },
          { at: 23200, row:'r10', k:'capability', icon:'✦', tone:'cyan',
            text:'Confirma legajo completo · solicita preferencia de contacto' },
          { at: 24600, row:'r11', k:'user_msg', text:'Sí, el sábado de mañana.', tm:'14:20' },
          { at: 25500, row:'r11', k:'capability', icon:'✦', tone:'cyan',
            text:'Extrae preferencia de contacto como referencia, no como confirmación de agenda' },
          { at: 26800, row:'r12', k:'bot_msg', text:'Perfecto, estamos en contacto!', tm:'14:21' },
          { at: 27600, row:'r12', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra expectativa · dispara email/ficha al equipo con datos y adjuntos' },
          { at: 28900, k:'email_notice', label:'Nuevo aviso interno · inscripción' },
          { at: 30700, k:'email' },
        ],
        summary: [
          'Detectó intención inscripción',
          'Pidió docs en orden',
          'Reconoció DNI frente y dorso',
          'Validó DNI argentino',
          'Procesó PDF de constancia',
          'Extrajo dirección',
          'Generó email con adjuntos',
        ],
        milestoneKeys: ['intencion', 'documento', 'valido', 'pdf', 'aviso'],
        whatItDid: 'El agente detectó la intención de inscripción, pidió los documentos en orden, reconoció el DNI argentino frente y dorso, leyó el PDF de constancia, extrajo el domicilio y dejó el legajo completo listo para activar.',
        potential: 'Permite recibir inscripciones por WhatsApp 24/7 sin que el equipo tenga que tipear datos: las altas llegan ya validadas y con los archivos adjuntos.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · Club Galaxia',
          labels: [{ k:'NUEVA INSCRIPCIÓN', tone:'ok' }, { k:'LEGAJO COMPLETO', tone:'default' }],
          subject: 'Nueva inscripción completa · Club',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Nueva inscripción · DNI argentino y constancia de domicilio validados.',
          fields: [
            ['DNI', 'Argentino · frente y dorso validados'],
            ['Constancia', 'PDF recibido · domicilio extraído'],
            ['Estado', 'Legajo completo'],
            ['Próximo paso', 'Equipo contacta para finalizar (ref: sábado de mañana)'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [
            { name:'dni_frente.jpg', size:'180 KB', icon:'JPG', kind:'img' },
            { name:'dni_dorso.jpg', size:'175 KB', icon:'JPG', kind:'img' },
            { name:'constancia_domicilio.pdf', size:'420 KB', icon:'PDF', kind:'pdf' },
          ],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 6. SOCIO actual · horario ─── */
      socio_horario: {
        name: 'Socio · horario',
        sub: 'reconoce socio por número · consulta horario · da luz verde · avisa que pasa viernes 10',
        chat: { name: 'Club Galaxia · Atención', sub: 'en línea', initials: 'CG' },
        duration: 17500,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, ¿cómo están?', tm:'09:30' },
          { at:  1300, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta el número y reconoce que es un socio actual' },
          { at:  2300, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Recupera contexto del socio asociado al número (socio inscripto)' },
          { at:  3400, row:'r3', k:'bot_typing' },
          { at:  4600, row:'r3', k:'bot_msg', text:'Hola, todo bien y vos? ¿En qué te puedo ayudar?', tm:'09:30' },
          { at:  5400, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde con trato personalizado · no pide datos que ya conoce' },
          { at:  6700, row:'r4', k:'user_msg', text:'Quería pasar este viernes a las 10, ¿están abiertos?', tm:'09:31' },
          { at:  7600, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta intención de pasar por el club · extrae día y hora' },
          { at:  8800, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Consulta horario y reglas cargadas por la empresa' },
          { at:  9900, row:'r6', k:'bot_msg', text:'Sí, claro. Pasate el viernes, estamos abiertos desde las 9 a las 18hs.', tm:'09:31' },
          { at: 10700, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Da luz verde según la regla de horario' },
          { at: 12000, row:'r7', k:'user_msg', text:'Ah genial, quedamos así!', tm:'09:32' },
          { at: 12800, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta confirmación del socio' },
          { at: 13900, row:'r8', k:'bot_msg', text:'Te esperamos!', tm:'09:32' },
          { at: 14400, row:'r8', k:'capability', icon:'⚡', tone:'violet',
            text:'Envía aviso interno al equipo: el socio pasa el viernes a las 10' },
          { at: 14900, k:'email_notice', label:'Nuevo aviso interno' },
          { at: 16700, k:'email' },
        ],
        summary: [
          'Reconoció socio por número',
          'Recuperó contexto',
          'Consultó horario interno',
          'Dio luz verde',
          'Avisó al equipo',
        ],
        milestoneKeys: ['reconocio', 'consulto', 'confirmo', 'aviso'],
        whatItDid: 'El agente reconoció al socio por su número de WhatsApp, recuperó su contexto, consultó las reglas de horario, confirmó que podía pasar el viernes a las 10 y avisó al equipo para que lo esperen.',
        potential: 'Permite atender a socios existentes sin pedirles datos que ya tenemos y dejar al equipo avisado de cada visita planificada.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · Club Galaxia',
          labels: [{ k:'SOCIO ACTUAL', tone:'ok' }],
          subject: 'Socio pasa por el club · viernes 10:00',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Socio actual avisa que pasa este viernes a las 10.',
          fields: [
            ['Tipo de contacto', 'Socio actual · inscripto'],
            ['Día y hora', 'Viernes · 10:00'],
            ['Regla aplicada', 'Club abierto 9 a 18hs'],
            ['Próximo paso', 'Equipo queda avisado'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 7. REAVISO por frustración ─── */
      reaviso: {
        name: 'RE AVISO · frustración',
        sub: 'detecta reclamo · revisa última solicitud · aplica política 48h · re-avisa al staff',
        chat: { name: 'Club Galaxia · Atención', sub: 'en línea', initials: 'CG' },
        duration: 16500,
        output: 'email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, me dijeron que me iban a llamar y todavía no me llamaron.', tm:'11:08' },
          { at:  1300, row:'r1', k:'capability', icon:'✦', tone:'violet',
            text:'Detecta reclamo · usuario frustrado por falta de llamada' },
          { at:  2400, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Busca historial del usuario · última solicitud de contacto: ayer 15hs' },
          { at:  3600, row:'r3', k:'bot_typing' },
          { at:  5400, row:'r3', k:'bot_msg', text:'Hola, lamento eso. Vos te comunicaste ayer a las 15hs y, como te comentamos, para contactarnos tenemos hasta 48hs. De todas maneras, doy un re-aviso al staff.', tm:'11:08' },
          { at:  6300, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde con empatía · recuerda última solicitud · aplica política de 48hs sin discutir' },
          { at:  8200, row:'r4', k:'user_msg', text:'Ok.', tm:'11:09' },
          { at:  9000, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Usuario acepta la respuesta' },
          { at: 10200, row:'r5', k:'capability', icon:'⚡', tone:'violet',
            text:'Genera re-aviso interno para el staff con todo el contexto' },
          { at: 11400, row:'r6', k:'bot_msg', text:'Ya están avisados. Cualquier duda igual estoy por acá. Saludos!', tm:'11:09' },
          { at: 12200, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Confirma al usuario que el equipo ya fue notificado' },
          { at: 13200, k:'email_notice', label:'RE AVISO generado al staff' },
          { at: 15000, k:'email' },
        ],
        summary: [
          'Detectó frustración',
          'Recuperó historial',
          'Aplicó política 48h',
          'Re-avisó al staff',
          'Confirmó al usuario',
        ],
        milestoneKeys: ['detecto', 'consulto', 'explico', 'aviso'],
        whatItDid: 'El agente detectó la frustración del usuario, recuperó el historial de su última solicitud, explicó la política de contacto de 48 horas con empatía y generó un re-aviso al staff sin discutir.',
        potential: 'Permite contener reclamos antes de que escalen, aplicar políticas internas con respeto y avisar al equipo para que priorice si corresponde.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Staff · Club Galaxia',
          labels: [{ k:'RE AVISO', tone:'warn' }, { k:'FRUSTRACIÓN DETECTADA', tone:'warn' }],
          subject: 'RE AVISO · Usuario espera contacto',
          meta: 'hace 1 segundo · para staff',
          body: 'Usuario reclama llamada pendiente · frustración detectada.',
          fields: [
            ['Última solicitud', 'Ayer 15:00'],
            ['Política aplicada', 'Contacto dentro de 48hs'],
            ['Acción tomada', 'Re-aviso al staff'],
            ['Próximo paso', 'Staff revisa y prioriza si corresponde'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 8. INGLÉS · Premium con seguimiento ─── */
      ingles_premium: {
        name: 'Inglés · Premium',
        sub: 'cambia a inglés mid-chat · queda pensando · seguimiento 24h · vuelve y elige Premium · email',
        chat: { name: 'Club Galaxia · Atención', sub: 'en línea', initials: 'CG' },
        duration: 35500,
        output: 'follow_up_then_email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, quería consultar por las membresías del club.', tm:'15:02' },
          { at:  1300, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta consulta sobre membresías' },
          { at:  2400, row:'r2', k:'bot_typing' },
          { at:  3800, row:'r2', k:'bot_msg', text:'Hola! Claro. Tenemos tres opciones: Normal, Basic y Premium. La Normal cuesta $40, Basic $65 y Premium $95. ¿Querés que te cuente las diferencias?', tm:'15:02' },
          { at:  4700, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Informa las tres opciones de membresía con costos' },
          { at:  6200, row:'r3', k:'user_msg', text:'Yes please, can you explain Premium in English? My Spanish is not great.', tm:'15:03' },
          { at:  7100, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta cambio de idioma a inglés a mitad de conversación' },
          { at:  8300, row:'r4', k:'bot_typing' },
          { at:  9800, row:'r4', k:'bot_msg', text:'Of course. Premium includes priority support, extended member benefits and faster assistance from the team. Basic is a middle option, and Normal covers the standard membership benefits.', tm:'15:03' },
          { at: 10700, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde en inglés manteniendo el contexto · sin perder los planes ya informados' },
          { at: 12200, row:'r5', k:'user_msg', text:'Sounds good, I will think about it.', tm:'15:04' },
          { at: 13100, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta interés sin decisión inmediata' },
          { at: 14200, row:'r6', k:'bot_msg', text:'No problem. If you have any questions, I am here to help.', tm:'15:04' },
          { at: 15000, row:'r6', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra sin presionar · agenda seguimiento automático para 24h' },
          { at: 16200, k:'follow_up',
            data: {
              title: 'Seguimiento agendado',
              motivo: 'Usuario consultó membresías y quedó pensando',
              planes: 'Normal · Basic · Premium',
              idioma: 'Inglés',
              cuando: 'En 24 horas',
              estado: 'Activo',
            }
          },
          { at: 18000, k:'time_jump', label:'24 HORAS DESPUÉS' },
          { at: 19000, row:'r7', k:'bot_typing' },
          { at: 20400, row:'r7', k:'bot_msg', text:'Hi! Just checking in. Did you have any questions about the Normal, Basic or Premium memberships?', tm:'15:02' },
          { at: 21300, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Retoma en inglés y recuerda el contexto de la conversación anterior' },
          { at: 22700, row:'r8', k:'user_msg', text:'Hi, yes. I want to join Premium.', tm:'15:03' },
          { at: 23500, row:'r8', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta decisión de asociarse a Premium' },
          { at: 24700, row:'r9', k:'bot_typing' },
          { at: 26000, row:'r9', k:'bot_msg', text:'Great. What day or time window works best for the team to contact you and finish the Premium membership?', tm:'15:03' },
          { at: 26800, row:'r9', k:'capability', icon:'✦', tone:'cyan',
            text:'Solicita horario o franja de contacto' },
          { at: 28200, row:'r10', k:'user_msg', text:'Tomorrow afternoon works for me.', tm:'15:04' },
          { at: 29100, row:'r10', k:'capability', icon:'✦', tone:'cyan',
            text:'Extrae preferencia de contacto' },
          { at: 30200, row:'r11', k:'bot_msg', text:'Perfect, we will contact you to continue with the Premium membership.', tm:'15:04' },
          { at: 31000, row:'r11', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra expectativa · reemplaza la tarjeta de seguimiento por email/ficha interna' },
          { at: 32200, k:'email_notice', label:'Nueva asociación Premium · idioma inglés' },
          { at: 34000, k:'email' },
        ],
        summary: [
          'Detectó consulta de membresías',
          'Informó Normal/Basic/Premium',
          'Cambió a inglés',
          'Agendó seguimiento 24h',
          'Retomó al día siguiente',
          'Detectó decisión Premium',
          'Generó email · tarjeta reemplazada',
        ],
        milestoneKeys: ['idioma', 'informo', 'seguimiento', 'premium', 'aviso'],
        whatItDid: 'El agente detectó el cambio de idioma a inglés a mitad de la conversación, informó las tres membresías con costos, agendó un seguimiento cuando el lead quedó pensando, retomó al día siguiente en inglés y, al detectar la decisión por Premium, generó el aviso al equipo.',
        potential: 'Permite atender en el idioma del cliente sin perder contexto y no dejar caer leads que quedan pensando: el seguimiento es automático y la conversión queda registrada.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · Club Galaxia',
          labels: [{ k:'NUEVA ASOCIACIÓN', tone:'ok' }, { k:'PREMIUM', tone:'violet' }, { k:'INGLÉS', tone:'amber' }],
          subject: 'Nueva asociación Premium · consulta en inglés',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Lead vuelve tras seguimiento 24h y elige Premium en inglés.',
          fields: [
            ['Plan elegido', 'Premium · $95'],
            ['Idioma', 'Inglés'],
            ['Histórico', 'Consulta + decisión a las 24h'],
            ['Próximo paso', 'Equipo contacta mañana de tarde'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 9. PORTUGUÊS · Premium con seguimiento ─── */
      portugues_premium: {
        name: 'Português · Premium',
        sub: 'portugués desde el inicio · queda pensando · seguimiento 24h · vuelve y elige Premium · email',
        chat: { name: 'Club Galaxia · Atendimento', sub: 'en línea', initials: 'CG' },
        duration: 35500,
        output: 'follow_up_then_email',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Oi, tudo bem? Gostaria de saber quais planos de associação vocês têm.', tm:'19:14' },
          { at:  1400, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta portugués desde el primer mensaje · cambia a portugués' },
          { at:  2500, row:'r2', k:'bot_typing' },
          { at:  3900, row:'r2', k:'bot_msg', text:'Oi! Tudo bem. Temos três opções: Normal, Basic e Premium. A Normal custa $40, Basic $65 e Premium $95. Quer que eu explique as diferenças?', tm:'19:14' },
          { at:  4800, row:'r2', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde en portugués con los planes y costos' },
          { at:  6200, row:'r3', k:'user_msg', text:'Sim, principalmente a Premium.', tm:'19:15' },
          { at:  7000, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta interés específico por Premium' },
          { at:  8200, row:'r4', k:'bot_typing' },
          { at:  9700, row:'r4', k:'bot_msg', text:'Claro. A Premium inclui atendimento prioritário, benefícios estendidos para sócios e suporte mais rápido da equipe. A Basic é intermediária, e a Normal cobre os benefícios padrão.', tm:'19:15' },
          { at: 10500, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Informa acorde al plan consultado · mantiene el idioma' },
          { at: 12000, row:'r5', k:'user_msg', text:'Vou ver e te aviso.', tm:'19:16' },
          { at: 12800, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta interés sin decisión inmediata' },
          { at: 13900, row:'r6', k:'bot_msg', text:'Perfeito. Se tiver qualquer dúvida, fico à disposição.', tm:'19:16' },
          { at: 14700, row:'r6', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra sin presionar · agenda seguimiento automático para 24h' },
          { at: 15900, k:'follow_up',
            data: {
              title: 'Seguimiento agendado',
              motivo: 'Cliente consultó membresías en portugués y quedó pensando',
              planes: 'Normal · Basic · Premium',
              idioma: 'Portugués',
              cuando: 'En 24 horas',
              estado: 'Activo',
            }
          },
          { at: 18000, k:'time_jump', label:'24 HORAS DESPUÉS' },
          { at: 19000, row:'r7', k:'bot_typing' },
          { at: 20400, row:'r7', k:'bot_msg', text:'Oi! Passando para saber se ficou alguma dúvida sobre os planos Normal, Basic ou Premium.', tm:'19:14' },
          { at: 21300, row:'r7', k:'capability', icon:'✦', tone:'cyan',
            text:'Retoma en portugués y recuerda los planes informados' },
          { at: 22700, row:'r8', k:'user_msg', text:'Oi, quero fazer a Premium.', tm:'19:15' },
          { at: 23500, row:'r8', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta decisión de asociarse a Premium' },
          { at: 24700, row:'r9', k:'bot_typing' },
          { at: 26000, row:'r9', k:'bot_msg', text:'Ótimo. Qual dia ou faixa de horário você prefere para a equipe entrar em contato e finalizar a associação Premium?', tm:'19:15' },
          { at: 26800, row:'r9', k:'capability', icon:'✦', tone:'cyan',
            text:'Pide horario o franja de contacto' },
          { at: 28200, row:'r10', k:'user_msg', text:'Pode ser sexta de tarde.', tm:'19:16' },
          { at: 29100, row:'r10', k:'capability', icon:'✦', tone:'cyan',
            text:'Extrae preferencia de contacto' },
          { at: 30200, row:'r11', k:'bot_msg', text:'Perfeito, vamos entrar em contato para continuar com a Premium.', tm:'19:16' },
          { at: 31000, row:'r11', k:'capability', icon:'⚡', tone:'violet',
            text:'Cierra expectativa · reemplaza la tarjeta de seguimiento por email/ficha' },
          { at: 32200, k:'email_notice', label:'Nueva asociación Premium · idioma portugués' },
          { at: 34000, k:'email' },
        ],
        summary: [
          'Detectó portugués desde inicio',
          'Informó membresías',
          'Resolvió dudas Premium',
          'Agendó seguimiento 24h',
          'Retomó al día siguiente',
          'Detectó decisión Premium',
          'Generó email · tarjeta reemplazada',
        ],
        milestoneKeys: ['idioma', 'informo', 'seguimiento', 'premium', 'aviso'],
        whatItDid: 'El agente detectó portugués desde el primer mensaje, informó las membresías y resolvió dudas sobre Premium en su idioma. Cuando el lead quedó pensando, agendó seguimiento, retomó al día siguiente en portugués y dejó la asociación lista para confirmar.',
        potential: 'Permite vender membresías a hablantes de otros idiomas sin equipo bilingüe activo 24/7, manteniendo el seguimiento automático.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Equipo · Club Galaxia',
          labels: [{ k:'NUEVA ASOCIACIÓN', tone:'ok' }, { k:'PREMIUM', tone:'violet' }, { k:'PORTUGUÉS', tone:'amber' }],
          subject: 'Nueva asociación Premium · consulta en portugués',
          meta: 'hace 1 segundo · para el equipo',
          body: 'Lead vuelve tras seguimiento 24h y elige Premium en portugués.',
          fields: [
            ['Plan elegido', 'Premium · $95'],
            ['Idioma', 'Portugués'],
            ['Histórico', 'Consulta + decisión a las 24h'],
            ['Próximo paso', 'Equipo contacta viernes de tarde'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },

      /* ─── 10. SOLICITA HUMANO · transparencia + handoff ─── */
      solicita_humano: {
        name: 'Solicita humano',
        sub: 'pregunta si somos IA · pide hablar con alguien · staff toma la conversación · bot en silencio',
        chat: { name: 'Club Galaxia · Atención', sub: 'en línea', initials: 'CG' },
        duration: 22000,
        output: 'email_then_human',
        timeline: [
          { at:   400, row:'r1', k:'user_msg', text:'Hola, ¿cómo están?', tm:'12:04' },
          { at:  1300, row:'r1', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta saludo normal · responde naturalmente' },
          { at:  2400, row:'r2', k:'bot_typing' },
          { at:  3500, row:'r2', k:'bot_msg', text:'Hola! Todo bien, y vos? ¿En qué te puedo ayudar?', tm:'12:04' },
          { at:  4900, row:'r3', k:'user_msg', text:'Antes de seguir, ¿ustedes son IA?', tm:'12:05' },
          { at:  5800, row:'r3', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta pregunta directa sobre IA' },
          { at:  7000, row:'r4', k:'bot_typing' },
          { at:  8600, row:'r4', k:'bot_msg', text:'Soy el agente de atención al cliente del club y utilizo IA para darte la mejor atención posible a vos y a nuestros socios.', tm:'12:05' },
          { at:  9500, row:'r4', k:'capability', icon:'✦', tone:'cyan',
            text:'Responde con transparencia y valor de servicio · sin esconderse' },
          { at: 11000, row:'r5', k:'user_msg', text:'Entiendo, pero ¿puedo hablar con alguien?', tm:'12:06' },
          { at: 11900, row:'r5', k:'capability', icon:'✦', tone:'cyan',
            text:'Detecta intención de hablar con personal humano' },
          { at: 13100, row:'r6', k:'bot_msg', text:'Sí, claro, no hay ningún problema. Ya le aviso al staff.', tm:'12:06' },
          { at: 13900, row:'r6', k:'capability', icon:'✦', tone:'cyan',
            text:'Acepta sin fricción y escala al equipo' },
          { at: 14900, row:'r7', k:'capability', icon:'⚡', tone:'violet',
            text:'Envía email/ficha interna al staff con datos y link directo' },
          { at: 15400, k:'email_notice', label:'Solicitud al staff · atención humana' },
          { at: 17200, k:'email' },
          { at: 18200, row:'r8', k:'bot_msg', text:'Ahora vamos a dejar que una persona del equipo continúe la atención.', tm:'12:07' },
          { at: 19200, row:'r9', k:'staff_banner', text:'Una persona del equipo se sumó a la conversación' },
          { at: 19800, row:'r9', k:'human_msg', text:'Hola, soy del equipo. Ya estoy acá para ayudarte.', tm:'12:08' },
          { at: 20700, row:'r9', k:'capability', icon:'⚡', tone:'violet',
            text:'Detecta contacto humano · el bot queda en silencio y no interviene más' },
        ],
        summary: [
          'Detectó pregunta sobre IA',
          'Respondió con transparencia',
          'Detectó pedido humano',
          'Escaló al staff',
          'Detectó intervención humana',
          'Quedó en silencio',
        ],
        milestoneKeys: ['detecto', 'explico', 'escalo', 'humano', 'silencio'],
        whatItDid: 'El agente detectó la pregunta directa sobre IA, respondió con transparencia, escaló al staff cuando el usuario pidió hablar con una persona y, al detectar la intervención humana, quedó en silencio sin interrumpir.',
        potential: 'Permite escalar a humano sin fricción, manteniendo la confianza del usuario y respetando el trabajo del equipo cuando toma el caso.',
        email: {
          from: 'Agente · newGalaxIA',
          to: 'Staff · Club Galaxia',
          labels: [{ k:'SOLICITA HUMANO', tone:'amber' }, { k:'HANDOFF', tone:'violet' }],
          subject: 'Usuario solicita hablar con personal',
          meta: 'hace 1 segundo · para staff',
          body: 'Usuario preguntó si era IA y luego pidió hablar con una persona.',
          fields: [
            ['Motivo', 'Solicita atención humana'],
            ['Pregunta previa', 'Consulta si somos IA'],
            ['Acción', 'Staff avisado · bot en silencio'],
            ['Próximo paso', 'Equipo continúa la atención'],
            ['Más info', 'Abrir conversación en WhatsApp'],
          ],
          attachments: [],
          cta: 'Abrir conversación en WhatsApp',
        },
      },
    },
  },
};

const VERTICAL_ORDER = ['inmobiliaria','club'];

/* ════════════════════════════════════════════════════════════════════
   HOOKS / UTILS
   ════════════════════════════════════════════════════════════════════ */

function useAnimationFrame(playing, speed, onTick) {
  const rafRef = useRef(0);
  const lastRef = useRef(0);
  useEffect(() => {
    if (!playing) return;
    /* V3 ADDENDUM 21 (2026-05-18) — Slowdown 30% en mobile (<768px).
       Permite que el usuario lea mensajes y aprecie capacidades sin
       que el demo corra demasiado rápido. La velocidad seleccionada
       (1x/1.5x/2x) sigue respetada pero con un multiplicador 0.7 en
       mobile para que el baseline sea más legible. */
    const isMobile = typeof window !== 'undefined' && window.matchMedia &&
      window.matchMedia('(max-width: 767px)').matches;
    const effSpeed = speed * (isMobile ? 0.7 : 1);
    lastRef.current = performance.now();
    function tick(now) {
      const delta = (now - lastRef.current) * effSpeed;
      lastRef.current = now;
      onTick(delta);
      rafRef.current = requestAnimationFrame(tick);
    }
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [playing, speed, onTick]);
}

function fmtTime(ms) {
  const total = Math.floor(ms / 1000);
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}

function buildRows(timeline) {
  const map = new Map();
  const order = [];
  for (const ev of timeline) {
    if (!ev.row) continue;
    if (!map.has(ev.row)) { map.set(ev.row, { rowId: ev.row, events: [] }); order.push(ev.row); }
    map.get(ev.row).events.push(ev);
  }
  return order.map(id => map.get(id));
}

function rowFirstAt(row) { return Math.min(...row.events.map(e => e.at)); }

/* Render text with URL detection (links blue clickable visually) */
function renderWithLinks(text) {
  if (!text) return text;
  const parts = text.split(/(www\.[^\s]+|https?:\/\/[^\s]+)/g);
  return parts.map((p, i) => {
    if (/^(www\.|https?:\/\/)/.test(p)) {
      return <a key={i} href="#" onClick={e => e.preventDefault()} className="wa-link">{p}</a>;
    }
    return <React.Fragment key={i}>{p}</React.Fragment>;
  });
}

/* ════════════════════════════════════════════════════════════════════
   WHATSAPP COMPONENTS
   ════════════════════════════════════════════════════════════════════ */

function WAHeader({ name, sub, initials }) {
  return (
    <div className="wa-header px-4 py-3 flex items-center gap-3">
      <div className="size-10 rounded-full bg-white/15 flex items-center justify-center text-[12px] font-mono text-white/95">{initials}</div>
      <div className="flex-1 min-w-0">
        <div className="text-[14.5px] font-medium text-white truncate">{name}</div>
        <div className="text-[11.5px] text-white/70">{sub}</div>
      </div>
      <div className="flex items-center gap-4 text-white/70 text-[18px]">
        <span aria-hidden="true">📞</span>
        <span aria-hidden="true">⋮</span>
      </div>
    </div>
  );
}

function WABubble({ side, text, tm, linkify, staff }) {
  const isOut = side === 'out';
  const cls = staff ? 'wa-bubble staff' : `wa-bubble ${isOut ? 'out' : 'in'}`;
  return (
    <div className={`flex ${isOut ? 'justify-end' : 'justify-start'} mb-1.5 px-2`}>
      <div className={cls}>
        {staff && <div className="wa-staff-tag">Staff · humano</div>}
        <div className="whitespace-pre-wrap">{linkify ? renderWithLinks(text) : text}</div>
        <div className="flex justify-end items-baseline -mt-0.5">
          <span className="wa-time">{tm}</span>
          {isOut && !staff && <span className="wa-checks">✓✓</span>}
        </div>
      </div>
    </div>
  );
}

function WAAudio({ dur, tm, transcript }) {
  const bars = [4,8,6,10,12,8,5,11,9,6,7,12,8,4,9,11,6,5,8,4,7,9,5,4,10,8,6,5,4,8,6,9,5,7,11];
  return (
    <>
      <div className="flex justify-start mb-1.5 px-2">
        <div className="wa-bubble in" style={{ padding: '8px 12px 8px 10px' }}>
          <div className="wa-audio-wrap">
            <div className="wa-audio-row">
              <div className="wa-audio-play"><span aria-hidden="true">▶</span></div>
              <div className="wa-wave">{bars.map((h, i) => <span key={i} style={{ height: `${h+4}px` }} />)}</div>
            </div>
            <div className="wa-audio-meta">
              <span>{dur}</span>
              <span>{tm}</span>
            </div>
          </div>
        </div>
      </div>
      {transcript && (
        <div className="wa-audio-transcript-row">
          <span className="wa-audio-transcript-chip">
            <span className="ic" aria-hidden="true">🎙</span>
            <span className="tx">{transcript}</span>
          </span>
        </div>
      )}
    </>
  );
}

function WAImage({ name, size, tm }) {
  return (
    <div className="flex justify-start mb-1.5 px-2">
      <div className="wa-image">
        <div className="wa-image-thumb">🖼  IMAGEN</div>
        <div className="wa-image-name">
          <span style={{ maxWidth:'140px', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{name}</span>
          <span className="wa-time">{tm}</span>
        </div>
        <div className="text-[10px] text-[#667781] px-1 pb-1">{size}</div>
      </div>
    </div>
  );
}

function WAPdf({ name, size, tm }) {
  return (
    <div className="flex justify-start mb-1.5 px-2">
      <div className="wa-pdf">
        <div className="wa-pdf-icon">PDF</div>
        <div className="wa-pdf-meta">
          <div className="wa-pdf-name">{name}</div>
          <div className="wa-pdf-sub">{size}</div>
        </div>
        <div className="wa-pdf-time">{tm}</div>
      </div>
    </div>
  );
}

function WATyping() {
  return (
    <div className="flex justify-start mb-1.5 px-2">
      <div className="wa-typing"><span/><span/><span/></div>
    </div>
  );
}

function WADateDivider({ label }) {
  return (
    <div className="flex justify-center my-3">
      <span className="wa-date-pill">{label}</span>
    </div>
  );
}

function WAStaffBanner({ text }) {
  return (
    <div className="wa-staff-banner">{text}</div>
  );
}

function WAInputBar() {
  return (
    <div className="wa-input">
      <span aria-hidden="true" className="text-[#667781] text-[20px]">😊</span>
      <span aria-hidden="true" className="text-[#667781] text-[18px]">📎</span>
      <div className="wa-input-field">escribí un mensaje</div>
      <div className="wa-input-btn"><span aria-hidden="true">🎤</span></div>
    </div>
  );
}

/* V3 polish ADDENDUM 23 (2026-05-18) — Selector mobile compacto.
   Reemplaza los vtabs+fchips en mobile (md:hidden) por dos botones
   "Inmobiliaria" / "Club" lado a lado. Click → dropdown con las cases
   adentro. Click en case → onSelect(vertical, flowKey) → cierra dropdown.
   Click-outside cierra. Desktop sigue usando los vtabs+fchips originales. */
function MobileBizSelector({ verticalOrder, cases, currentVertical, currentFlowKey, onSelect }) {
  const [open, setOpen] = useState(null); // null | 'inmobiliaria' | 'club'
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    function onClickAway(e) {
      if (ref.current && !ref.current.contains(e.target)) setOpen(null);
    }
    function onKey(e) { if (e.key === 'Escape') setOpen(null); }
    document.addEventListener('click', onClickAway);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('click', onClickAway);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  return (
    <div ref={ref} className="md:hidden relative">
      <div className="grid grid-cols-2 gap-2">
        {verticalOrder.map((vKey) => {
          const v = cases[vKey];
          const isActive = vKey === currentVertical;
          const isOpen = open === vKey;
          return (
            <div key={vKey} className="relative">
              <button
                type="button"
                onClick={(e) => { e.stopPropagation(); setOpen(isOpen ? null : vKey); }}
                aria-expanded={isOpen}
                aria-haspopup="menu"
                className={`w-full inline-flex items-center justify-between gap-2 px-3 py-2 rounded-full border transition-colors ${
                  isActive
                    ? 'border-cyan/55 bg-cyan/10 text-ink'
                    : 'border-line bg-bg/40 text-ink/80 hover:text-ink hover:border-ink/30'
                }`}>
                <span className="inline-flex items-center gap-1.5 min-w-0">
                  <span className={`size-1.5 rounded-full ${isActive ? 'bg-cyan' : 'bg-dim'}`}
                    style={isActive ? { boxShadow: '0 0 6px rgba(51,194,234,0.7)' } : {}} />
                  <span className="text-[13px] truncate">{v.label}</span>
                </span>
                <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true"
                  className={`flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180 text-cyan' : 'text-ink/55'}`}>
                  <path d="M2 3.5 5 6.5 8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              {isOpen && (
                <div role="menu"
                  className="absolute left-0 right-0 top-full mt-2 rounded-2xl border border-cyan/30 overflow-hidden z-30"
                  style={{
                    background: 'linear-gradient(180deg, rgba(11,11,18,0.96), rgba(11,11,18,0.98))',
                    backdropFilter: 'blur(18px) saturate(140%)',
                    WebkitBackdropFilter: 'blur(18px) saturate(140%)',
                    boxShadow: '0 0 0 1px rgba(91,213,242,0.10) inset, 0 22px 60px -28px rgba(91,213,242,0.55)',
                    minWidth: '160px'
                  }}>
                  <ul className="p-1.5">
                    {Object.entries(v.flows).map(([fKey, f]) => {
                      const isCurrent = vKey === currentVertical && fKey === currentFlowKey;
                      return (
                        <li key={fKey}>
                          <button
                            type="button"
                            onClick={(e) => {
                              e.stopPropagation();
                              setOpen(null);
                              onSelect(vKey, fKey);
                            }}
                            className={`w-full text-left px-3 py-2 rounded-lg text-[12.5px] transition-colors ${
                              isCurrent
                                ? 'bg-cyan/15 text-cyan'
                                : 'text-ink/85 hover:bg-cyan/10 hover:text-cyan'
                            }`}>
                            <span className="flex items-center gap-2">
                              <span className={`size-1 rounded-full ${isCurrent ? 'bg-cyan' : 'bg-dim'}`} />
                              <span className="truncate">{f.name}</span>
                            </span>
                          </button>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ════════════════════════════════════════════════════════════════════
   RIGHT COLUMN COMPONENTS
   ════════════════════════════════════════════════════════════════════ */

/* ════════════════════════════════════════════════════════════════════
   V6 · 4 RIGHT-PANEL STYLES (A · B · C · D)
   Each receives the same props and renders its own visual language.
   ════════════════════════════════════════════════════════════════════ */

/* Short labels for active/history (extract from full text, max ~32 chars) */
function shortCap(text) {
  if (!text) return '';
  const t = text.split('·')[0].trim();
  return t.length > 40 ? t.slice(0, 38) + '…' : t;
}

/* ════════════════════════════════════════════════════════════════════
   V6 ATTENTION FORMAT · 3 NEW PANELS
   A · Stage    (Agent Work Stage · icono central + acción + rastro)
   B · Inbox    (Inbox Reveal · bandeja del agente + email cae)
   C · Split    (Split Result · arriba salida + abajo 4 bloques)
   ════════════════════════════════════════════════════════════════════ */

/* Pick a meaningful icon for the active capability text */
function stageIconFor(text, output) {
  if (output === 'email' || output === 'notice') return '✉';
  if (output === 'follow_up') return '⏱';
  if (!text) return '◐';
  const t = text.toLowerCase();
  if (/audio/.test(t)) return '🎙';
  if (/imagen|documento|dni|pdf|foto|leyo/i.test(t)) return '📄';
  if (/agend|seguimient|programa|reserv|reagend/i.test(t)) return '⏱';
  if (/memori|recuer|retom|histori|contexto/i.test(t)) return '🧠';
  if (/email|deriva|escala|aviso|genera/i.test(t)) return '✉';
  if (/silen|humano|staff|cede/i.test(t)) return '🤝';
  if (/busc|consult|cruz|revis|identific|matche|stock|disponib|filtr/i.test(t)) return '🔎';
  if (/idioma|inglés|ingles|portugu|traduc/i.test(t)) return '🌐';
  if (/urgenc|priorid|frustrac|reclam/i.test(t)) return '⚡';
  if (/inmobiliaria|colega|b2b|comparten/i.test(t)) return '🤝';
  if (/link|publicacion/i.test(t)) return '🔗';
  if (/audio/.test(t)) return '🎙';
  return '◐';
}

/* Make active-text into shorter "action" phrase for Stage */
function stageAction(text) {
  if (!text) return 'Esperando primer mensaje';
  return text.split('·')[0].trim();
}
function stageActionSub(text) {
  if (!text) return 'Las capacidades del agente aparecerán acá durante la conversación.';
  const parts = text.split('·');
  if (parts.length > 1) return parts.slice(1).join('·').trim();
  return '';
}

/* ─── OPTION A · NUEVO (resultado tangible + resumen + potencial) ───
   Las skills ya viven inline en WhatsApp. Acá solo el resultado y el "por qué importa". */
function RightPanelA({ active, history, output, flow, t, duration, noticeEv, activeFollowUp }) {
  const showEmail = output === 'email';
  const showNotice = output === 'notice';
  const showFollowUp = output === 'follow_up';
  const isLive = output === 'live';

  return (
    <div>
      {/* Resultado tangible */}
      <div className="pa-result-slot">
        {showEmail && (
          <>
            <GmailPanel email={flow.email} />
            {flow.output === 'email_then_human' && t >= duration - 1500 && (
              <div className="mt-3"><BotSilentCard /></div>
            )}
          </>
        )}
        {showFollowUp && (
          <FollowUpCard data={activeFollowUp && activeFollowUp.data} state="enter" />
        )}
        {showNotice && <EmailNoticeCard label={noticeEv && noticeEv.label} />}
        {isLive && (
          <div className="pa-live-placeholder">
            <span className="ic" aria-hidden="true">⏱</span>
            <div>El agente está trabajando.</div>
            <div style={{ marginTop: 6, fontSize: 12, color: 'rgba(244,241,234,0.40)' }}>
              La salida tangible (correo, tarjeta de seguimiento o intervención humana) va a aparecer acá.
            </div>
          </div>
        )}
      </div>

      {/* Lo que resolvió */}
      <div className="pa-summary-block">
        <div className="pa-summary-label">
          <span className="ic" aria-hidden="true">✓</span>
          <span>Lo que resolvió</span>
        </div>
        <div className="pa-summary-text">
          {flow.whatItDid || 'El agente atiende el chat, identifica intenciones y deja una salida tangible para el equipo.'}
        </div>
      </div>

      {/* Por qué importa */}
      <div className="pa-summary-block">
        <div className="pa-summary-label alt">
          <span className="ic" aria-hidden="true">★</span>
          <span>Por qué importa</span>
        </div>
        <div className="pa-summary-text">
          {flow.potential || 'Permite atender consultas 24/7 con contexto claro y dejar al equipo con el trabajo avanzado.'}
        </div>
      </div>
    </div>
  );
}

/* ─── B3 · Map a capability text to one of 6 fixed slots ─── */
/* ════════════════════════════════════════════════════════════════════
   V6 INLINE · helpers para skills inline + milestones
   ════════════════════════════════════════════════════════════════════ */

/* Convert capability text → short inline label (≤24 chars).
   Use canonical short forms when text matches keywords, else truncate. */
function inlineSkillLabel(text) {
  if (!text) return '';
  const t = text.toLowerCase();
  // Canonical short forms by keyword
  if (/entend.*audio|audio.*normaliz/i.test(text)) return 'Entendió audio';
  if (/extra.*propiedad|identific.*propiedad/i.test(text)) return 'Extrajo propiedades';
  if (/crm|conecta.*crm/i.test(text)) return 'Consultó CRM';
  if (/revis.*disponib|consulta disponib|stock/i.test(text)) return 'Consultó disponibilidad';
  if (/detect.*no disponib|propiedad no disponib/i.test(text)) return 'Detectó no disponible';
  if (/ofrec.*alternativa|alternativ.*disponib/i.test(text)) return 'Ofreció alternativa';
  if (/aumentar posibilidades|contacto exitoso/i.test(text)) return 'Pide datos clave';
  if (/pid.*dato necesari|pid.*franja|pid.*día|pid.*día|solo el dato/i.test(text)) return 'Pidió dato necesario';
  if (/extrajo.*preferencia|preferencia.*día/i.test(text)) return 'Extrajo preferencia';
  if (/aviso interno|email.*equipo|deriva.*equipo|genera.*email/i.test(text)) return 'Aviso interno';
  if (/agend.*seguimient|seguimient.*24h|seguimient.*automátic/i.test(text)) return 'Agendó seguimiento';
  if (/reagend.*semana|nuevo seguimient/i.test(text)) return 'Reagendó próxima semana';
  if (/retom.*context|recuerd.*context|memoria.*context/i.test(text)) return 'Recordó contexto';
  if (/detect.*link|link.*referencia|identifica.*link/i.test(text)) return 'Entendió link';
  if (/identifica.*propiedad|identific.*casa|publica/i.test(text)) return 'Identificó propiedad';
  if (/responde.*disponib|datos.*propiedad/i.test(text)) return 'Respondió datos';
  if (/detect.*inter.*sin/i.test(text)) return 'Detectó interés';
  if (/detect.*interés.*diferid|nueva.*ventana.*temp/i.test(text)) return 'Detectó interés diferido';
  if (/pregunta.*afinar|afinar.*búsqueda/i.test(text)) return 'Pidió afinación';
  if (/filtra.*result|filtra.*característ/i.test(text)) return 'Filtró resultados';
  if (/varias.*coincid|más de una coincid/i.test(text)) return 'Mandó 2 opciones';
  if (/la primera.*opción|entiende.*primera|refiere.*opción 1/i.test(text)) return 'Entendió "la primera"';
  if (/intención.*visita.*detec|detect.*intención.*visita/i.test(text)) return 'Detectó intención visita';
  if (/detect.*tipo.*usuario|otra inmobiliaria|extrae.*nombre/i.test(text)) return 'Detectó B2B';
  if (/intención.*compartir|extrae el link/i.test(text)) return 'Detectó compartir';
  if (/matchea.*base|matche.*publicación|consulta.*publica/i.test(text)) return 'Matcheó link';
  if (/confirma.*compart|pide.*contact|pide.*datos.*horario/i.test(text)) return 'Confirmó compartir';
  if (/guarda.*horari|preferencia horaria/i.test(text)) return 'Guardó horario';
  if (/cierra expectativa|dispara aviso/i.test(text)) return 'Cerró y avisó';
  if (/consulta general|info.*club/i.test(text)) return 'Detectó consulta';
  if (/responde información.*cargada|información cargada/i.test(text)) return 'Respondió info cargada';
  if (/intención.*inscripción|inscripción.*detect/i.test(text)) return 'Detectó inscripción';
  if (/solicita.*requisitos|requisitos.*docs/i.test(text)) return 'Pidió documentos';
  if (/reconoce.*imagen|reconoce.*foto|frente.*DNI/i.test(text)) return 'Leyó DNI frente';
  if (/valida.*primer doc|pide.*dorso/i.test(text)) return 'Pidió dorso';
  if (/dorso.*DNI|DNI.*argentino/i.test(text)) return 'Validó DNI argentino';
  if (/falta.*constancia|domicilio.*falta/i.test(text)) return 'Pidió constancia';
  if (/reconoce.*PDF|extrae.*dirección/i.test(text)) return 'Leyó PDF · extrajo domicilio';
  if (/confirma.*legajo|legajo.*completo/i.test(text)) return 'Legajo completo';
  if (/preferencia.*contacto/i.test(text)) return 'Extrajo preferencia';
  if (/email.*tesorer|email.*adjunt/i.test(text)) return 'Aviso interno';
  if (/reconoce.*socio|reconoc.*número.*socio/i.test(text)) return 'Reconoció socio';
  if (/recupera.*contexto.*socio|contexto.*asociado/i.test(text)) return 'Recuperó contexto';
  if (/consulta.*horario|consulta.*regla|consulta.*calendario/i.test(text)) return 'Consultó reglas';
  if (/da luz verde|según.*regla.*horario/i.test(text)) return 'Confirmó horario';
  if (/detect.*confirm.*socio|confirma.*socio/i.test(text)) return 'Socio confirmó';
  if (/avisando.*equipo|avis.*equipo/i.test(text)) return 'Aviso al equipo';
  if (/detect.*reclamo|frustrac/i.test(text)) return 'Detectó reclamo';
  if (/historial.*usuario|historial.*última|busca historial/i.test(text)) return 'Recuperó historial';
  if (/política.*48|aplica.*política/i.test(text)) return 'Aplicó política 48h';
  if (/re-aviso|reaviso|generar.*re.aviso/i.test(text)) return 'Generó re-aviso';
  if (/confirma.*usuario.*equipo|equipo.*notificado/i.test(text)) return 'Confirmó al usuario';
  if (/cambio.*idioma|detect.*inglés|detect.*portugu/i.test(text)) return 'Detectó idioma';
  if (/responde.*inglés|responde.*portugu|mantiene.*contexto/i.test(text)) return 'Respondió en idioma';
  if (/membres|tres opciones|premium|basic.*normal/i.test(text)) return 'Informó membresías';
  if (/detect.*decisión.*premium|premium.*decisión/i.test(text)) return 'Detectó Premium';
  if (/solicita horario|franja.*contacto/i.test(text)) return 'Pidió horario';
  if (/retoma.*día.*siguiente|retoma.*portugu|retoma.*inglés/i.test(text)) return 'Retomó al día siguiente';
  if (/saludo normal|naturalmente/i.test(text)) return 'Detectó saludo';
  if (/pregunta.*IA|sobre IA|directa.*IA/i.test(text)) return 'Detectó pregunta IA';
  if (/transparencia|valor.*servicio/i.test(text)) return 'Respondió transparente';
  if (/hablar.*personal|pedido.*humano/i.test(text)) return 'Detectó pedido humano';
  if (/escala|sin fricción|avisar.*staff/i.test(text)) return 'Escaló al staff';
  if (/contacto humano|intervención humana/i.test(text)) return 'Detectó humano';
  if (/silencio|queda.*silencio|no interviene/i.test(text)) return 'Bot en silencio';
  if (/urgencia.*explíc|escala prioridad|⚠/i.test(text)) return 'Detectó urgencia';
  // Fallback: take first ~24 chars before "·"
  const first = text.split('·')[0].trim();
  if (first.length <= 24) return first;
  const words = first.split(' ');
  let result = '';
  for (const w of words) {
    if ((result + ' ' + w).trim().length > 22) break;
    result = result ? result + ' ' + w : w;
  }
  return result + '…';
}

/* Map capability text → milestone key (for the bottom timeline band).
   Keys are matched to flow.milestoneKeys to determine which milestone is touched. */
function milestoneKeyOf(text) {
  if (!text) return null;
  const t = text.toLowerCase();
  if (/silencio|queda.*silencio|no interviene/i.test(text)) return 'silencio';
  if (/contacto humano|intervención humana|humano del/i.test(text)) return 'humano';
  if (/escala|avisar.*staff|sin fricción/i.test(text)) return 'escalo';
  if (/transparencia|valor.*servicio|responde con transparencia/i.test(text)) return 'explico';
  if (/saludo normal|sobre IA|pedido humano|directa.*IA/i.test(text)) return 'detecto';
  if (/decisión.*premium|premium.*decisión/i.test(text)) return 'premium';
  if (/membres|tres opciones|costos|premium incluye|basic/i.test(text)) return 'informo';
  if (/cambio.*idioma|inglés|portugu|idioma|detect.*idioma/i.test(text)) return 'idioma';
  if (/PDF|constancia.*domicilio|extrae.*dirección/i.test(text)) return 'pdf';
  if (/argentino|valida.*DNI|dorso.*DNI/i.test(text)) return 'valido';
  if (/DNI|imagen.*foto|reconoce.*imagen|leyo|leyó/i.test(text)) return 'documento';
  if (/intención.*inscripción|inscripción.*detect|info.*club|consulta general/i.test(text)) return 'intencion';
  if (/re-aviso|reaviso|generar.*aviso/i.test(text)) return 'aviso';
  if (/política.*48|aplica.*política/i.test(text)) return 'explico';
  if (/reclamo|frustrac|historial/i.test(text)) return 'detecto';
  if (/socio confirmó|detect.*confirm|confirma.*viernes/i.test(text)) return 'confirmo';
  if (/luz verde|según.*regla.*horario|confirma.*horario/i.test(text)) return 'confirmo';
  if (/recuperó.*contexto|recupera.*contexto|reconoce.*socio|reconoc.*número/i.test(text)) return 'reconocio';
  if (/reagend.*semana|nuevo seguimient|seguimient.*proxima/i.test(text)) return 'seguimiento';
  if (/agend.*seguimient|seguimient.*24h|seguimient.*automátic/i.test(text)) return 'seguimiento';
  if (/retoma|recuerd.*context|memoria.*context|retom.*día|recordó/i.test(text)) return 'recordo';
  if (/aviso interno|email.*equipo|deriva.*equipo|genera.*email|email.*tesorer|email.*tarjet|legajo.*completo/i.test(text)) return 'aviso';
  if (/agend|propone.*día|coordin|reserva|slot|propon.*opción/i.test(text)) return 'agendo';
  if (/pid|solicita|falta|preferencia|franja|horario|día.*franja|dato faltante/i.test(text)) return 'pidio';
  if (/detect|reconoc|interpreta|frustrac|urgenc|interés|intenc|pedido humano|colega|b2b|compartir|primera/i.test(text)) return 'detecto';
  if (/busc|consult|cruz|revis|identific|matche|extra|valid|stock|disponib|filtra|leyó|leyo|calendari|histori|datos.*propiedad|crm|conecta/i.test(text)) return 'consulto';
  return 'entendio';
}

/* Compute milestones array for a flow at current time */
const MILESTONE_LABELS = {
  entendio: 'Entendió',  consulto: 'Consultó',  detecto: 'Detectó',
  pidio: 'Pidió',         agendo: 'Agendó',     aviso: 'Aviso',
  recordo: 'Recordó',     documento: 'Documento', idioma: 'Idioma',
  humano: 'Humano',       silencio: 'Silencio',  reconocio: 'Reconoció',
  confirmo: 'Confirmó',   premium: 'Premium',    intencion: 'Intención',
  valido: 'Validó',       explico: 'Explicó',    escalo: 'Escaló',
  informo: 'Informó',     seguimiento: 'Seguimiento', pdf: 'PDF',
};

function getMilestonesForFlow(flow, t) {
  const keys = flow.milestoneKeys || ['entendio','consulto','detecto','pidio','aviso'];
  // For each key, find last capability in timeline that matches.
  // Respect an explicit `slot` on the capability event before falling back to the regex.
  return keys.map(key => {
    const matching = flow.timeline.filter(e => e.k === 'capability' && (e.slot || milestoneKeyOf(e.text)) === key);
    if (matching.length === 0) {
      return { key, label: MILESTONE_LABELS[key] || key, state: 'pending' };
    }
    const completedAt = matching[matching.length - 1].at;
    const firstAt = matching[0].at;
    let state;
    if (t >= completedAt) state = 'done';
    else if (t >= firstAt) state = 'active';
    else state = 'pending';
    return { key, label: MILESTONE_LABELS[key] || key, state };
  });
}

/* Component: chip row that appears below a message in the chat */
function ChatSkillRow({ side, caps, t, style }) {
  // Only show capabilities whose at has passed
  const visible = caps.filter(c => t >= c.at);
  if (visible.length === 0) return null;
  // Max 2 chips per message
  const shown = visible.slice(0, 2);
  // Icon content varies by bubble style
  function iconFor(c) {
    if (style === 'note') return 'IA·';
    if (style === 'neon') return '✦';
    return c.tone === 'violet' ? '⚡' : '✓'; // compact (default)
  }
  return (
    <div className={`chat-skill-row ${style || 'compact'} ${side === 'out' ? 'right' : ''}`}>
      {shown.map((c, i) => (
        <span key={i} className={`chat-skill-chip ${c.tone === 'violet' ? 'violet' : ''}`}>
          <span className="ic" aria-hidden="true">{iconFor(c)}</span>
          <span>{c.label || inlineSkillLabel(c.text)}</span>
        </span>
      ))}
    </div>
  );
}

/* Component: bottom milestones band */
function MilestonesBand({ flow, t }) {
  const milestones = useMemo(() => getMilestonesForFlow(flow, t), [flow, t]);
  return (
    <div className="milestones-band">
      <span className="milestones-band-label">Hitos</span>
      {milestones.map((m, i) => (
        <div key={m.key} className="milestone" data-state={m.state}>
          <span className="milestone-dot">
            {m.state === 'done' ? '✓' : m.state === 'active' ? '●' : '·'}
          </span>
          <span>{m.label}</span>
        </div>
      ))}
    </div>
  );
}

const B3_SLOTS = [
  { k: 'entendio', label: 'Entendió' },
  { k: 'consulto', label: 'Consultó' },
  { k: 'detecto',  label: 'Detectó' },
  { k: 'pidio',    label: 'Pidió' },
  { k: 'agendo',   label: 'Agendó' },
  { k: 'aviso',    label: 'Avisó' },
];

function b3SlotFor(text) {
  if (!text) return 'entendio';
  const t = text.toLowerCase();
  // Order matters: most specific first
  if (/genera email|genera ficha|escala|deriva|aviso|reaviso|envia|tarjeta de seguimiento/i.test(t)) return 'aviso';
  if (/agend|seguimient|programa|reserv|reagend|coordin|propon/i.test(t)) return 'agendo';
  if (/pid|solicit|falta|preferencia|franja|horario|día|dato faltante/i.test(t)) return 'pidio';
  if (/detect|reconoc|interpreta|frustrac|urgenc|memoria|recuer|retom|interés|intenc|pedido humano|colega|b2b/i.test(t)) return 'detecto';
  if (/busc|consult|cruz|revis|identific|matche|extra|valid|stock|disponib|filtra|leyó|leyo|calendari|histori/i.test(t)) return 'consulto';
  return 'entendio'; // default for "entendió/audio/imagen/normalizó"
}

/* HorizontalTimeline: 6 fixed nodes that activate based on visible capabilities + output state */
function HorizontalTimeline({ active, history, allCaps, output }) {
  const showNotice = output === 'notice';
  const showEmail = output === 'email';
  const showFollowUp = output === 'follow_up';

  // Compute which slots are touched by visible capabilities
  const visibleCaps = active ? [...history, active] : history;
  const touchedSlots = new Set();
  visibleCaps.forEach(c => touchedSlots.add(b3SlotFor(c.text)));

  // The "active slot" is the one of the latest capability (only during live)
  const activeSlot = (active && output === 'live') ? b3SlotFor(active.text) : null;

  // Decide state per slot
  function stateFor(slotKey) {
    if (slotKey === 'aviso') {
      if (showEmail) return 'done';
      if (showNotice) return 'email_notice';
    }
    if (slotKey === 'agendo' && showFollowUp && !showEmail) return 'active';
    if (slotKey === activeSlot) return 'active';
    if (touchedSlots.has(slotKey)) return 'done';
    return 'pending';
  }

  // Compute prev-done for connector colors
  const states = B3_SLOTS.map(s => stateFor(s.k));
  return (
    <div className="b3-timeline-wrap">
      <div className="b3-timeline-header">
        <span>línea de capacidades</span>
        <span className="live">
          <span className="pulse" />
          {showEmail ? 'correo generado' : showFollowUp ? 'seguimiento activo' : showNotice ? 'preparando aviso' : active ? 'en proceso' : 'esperando'}
        </span>
      </div>
      <div className="b3-timeline">
        {B3_SLOTS.map((slot, i) => {
          const state = states[i];
          const prevDone = i > 0 && (states[i-1] === 'done' || states[i-1] === 'active' || states[i-1] === 'email_notice');
          // Special label for 6th slot when email notice is active
          let label = slot.label;
          if (slot.k === 'aviso') {
            if (showNotice) label = 'Correo nuevo';
            else if (showEmail) label = 'Correo enviado';
          }
          if (slot.k === 'agendo' && showFollowUp) label = 'Seguimiento';
          // Icon inside dot
          let icon = '·';
          if (state === 'done') icon = '✓';
          else if (state === 'active') icon = '●';
          else if (state === 'email_notice') icon = '✉';
          return (
            <div key={slot.k} className="b3-node" data-state={state} data-prev-done={prevDone}>
              <div className="b3-dot">{icon}</div>
              <div className="b3-label">{label}</div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* B3LiveCard: right column placeholder during live (no output yet) */
function B3LiveCard({ active }) {
  return (
    <div className="b3-live-card">
      <div className="b3-live-card-label">
        <span className="pulse" />
        <span>{active ? 'Capacidad en ejecución' : 'Esperando primer mensaje'}</span>
      </div>
      <div className="b3-live-card-icon" key={active ? active.text : 'empty'}>
        {stageIconFor(active && active.text, 'live')}
      </div>
      <div className="b3-live-card-action" key={(active && active.text) + '-act'}>
        {active ? stageAction(active.text) : 'Las capacidades del agente aparecen sincronizadas en la barra superior. La salida tangible (correo, tarjeta, intervención humana) va a aparecer acá.'}
      </div>
    </div>
  );
}

/* ─── OPTION B · TIMELINE VERTICAL (favorita Martín) ─── */
/* Renders the timeline list of capability nodes.
   variant: 'B1' (collapsible when email arrives) | 'B2' (compact for 3-col mode) | 'normal' (default).
   collapsedAtEmail: when true and showEmail, capabilities are tucked behind an accordion. */
function TimelineList({ active, history, allCaps, output, compact, collapsed, onToggleCollapsed }) {
  const showEmail = output === 'email';
  const showNotice = output === 'notice';
  const showFollowUp = output === 'follow_up';

  // Build node list: prioritize last 3 done + active + 2 pending. Same as legacy B.
  const past = history;
  const visibleNodes = [];
  past.slice(-3).forEach(c => visibleNodes.push({ ...c, _state: 'done' }));
  if (active) visibleNodes.push({ ...active, _state: 'active' });
  const visibleIdx = history.length;
  allCaps.slice(visibleIdx + (active ? 1 : 0), visibleIdx + (active ? 3 : 2)).forEach(c => {
    visibleNodes.push({ ...c, _state: 'pending' });
  });

  // Highlight (always visible when email): active + 2 most recent done
  const recentNodes = [];
  past.slice(-2).forEach(c => recentNodes.push({ ...c, _state: 'done' }));
  if (active && (showEmail || showFollowUp || showNotice)) {
    recentNodes.push({ ...active, _state: 'active' });
  } else if (!active && history.length > 0 && (showEmail || showFollowUp)) {
    // last done acts as "result" pointer
    const last = past[past.length - 1];
    if (last) recentNodes[recentNodes.length - 1] = { ...last, _state: 'done' };
  }

  // When collapsed at email, show only recent + summary pill
  if (collapsed && (showEmail || showFollowUp)) {
    return (
      <div className={`panelB ${compact ? 'panelB-compact' : ''}`}>
        <div className="panelB-header">linea de capacidades</div>
        {recentNodes.map((n, i) => (
          <div key={i} className={`panelB-node ${n.tone === 'violet' ? 'violet' : ''}`} data-state={n._state}>
            <span className="dot" />
            <div className="panelB-node-text">
              {n._state === 'done' && <span className="check">✓</span>}
              {shortCap(n.text)}
            </div>
          </div>
        ))}
        <button className="b1-summary-pill" onClick={onToggleCollapsed} aria-expanded={!collapsed}>
          <span className="b1-summary-icon">▾</span>
          <span>Ver capacidades ejecutadas</span>
          <span className="b1-summary-count">{allCaps.length}</span>
        </button>
      </div>
    );
  }

  // Expanded (or default during flow) — show full timeline
  return (
    <div className={`panelB ${compact ? 'panelB-compact' : ''}`}>
      <div className="panelB-header">
        <span>linea de capacidades</span>
        {(showEmail || showFollowUp) && onToggleCollapsed && (
          <button className="b1-collapse-btn" onClick={onToggleCollapsed} aria-label="Contraer">
            <span aria-hidden="true">▴</span> contraer
          </button>
        )}
      </div>
      {visibleNodes.length === 0 ? (
        <div className="panelB-node" data-state="pending">
          <span className="dot" />
          <div className="panelB-node-text">esperando primer mensaje</div>
        </div>
      ) : (
        visibleNodes.map((n, i) => (
          <div key={i} className={`panelB-node ${n.tone === 'violet' ? 'violet' : ''}`} data-state={n._state}>
            <span className="dot" />
            <div className="panelB-node-text">
              {n._state === 'done' && <span className="check">✓</span>}
              {shortCap(n.text)}
            </div>
          </div>
        ))
      )}
    </div>
  );
}

function RightPanelB({ active, history, allCaps, output, flow, t, duration, noticeEv, activeFollowUp, bVariant, collapsed, setCollapsed }) {
  const showEmail = output === 'email';
  const showNotice = output === 'notice';
  const showFollowUp = output === 'follow_up';

  // B2 is rendered as 3 columns at App level when there's output.
  // Here we render the 2-col version: timeline + (notice/email/follow-up below).
  // B1 uses the accordion logic.

  const isB1 = bVariant === 'B1';

  return (
    <>
      {/* Timeline (full or collapsed depending on B1 state) */}
      <TimelineList
        active={active}
        history={history}
        allCaps={allCaps}
        output={output}
        compact={false}
        collapsed={isB1 && collapsed && (showEmail || showFollowUp)}
        onToggleCollapsed={isB1 && (showEmail || showFollowUp) ? (() => setCollapsed(c => !c)) : null}
      />

      {/* Output area (notice / email / follow-up) */}
      {showNotice && (
        <div className="mt-3"><EmailNoticeCard label={noticeEv && noticeEv.label} /></div>
      )}
      {showEmail && (
        <div className="mt-3">
          <GmailPanel email={flow.email} />
          {flow.output === 'email_then_human' && t >= duration - 1500 && (
            <div className="mt-3"><BotSilentCard /></div>
          )}
        </div>
      )}
      {showFollowUp && (
        <div className="mt-3">
          <FollowUpCard data={activeFollowUp && activeFollowUp.data} state="enter" />
        </div>
      )}
    </>
  );
}

/* ─── PANEL C · CENTRO OPERATIVO (V7 · 5 slots + resumen + potencial) ─── */
/* Map capability text → one of 5 slots */
function panelCSlot5(text) {
  if (!text) return 'entendio';
  if (/genera|deriva|escala|aviso|email|tarjeta|reaviso|reserv|envia|seguimient|legajo.*complet/i.test(text)) return 'aviso';
  if (/agend|coordin|propon|slot|visita|pid|solicita|falta|preferencia|franja|horario|día/i.test(text)) return 'pidio';
  if (/detect|reconoc|interpreta|frustrac|urgenc|memoria|recuer|retom|interés|intenc|colega|b2b|compartir|primera|silenci|humano/i.test(text)) return 'detecto';
  if (/busc|consult|cruz|revis|identific|matche|extra|valid|stock|disponib|filtra|leyó|leyo|calendari|histori|crm|conecta/i.test(text)) return 'consulto';
  return 'entendio';
}

function RightPanelC({ active, history, output, flow, t, duration, noticeEv, activeFollowUp }) {
  const showEmail = output === 'email';
  const showNotice = output === 'notice';
  const showFollowUp = output === 'follow_up';

  // Aggregate latest text per slot from visible capabilities.
  // Respect an explicit `slot` on the capability event before falling back to the regex.
  const visible = active ? [...history, active] : history;
  const slots = { entendio: null, consulto: null, detecto: null, pidio: null, aviso: null };
  for (const c of visible) {
    const s = c.slot || panelCSlot5(c.text);
    if (slots.hasOwnProperty(s)) slots[s] = c;
  }
  const activeSlot = active ? (active.slot || panelCSlot5(active.text)) : null;
  const order = ['entendio', 'consulto', 'detecto', 'pidio', 'aviso'];
  const labels = {
    entendio: 'Entendió',
    consulto: 'Consultó',
    detecto: 'Detectó / Decidió',
    pidio: 'Pidió / Coordinó',
    // For follow-up flows the last block represents the scheduled follow-up, not an email.
    aviso: showFollowUp ? 'Seguimiento' : 'Generó / Avisó',
  };
  const icons = { entendio: '👁', consulto: '🔎', detecto: '⚡', pidio: '📅', aviso: showFollowUp ? '⏱' : '✉' };

  /* Header del panel removido (brief 2026-05-16): el título "Centro operativo
     del agente" vive ahora dentro de los controles arriba; el meta textual
     del header también queda eliminado.
     `data-compact` activa modo compacto en CSS cuando hay email/seguimiento/aviso
     para liberar espacio y que el resultado entre mejor en pantalla. */
  const isCompact = showEmail || showFollowUp || showNotice;
  return (
    <div className="panelC" data-compact={isCompact ? 'true' : 'false'}>
      {/* 5 bloques de proceso */}
      {order.map(slot => {
        const cap = slots[slot];
        let state = 'idle';
        if (cap) state = (slot === activeSlot && (output === 'live' || output === 'follow_up')) ? 'active' : 'done';
        return (
          <div key={slot} className="panelC-block" data-state={state}>
            <div className="panelC-block-key">
              <span className="ic" aria-hidden="true">{icons[slot]}</span>
              <span>{labels[slot]}</span>
            </div>
            <div className={`panelC-block-val ${cap ? '' : 'empty'}`}>
              {cap ? cap.text : 'pendiente'}
            </div>
          </div>
        );
      })}

      {/* Resultado tangible */}
      <div className="panelC-output-slot">
        {showEmail && (
          <>
            <GmailPanel email={flow.email} />
            {flow.output === 'email_then_human' && t >= duration - 1500 && (
              <div className="mt-3"><BotSilentCard /></div>
            )}
          </>
        )}
        {showFollowUp && (
          <FollowUpCard
            key={activeFollowUp.at}
            data={activeFollowUp.data}
            state="enter"
            fresh={true}
          />
        )}
        {showNotice && <EmailNoticeCard label={noticeEv && noticeEv.label} />}
      </div>

      {/* Resumen + Potencial removidos (brief 2026-05-16): "Lo que resolvió"
          y "Por qué importa" viven ahora en <AgentResolvedSummarySection/>
          inmediatamente después del V7. Los datos `whatItDid` / `potential`
          de cada flow se conservan en CASES_V4 para reutilización futura. */}
    </div>
  );
}

/* V3 polish ADDENDUM 32 (2026-05-19) — Mobile milestone history.
   Componente mobile-only que renderiza el historial de hitos LOGRADOS
   debajo de la barra de controles. En desktop está oculto por CSS
   (`.mobile-milestone-history { display: none }` fuera de @media mobile).
   Reusa la misma lógica de slot mapping que RightPanelC para garantizar
   consistencia entre la card activa y el historial.

   Reglas brief Addendum 32:
   - Solo muestra slots con capability REAL (no idle/pendiente).
   - Excluye el slot ACTIVE actual (vive arriba en la card activa).
   - Cuando termina el flujo y aparece email/follow-up/notice, `active`
     es null y todos los slots con capability se vuelven done → siguen
     visibles aquí abajo como logros acontecidos.
   - Chips compactos con check cyan, texto corto, dark/glass premium. */
function MobileMilestoneHistory({ history, active, output }) {
  const visible = active ? [...history, active] : history;
  const slots = { entendio: null, consulto: null, detecto: null, pidio: null, aviso: null };
  for (const c of visible) {
    const s = c.slot || panelCSlot5(c.text);
    if (slots.hasOwnProperty(s)) slots[s] = c;
  }
  const activeSlot = active ? (active.slot || panelCSlot5(active.text)) : null;
  const order = ['entendio', 'consulto', 'detecto', 'pidio', 'aviso'];
  const labels = {
    entendio: 'Entendió',
    consulto: 'Consultó',
    detecto: 'Detectó',
    pidio: 'Pidió',
    aviso: output === 'follow_up' ? 'Seguimiento' : 'Avisó',
  };
  const doneSlots = order.filter(slot =>
    slots[slot] && !(slot === activeSlot && (output === 'live' || output === 'follow_up'))
  );
  if (doneSlots.length === 0) return null;
  return (
    <div className="mobile-milestone-history" aria-label="Historial de capacidades logradas">
      <div className="mobile-milestone-history-label">Historial</div>
      <div className="mobile-milestone-history-list">
        {doneSlots.map(slot => (
          <div key={slot} className="mobile-milestone-chip">
            <span className="mobile-milestone-chip-check" aria-hidden="true">✓</span>
            <span className="mobile-milestone-chip-label">{labels[slot]}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

/* Capability Spotlight — the ACTIVE capability gets visual protagonism */
function CapabilitySpotlight({ icon, tone, text, label }) {
  const isViolet = tone === 'violet';
  return (
    <div className={`cap-spotlight ${isViolet ? 'violet' : ''}`} key={text}>
      <div className="cap-spotlight-label">
        <span className="live-dot" />
        <span>{label || 'Capacidad en ejecución'}</span>
      </div>
      <div className="cap-spotlight-main">
        <span className="cap-spotlight-icon">{icon}</span>
        <p className="cap-spotlight-text">{text}</p>
      </div>
    </div>
  );
}

/* Capability History — past capabilities as compact ticks */
function CapabilityHistory({ caps, label }) {
  return (
    <div className="cap-history">
      <div className="cap-history-label">
        <span>{label || 'capacidades ejecutadas'}</span>
        <span className="cap-history-count">{caps.length}</span>
      </div>
      {caps.length === 0 ? (
        <div className="cap-history-empty">aún no se ejecutó ninguna · esperando primer mensaje</div>
      ) : (
        <div className="cap-history-list">
          {caps.map((c, i) => (
            <span key={i} className="cap-history-pill" style={{ animationDelay: `${i * 40}ms` }}>
              <span className="check">✓</span> {c.shortText || c.text}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

/* Capability Live Stage — combines spotlight (active) + history (past) */
function CapabilityLiveStage({ active, history }) {
  if (!active) {
    return (
      <div className="cap-spotlight" style={{ borderStyle:'dashed', opacity:0.6 }}>
        <div className="cap-spotlight-label">
          <span className="live-dot" />
          <span>Esperando primer mensaje</span>
        </div>
        <div className="cap-spotlight-main">
          <span className="cap-spotlight-icon" style={{ opacity:0.6 }}>·</span>
          <p className="cap-spotlight-text" style={{ color:'rgba(244,241,234,0.55)' }}>
            Las capacidades del agente van a aparecer acá a medida que avanza la conversación.
          </p>
        </div>
      </div>
    );
  }
  return (
    <>
      <CapabilitySpotlight icon={active.icon} tone={active.tone} text={active.text} />
      <CapabilityHistory caps={history} label="capacidades ya ejecutadas" />
    </>
  );
}

function CapabilityCard({ icon, tone, text, state }) {
  return (
    <div className={`cap-card ${tone === 'violet' ? 'violet' : ''}`} data-state={state}>
      <div className="flex items-start gap-3">
        <span className="cap-icon">{icon}</span>
        <p className="text-[13.5px] leading-[1.5] text-ink/92">{text}</p>
      </div>
    </div>
  );
}

function EmailNoticeCard({ label, sub }) {
  return (
    <div className="email-notice-v6">
      <div className="email-notice-v6-icon" aria-hidden="true">✉</div>
      <div className="email-notice-v6-title">Correo nuevo</div>
      <div className="email-notice-v6-sub">{label || sub || 'Aviso interno generado'}</div>
      <div className="email-notice-v6-prep" aria-hidden="true">
        <span className="dot" /><span className="dot" /><span className="dot" />
      </div>
    </div>
  );
}

function FollowUpCard({ data, state, fresh }) {
  const order = ['motivo','propiedad','planes','idioma','cuando','estado','condicion'];
  const labels = {
    motivo:'Motivo', propiedad:'Propiedad', planes:'Planes',
    idioma:'Idioma', cuando:'Cuándo', estado:'Estado', condicion:'Condición',
  };
  return (
    <div className={`followup-card ${fresh ? 'is-fresh' : ''}`} data-state={state}>
      <div className="flex items-start gap-3 mb-4">
        <div className="followup-icon"><span aria-hidden="true">⏱</span></div>
        <div className="flex-1 min-w-0">
          <div className="text-[10.5px] font-mono uppercase tracking-[0.18em] text-mute mb-1">salida tangible</div>
          <div className="text-[18px] font-serif text-ink leading-tight" style={{ fontFamily:'"Instrument Serif", serif' }}>
            {data.title}
          </div>
        </div>
        <span className="followup-status"><span className="dot" /> {data.estado || 'Activo'}</span>
      </div>
      <div>
        {order.map(k => data[k] && k !== 'estado' && (
          <div key={k} className="followup-field">
            <span className="k">{labels[k]}</span>
            <span className="v">{data[k]}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function ResolvedCard({ note }) {
  return (
    <div className="resolved-card">
      <div className="resolved-icon">✓</div>
      <div className="flex-1 min-w-0">
        <div className="text-[14px] font-medium text-ink">Resuelto sin escalar</div>
        <div className="text-[12px] text-mute mt-0.5">{note}</div>
      </div>
    </div>
  );
}

function BotSilentCard() {
  return (
    <div className="silent-card">
      <div className="silent-icon">🔇</div>
      <div className="flex-1 min-w-0">
        <div className="text-[14px] font-medium text-ink">El agente queda en silencio</div>
        <div className="text-[12px] text-mute mt-0.5">una persona del equipo está atendiendo · el bot detectó la intervención humana y no responde</div>
      </div>
    </div>
  );
}

/* V3 polish ADDENDUM 23 (2026-05-18) — Mapping de labels mobile.
   Mobile abrevia labels largos de email/output para evitar cortes y
   apretado en 390 px. Desktop muestra el label completo. */
const MOBILE_LABEL_MAP = {
  'Próximo paso': 'Sigue',
  'Más info': 'Info',
  'Propiedad elegida': 'Propiedad',
  'Características': 'Detalles',
  'Acción tomada': 'Acción',
  'Tiempo estimado': 'Tiempo',
  'Origen del contacto': 'Origen'
};
function mobileLabel(k) { return MOBILE_LABEL_MAP[k] || k; }
const MOBILE_CTA_MAP = {
  'Abrir conversación en WhatsApp': 'Abrir WhatsApp'
};
function mobileCta(t) { return MOBILE_CTA_MAP[t] || t; }

function GmailPanel({ email }) {
  return (
    <div className="gm-shell">
      <div className="gm-header">
        <div className="size-9 rounded-full bg-gradient-to-br from-cyan to-violet flex items-center justify-center text-white text-[12px] font-mono">NG</div>
        <div className="flex-1 min-w-0">
          <div className="text-[13.5px] text-[#202124] font-medium">{email.from}</div>
          <div className="text-[12px] text-[#5F6368]">{email.meta}</div>
        </div>
        <div className="flex flex-wrap items-center gap-2 justify-end">
          {email.labels.map((l, i) => (
            <span key={i} className={`gm-chip ${l.tone || ''}`}>{l.k}</span>
          ))}
        </div>
      </div>
      <div className="gm-subject-row">
        <div className="gm-subject">{email.subject}</div>
        {email.cta && (
          <button className="gm-cta-inline">
            <span aria-hidden="true" className="hidden md:inline">📱</span>
            <span className="hidden md:inline">{email.cta}</span>
            <span className="md:hidden">{mobileCta(email.cta)}</span>
          </button>
        )}
      </div>
      <div className="gm-meta"><span className="font-medium text-[#202124]">Para:</span> {email.to}</div>
      <div className="gm-body">{email.body}</div>
      <div className="gm-fields">
        {email.fields
          /* V3 polish ADDENDUM 34 (2026-05-19) — Si el email ya tiene CTA
             ("Abrir WhatsApp"), el field "Más info: Abrir conversación en
             WhatsApp" duplica el call-to-action en la fila de fields. Lo
             filtramos para evitar la duplicación y darle más espacio al
             resto. Aplica a todos los emails con cta. */
          .filter(([k]) => !(email.cta && k === 'Más info'))
          .map(([k, v], i) => (
            <div key={i} className="row">
              <span className="k">
                <span className="hidden md:inline">{k}</span>
                <span className="md:hidden">{mobileLabel(k)}</span>
              </span>
              <span className="v">{v}</span>
            </div>
          ))}
      </div>
      {email.attachments && email.attachments.length > 0 && (
        <div className="px-[20px] pb-3 flex flex-wrap">
          {email.attachments.map((a, i) => (
            <span key={i} className="gm-attachment">
              <span className={`icon ${a.kind === 'pdf' ? 'pdf' : ''}`}>{a.icon}</span>
              <span><div className="font-medium">{a.name}</div><div className="text-[10.5px] text-[#5F6368]">{a.size}</div></span>
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

function TicksBelowOutput({ summary, label }) {
  return (
    <div className="ticks-block">
      <div className="flex items-baseline justify-between mb-2.5">
        <div className="flex items-center gap-2">
          <span className="size-1.5 rounded-full bg-cyan" style={{ boxShadow:'0 0 0 3px rgba(51,194,234,0.18)' }} />
          <span className="kicker text-[10px]">{label || 'capacidades ejecutadas en este flujo'}</span>
        </div>
        <span className="text-[11px] text-mute">{summary.length}</span>
      </div>
      <div>
        {summary.map((s, i) => (
          <span key={i} className="tick-pill" style={{ animationDelay: `${i * 60 + 320}ms` }}>
            <span className="check">✓</span> {s}
          </span>
        ))}
      </div>
    </div>
  );
}

/* ════════════════════════════════════════════════════════════════════
   APP
   ════════════════════════════════════════════════════════════════════ */

/* Pausa antes de pasar automáticamente al siguiente flow una vez que el
   flujo actual terminó (email, follow-up o final natural de timeline). */
const AUTO_ADVANCE_DELAY_MS = 12500;

function HeroDemoV7Full() {
  const [vertical, setVertical] = useState('inmobiliaria');
  const [flowKey, setFlowKey] = useState(CASES_V4.inmobiliaria.defaultFlow);
  /* V3 polish ADDENDUM 42 (2026-05-19) — el demo arranca PAUSADO al mount
     y un IntersectionObserver lo destranca cuando la pantalla de
     WhatsApp (.wa-shell) está mayormente visible en el viewport.
     `autoStartedRef` garantiza que esto pase una sola vez en la vida del
     componente; después, cambios manuales de flow (chips, ←/→, swipe)
     respetan el playing=true como antes. */
  const [playing, setPlaying] = useState(false);
  const autoStartedRef = useRef(false);
  const [speed, setSpeed] = useState(1); // V6 attention-format: default x1 (más lento, lectura calma)
  // V7: Centro Operativo (C) es el único panel derecho (decisión final Martín)
  const rightPanelStyle = 'C';
  const bVariant = null; // no aplica
  const bubbleStyle = 'compact'; // V7 final: explicación compacta fija, sin selector en vivo
  const [b1Collapsed, setB1Collapsed] = useState(true); // default colapsado al llegar email
  const [t, setT] = useState(0);
  const chatScrollRef = useRef(null);

  const vData = CASES_V4[vertical];
  const safeFlowKey = vData.flows[flowKey] ? flowKey : vData.defaultFlow;
  const flow = vData.flows[safeFlowKey];
  const duration = flow.duration;

  const rows = useMemo(() => buildRows(flow.timeline), [vertical, safeFlowKey]);

  const dateDividers = useMemo(
    () => flow.timeline.filter(e => e.k === 'date_divider' || e.k === 'time_jump'),
    [vertical, safeFlowKey]
  );

  const followUpEvents = useMemo(
    () => flow.timeline.filter(e => e.k === 'follow_up' || e.k === 'follow_up_update'),
    [vertical, safeFlowKey]
  );

  const noticeEv = useMemo(
    () => flow.timeline.find(e => e.k === 'email_notice'),
    [vertical, safeFlowKey]
  );
  const emailEv = useMemo(
    () => flow.timeline.find(e => e.k === 'email'),
    [vertical, safeFlowKey]
  );

  // Active follow_up data (latest follow_up event before now)
  const activeFollowUp = useMemo(() => {
    const visible = followUpEvents.filter(e => t >= e.at);
    return visible[visible.length - 1];
  }, [followUpEvents, t]);

  // Capabilities derived from timeline — for live stage (active + history)
  const allCapabilities = useMemo(
    () => flow.timeline.filter(e => e.k === 'capability'),
    [vertical, safeFlowKey]
  );
  const visibleCapabilities = useMemo(
    () => allCapabilities.filter(c => t >= c.at),
    [allCapabilities, t]
  );
  const activeCapability = visibleCapabilities[visibleCapabilities.length - 1];
  const pastCapabilities = visibleCapabilities.slice(0, -1);

  const showNotice = noticeEv && t >= noticeEv.at && (!emailEv || t < emailEv.at);
  const showEmail = emailEv && t >= emailEv.at;
  const showFollowUp = activeFollowUp && !showEmail && !showNotice;

  const onTick = useCallback((delta) => {
    setT((prev) => {
      const nt = prev + delta;
      if (nt >= duration) { setPlaying(false); return duration; }
      return nt;
    });
  }, [duration]);

  useAnimationFrame(playing, speed, onTick);

  // Reset timeline when vertical or flow changes (also reset B1 collapse to default).
  // ADDENDUM 42: el primer mount NO autoarranca aquí; espera al
  // IntersectionObserver. A partir del segundo render (cambio manual de flow
  // por chips, ←/→, swipe, o auto-advance), sí autoarranca normalmente.
  useEffect(() => {
    setT(0);
    setB1Collapsed(true);
    if (autoStartedRef.current) {
      setPlaying(true);
    }
  }, [vertical, safeFlowKey]);

  // ADDENDUM 42: gate de visibilidad. El demo arranca solo cuando .wa-shell
  // está mayormente visible en el viewport. Umbral 0.85 si el shell entra
  // entero en el viewport; 0.6 si el shell es más alto que el viewport
  // (mobile con shell 820px puede no llegar a 0.85). Si IntersectionObserver
  // no está disponible o el elemento no se encuentra, fallback a arrancar
  // tras 500ms para no quedar pausado para siempre.
  useEffect(() => {
    if (autoStartedRef.current) return;
    if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
      autoStartedRef.current = true;
      setPlaying(true);
      return;
    }
    let observer = null;
    let fallbackTimer = null;
    const trigger = () => {
      if (autoStartedRef.current) return;
      autoStartedRef.current = true;
      setPlaying(true);
      if (observer) observer.disconnect();
    };
    const tryAttach = () => {
      if (autoStartedRef.current) return;
      const el = document.querySelector('.v7-demo-section .wa-shell');
      if (!el) {
        // Si aún no se montó .wa-shell, retry corto + fallback de 500ms.
        fallbackTimer = window.setTimeout(trigger, 500);
        return;
      }
      const shellTallerThanViewport = el.clientHeight > window.innerHeight * 0.85;
      const wantRatio = shellTallerThanViewport ? 0.6 : 0.85;
      observer = new IntersectionObserver((entries) => {
        for (const e of entries) {
          if (e.intersectionRatio >= wantRatio) {
            trigger();
            break;
          }
        }
      }, { threshold: [Math.max(0.3, wantRatio - 0.2), wantRatio] });
      observer.observe(el);
    };
    tryAttach();
    return () => {
      if (observer) observer.disconnect();
      if (fallbackTimer) window.clearTimeout(fallbackTimer);
    };
  }, []);

  // Atomic switchers (avoid race conditions when changing vertical)
  function switchVertical(key) {
    const def = CASES_V4[key].defaultFlow;
    setVertical(key);
    setFlowKey(def);
    setT(0);
    setPlaying(true);
  }
  function switchFlow(key) {
    setFlowKey(key);
    setT(0);
    setPlaying(true);
  }

  function advanceToNextFlow() {
    const flowKeys = Object.keys(vData.flows);
    const currentIndex = flowKeys.indexOf(safeFlowKey);
    if (currentIndex >= 0 && currentIndex < flowKeys.length - 1) {
      switchFlow(flowKeys[currentIndex + 1]);
      return;
    }

    const verticalIndex = VERTICAL_ORDER.indexOf(vertical);
    const nextVertical = VERTICAL_ORDER[(verticalIndex + 1) % VERTICAL_ORDER.length];
    switchVertical(nextVertical);
  }

  /* V3 polish ADDENDUM 30 (2026-05-19) — previousFlow simétrico a
     advanceToNextFlow. Si hay flow anterior dentro del vertical, retrocede;
     si está en el primero del vertical actual, salta al vertical previo
     (wrap-around al último) y va al ÚLTIMO flow de ese vertical para que la
     navegación se sienta lineal. */
  function previousFlow() {
    const flowKeys = Object.keys(vData.flows);
    const currentIndex = flowKeys.indexOf(safeFlowKey);
    if (currentIndex > 0) {
      switchFlow(flowKeys[currentIndex - 1]);
      return;
    }

    const verticalIndex = VERTICAL_ORDER.indexOf(vertical);
    const prevVerticalIndex = (verticalIndex - 1 + VERTICAL_ORDER.length) % VERTICAL_ORDER.length;
    const prevVertical = VERTICAL_ORDER[prevVerticalIndex];
    const prevFlowKeys = Object.keys(CASES_V4[prevVertical].flows);
    const lastFlowKey = prevFlowKeys[prevFlowKeys.length - 1];
    setVertical(prevVertical);
    setFlowKey(lastFlowKey);
    setT(0);
    setPlaying(true);
  }

  /* Auto-advance: cuando el flujo termina (t alcanza duration y la reproducción
     se detuvo naturalmente desde el tick), esperar AUTO_ADVANCE_DELAY_MS antes
     de pasar al siguiente flow. Cubre flujos que terminan en email, follow-up
     y follow_up_update — antes solo dependía de showEmail y `seguimiento_link`
     quedaba clavado. Cleanup automático si el usuario cambia vertical/flow,
     toca replay, pausa antes del final o vuelve a reproducir desde t=0. */
  const isFlowComplete = duration > 0 && t >= duration && !playing;
  useEffect(() => {
    if (!isFlowComplete) return;
    const timer = window.setTimeout(() => {
      advanceToNextFlow();
    }, AUTO_ADVANCE_DELAY_MS);
    return () => window.clearTimeout(timer);
  }, [isFlowComplete, vertical, safeFlowKey]);

  useEffect(() => {
    const el = chatScrollRef.current;
    if (!el) return;
    const isMobile = window.matchMedia('(max-width: 767px)').matches;
    const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
    if (!isMobile && distFromBottom > 80) return;
    const followLatest = () => {
      el.scrollTo({ top: el.scrollHeight, behavior: isMobile ? 'auto' : 'smooth' });
    };
    requestAnimationFrame(followLatest);
  }, [t, safeFlowKey, vertical]);

  function handlePlay() {
    if (t >= duration) { setT(0); setPlaying(true); return; }
    setPlaying(p => !p);
  }
  function handleReplay() { setT(0); setPlaying(true); }
  function handleScrub(newT) { setT(Math.max(0, Math.min(duration, newT))); }

  /* V3 polish ADDENDUM 35 (2026-05-19) — Reposicionar viewport al cambiar
     de caso manualmente en mobile. Antes: tras tocar ←/→, el scroll
     quedaba en la zona inferior (panelC + controles) porque ahí estaba el
     dedo. Ahora se hace scroll suave hacia el inicio del chat
     (.wa-shell), dejando visibles tabs + flow chips + FLUJO ACTIVO header
     y el comienzo del WhatsApp del nuevo caso. Solo aplica en mobile;
     desktop queda intacto. No se llama desde auto-advance ni desde el
     tick del demo, sólo desde los botones manuales. */
  function scrollDemoToChatStart() {
    if (typeof window === 'undefined') return;
    if (!window.matchMedia('(max-width: 767px)').matches) return;
    const target =
      document.querySelector('.v7-demo-section .wa-shell') ||
      document.querySelector('.v7-demo-section');
    if (!target) return;
    const NAV_OFFSET = 90;
    const top = target.getBoundingClientRect().top + window.scrollY - NAV_OFFSET;
    window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
  }
  function handleNextCase() {
    advanceToNextFlow();
    /* Programar el scroll por DOS vías para máxima robustez:
       1) Doble rAF: ideal — el primer frame deja a React commit-ear el
          cambio de flow, el segundo asegura layout estable.
       2) setTimeout 120ms: fallback en caso de que rAF no dispare a
          tiempo (e.g., tab background, scheduler de React 18 demora).
       Ambos llaman a la misma función; si rAF ya scrolleó, el setTimeout
       hace un scrollTo al mismo target = no-op. */
    requestAnimationFrame(() => {
      requestAnimationFrame(scrollDemoToChatStart);
    });
    window.setTimeout(scrollDemoToChatStart, 120);
  }
  function handlePreviousCase() {
    previousFlow();
    requestAnimationFrame(() => {
      requestAnimationFrame(scrollDemoToChatStart);
    });
    window.setTimeout(scrollDemoToChatStart, 120);
  }

  /* V3 polish ADDENDUM 38 (2026-05-19) — Swipe horizontal sobre WhatsApp
     para cambiar de caso. Threshold: |dx| >= 48 y |dx| > |dy| * 1.25 →
     dispara navegación. Si predomina el componente vertical, NO se
     dispara para no bloquear scroll del chat. Una sola navegación por
     gesto (se resetea al touchstart). */
  const waSwipeRef = useRef({ x: 0, y: 0, active: false });
  const onWaShellTouchStart = useCallback((e) => {
    const t = e.touches && e.touches[0];
    if (!t) return;
    waSwipeRef.current = { x: t.clientX, y: t.clientY, active: true };
  }, []);
  const onWaShellTouchEnd = useCallback((e) => {
    const start = waSwipeRef.current;
    if (!start || !start.active) return;
    waSwipeRef.current.active = false;
    const t = e.changedTouches && e.changedTouches[0];
    if (!t) return;
    const dx = t.clientX - start.x;
    const dy = t.clientY - start.y;
    const adx = Math.abs(dx);
    const ady = Math.abs(dy);
    if (adx < 48) return;
    if (adx <= ady * 1.25) return;
    if (dx < 0) handleNextCase();
    else handlePreviousCase();
  }, []);
  const onWaShellTouchCancel = useCallback(() => {
    waSwipeRef.current.active = false;
  }, []);

  // Decide ticks label based on output type
  const ticksLabel = showEmail
    ? 'capacidades ejecutadas para generar este correo'
    : showFollowUp
      ? 'capacidades ejecutadas hasta el seguimiento'
      : 'capacidades ejecutadas en este flujo';

  /* Controles arriba del Centro Operativo · Pro Max layout:
     - card glass para integrar visualmente con el panel inferior
     - sin MilestonesBand (retirada por brief 2026-05-16)
     - Scrubber discreto debajo de los botones · sigue siendo útil para seek */
  const timelineControls = (
    <div className="v7-timeline-controls rounded-2xl border border-line bg-bg/40 backdrop-blur-md px-4 py-3 md:px-5 md:py-4 mb-3 md:mb-4"
      style={{ WebkitBackdropFilter: 'blur(10px) saturate(110%)' }}>
      <div className="v7-control-row flex items-center gap-3 md:gap-4 flex-wrap mb-3">
        <button onClick={handlePlay} aria-label={playing ? 'Pausar' : 'Reproducir'}
          className="v7-control-btn v7-control-primary size-10 rounded-full bg-cyan/15 border border-cyan/40 text-cyan flex items-center justify-center hover:bg-cyan/25 transition-colors">
          {playing ? (
            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
              <rect x="6.5" y="5" width="3.5" height="14" rx="0.8" />
              <rect x="14" y="5" width="3.5" height="14" rx="0.8" />
            </svg>
          ) : (
            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
              <path d="M7.5 5.5 L18 12 L7.5 18.5 Z" />
            </svg>
          )}
        </button>
        <button onClick={handleReplay} aria-label="Replay"
          className="v7-control-btn size-10 rounded-full border border-cyan/30 text-cyan/85 hover:text-cyan hover:border-cyan/55 hover:bg-cyan/8 transition-colors flex items-center justify-center">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.85" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
            <path d="M20 12a8 8 0 1 1-2.34-5.66" />
            <polyline points="20 4 20 9 15 9" />
          </svg>
        </button>

        {/* Controles manuales de caso: icon-only en todos los tamaños. */}
        <button onClick={handlePreviousCase} aria-label="Caso anterior"
          className="v7-control-btn v7-case-nav size-10 rounded-full border border-cyan/30 text-cyan/85 hover:text-cyan hover:border-cyan/55 hover:bg-cyan/8 transition-colors flex items-center justify-center flex-shrink-0">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.85" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
            <line x1="19" y1="12" x2="5" y2="12" />
            <polyline points="11 18 5 12 11 6" />
          </svg>
        </button>
        <button onClick={handleNextCase} aria-label="Siguiente caso"
          className="v7-control-btn v7-case-nav size-10 rounded-full border border-cyan/30 text-cyan/85 hover:text-cyan hover:border-cyan/55 hover:bg-cyan/8 transition-colors flex items-center justify-center flex-shrink-0">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.85" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
            <line x1="5" y1="12" x2="19" y2="12" />
            <polyline points="13 6 19 12 13 18" />
          </svg>
        </button>

        {/* Título del Centro Operativo · vive en los controles desde brief 2026-05-16.
            Hidden en mobile · visible en md+. Sin `truncate` · `whitespace-nowrap`
            evita romper la frase a mitad; el `flex-wrap` del padre permite que
            todo el bloque caiga a una segunda línea si el ancho aprieta, en
            lugar de cortar el texto. Tracking reducido a 0.16em para acortar
            ancho total sin perder mono-uppercase feel. */}
        <div className="hidden md:flex items-center gap-2 px-2">
          <span className="size-1.5 rounded-full bg-cyan flex-shrink-0" aria-hidden="true" />
          <span className="text-[10.5px] font-mono uppercase tracking-[0.16em] text-ink/85 whitespace-nowrap">
            Centro operativo del agente
          </span>
        </div>

        <div className="v7-speed-control flex items-center gap-1 ml-auto text-[10.5px] font-mono text-dim">
          <span className="mr-1 uppercase tracking-[0.18em]">velocidad</span>
          {[1, 1.5, 2].map(s => (
            <button key={s} onClick={() => setSpeed(s)} data-active={speed === s}
              className="spd px-2 py-0.5 rounded-full border border-line text-mute">{s}x</button>
          ))}
        </div>
      </div>

      <div className="v7-scrubber-wrap">
        <Scrubber t={t} duration={duration} timeline={flow.timeline} onScrub={handleScrub} />
      </div>
    </div>
  );

  return (
    /* V3 polish ADDENDUM 20 (2026-05-18) — clase `v7-demo-section` agregada
       SOLO para que el CSS mobile (en hero-v7-full.css `@media (max-width:
       767px)`) pueda scopearse a esta sección sin afectar otros componentes
       con `py-12` o `header`. Desktop intacto. */
    <section className="py-12 md:py-16 v7-demo-section">
      <div className="mx-auto max-w-[1640px] px-6 md:px-10 xl:px-14">

        {/* ─── HEADER ─── */}
        <header className="mb-8">
          <div className="kicker mb-3">Empleados IA en operación</div>
          <h1 className="display-lg text-[clamp(34px,3.6vw,56px)] max-w-[1000px] mb-3">
            Tu negocio respondiendo, vendiendo y avisándole al equipo, <span className="italic">mientras dormís</span>.
          </h1>
          <p className="text-[15.5px] text-mute max-w-[720px] leading-[1.55] mb-6">
            Mirá cómo un compañero IA entiende audios, mensajes y documentos; consulta tu CRM, toma decisiones y deja cada próximo paso listo para tu equipo.
          </p>

          {/* Vertical tabs — DESKTOP (md+). En mobile reemplazado por
              MobileBizSelector debajo. */}
          <div className="hidden md:flex flex-wrap gap-2 mb-4" role="tablist">
            {VERTICAL_ORDER.map((key) => {
              const v = CASES_V4[key];
              const active = key === vertical;
              return (
                <button key={key} role="tab" aria-selected={active} data-active={active}
                  onClick={() => switchVertical(key)}
                  className="vtab inline-flex items-center gap-2 px-4 py-2 rounded-full border border-line text-[13px] text-mute hover:text-ink hover:border-ink/30">
                  <span className="vtab-dot size-1.5 rounded-full bg-dim" />
                  <span>{v.label}</span>
                  <span className="text-dim font-mono text-[10px] tracking-[0.14em] ml-1">· {Object.keys(v.flows).length} flujos</span>
                </button>
              );
            })}
          </div>

          {/* Flow chips — DESKTOP (md+). En mobile reemplazado por dropdown. */}
          <div className="hidden md:flex flex-wrap gap-1.5">
            {Object.entries(vData.flows).map(([key, f]) => {
              const active = key === safeFlowKey;
              return (
                <button key={key} data-active={active}
                  onClick={() => switchFlow(key)}
                  className="fchip inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-line text-[12px] text-dim hover:text-ink hover:border-ink/20">
                  <span className="fdot size-1 rounded-full bg-dim" />
                  <span>{f.name}</span>
                </button>
              );
            })}
          </div>

          {/* V3 polish ADDENDUM 23 (2026-05-18) — Selector mobile compacto.
              Antes: dos vtabs (Inmobiliaria · 4 flujos / Club · 6 flujos)
              en filas + 4-6 fchips en fila aparte → 2 filas, etiqueta "4
              flujos" / "6 flujos" innecesaria.
              Ahora: 2 botones lado a lado (Inmobiliaria / Club), cada uno
              abre un panel dropdown con sus cases adentro. Más compacto,
              touch-friendly, sin redundancia "X flujos". */}
          <MobileBizSelector
            verticalOrder={VERTICAL_ORDER}
            cases={CASES_V4}
            currentVertical={vertical}
            currentFlowKey={safeFlowKey}
            onSelect={(vKey, fKey) => {
              if (vKey !== vertical) switchVertical(vKey);
              if (fKey) {
                // small delay to let switchVertical race-update flow defaults first
                setTimeout(() => switchFlow(fKey), 0);
              }
            }}
          />

          {/* Active flow caption */}
          <div className="mt-4 flex items-center gap-3 text-[12.5px] text-mute flex-wrap">
            <span className="font-mono uppercase tracking-[0.18em] text-dim text-[10.5px]">flujo activo</span>
            <span className="text-ink/90">{flow.name}</span>
            <span className="text-dim">·</span>
            <span className="italic">{flow.sub}</span>
          </div>
          {/* timelineControls movido a la columna derecha, arriba del Centro Operativo (brief 2026-05-16) */}
        </header>

        {/* ─── DEMO STAGE (V7: 2 columnas · Panel C único) ─── */}
        {(() => {
          const outputMode = showEmail ? 'email' : showNotice ? 'notice' : showFollowUp ? 'follow_up' : 'live';
          const commonProps = {
            active: activeCapability,
            history: pastCapabilities,
            allCaps: allCapabilities,
            output: outputMode,
            flow, t, duration, noticeEv,
            activeFollowUp,
          };
          return (
            <div className="demo-stage two-col">
              {/* LEFT: WhatsApp chat (skills inline compactas) */}
              <div className="demo-chat-col">
                {/* ADDENDUM 38: swipe horizontal cambia de caso en mobile.
                    El threshold (48 px + ratio 1.25 hor/ver) garantiza que
                    el scroll vertical del chat NO dispara la navegación. */}
                <div className="wa-shell flex flex-col" style={{ height: 820 }}
                  onTouchStart={onWaShellTouchStart}
                  onTouchEnd={onWaShellTouchEnd}
                  onTouchCancel={onWaShellTouchCancel}>
                  <WAHeader name={flow.chat.name} sub={flow.chat.sub} initials={flow.chat.initials} />
                  <div ref={chatScrollRef} className="flex-1 wa-bg stable-scroll py-3">
                    {dateDividers.map((d, i) => (
                      t >= d.at ? <WADateDivider key={`d${i}`} label={d.label} /> : null
                    ))}
                    {rows.map((row) => {
                      const rowAt = rowFirstAt(row);
                      if (t < rowAt) return null;
                      const rowCaps = row.events.filter(e => e.k === 'capability');
                      // Determine side: last visible non-capability event in row
                      let side = 'in';
                      for (let k = row.events.length - 1; k >= 0; k--) {
                        const ev = row.events[k];
                        if (ev.k === 'capability' || t < ev.at) continue;
                        if (ev.k === 'bot_msg' || ev.k === 'human_msg') { side = 'out'; break; }
                        if (ev.k === 'user_msg' || ev.k === 'user_audio' || ev.k === 'user_image' || ev.k === 'user_pdf') { side = 'in'; break; }
                      }
                      return (
                        <React.Fragment key={row.rowId}>
                          {row.events.filter(e => t >= e.at).map((ev, i) => {
                            if (ev.k === 'user_msg')  return <WABubble key={i} side="in" text={ev.text} tm={ev.tm} linkify={ev.linkify} />;
                            if (ev.k === 'bot_msg')   return <WABubble key={i} side="out" text={ev.text} tm={ev.tm} linkify={ev.linkify} />;
                            if (ev.k === 'user_audio')return <WAAudio key={i} dur={ev.dur} tm={ev.tm} transcript={ev.transcript} />;
                            if (ev.k === 'user_image')return <WAImage key={i} name={ev.name} size={ev.size} tm={ev.tm} />;
                            if (ev.k === 'user_pdf')  return <WAPdf key={i} name={ev.name} size={ev.size} tm={ev.tm} />;
                            if (ev.k === 'staff_banner') return <WAStaffBanner key={i} text={ev.text} />;
                            if (ev.k === 'human_msg') return <WABubble key={i} side="in" text={ev.text} tm={ev.tm} staff={true} />;
                            if (ev.k === 'bot_typing') {
                              const botMsg = row.events.find(e => e.k === 'bot_msg');
                              if (botMsg && t >= botMsg.at) return null;
                              return <WATyping key={i} />;
                            }
                            return null;
                          })}
                          {rowCaps.length > 0 && <ChatSkillRow side={side} caps={rowCaps} t={t} style={bubbleStyle} />}
                        </React.Fragment>
                      );
                    })}
                  </div>
                  <WAInputBar />
                </div>
              </div>

              {/* RIGHT: controles + Centro Operativo (Panel C · único) */}
              <div className="demo-right-col right-stage">
                {timelineControls}
                <RightPanelC {...commonProps} />
                {/* V3 polish ADDENDUM 32 (2026-05-19) — Historial de hitos
                    logrados mobile-only debajo de la barra de controles.
                    En desktop el componente se monta pero queda con
                    `display: none` vía CSS, así no genera DOM duplicado. */}
                <MobileMilestoneHistory
                  history={pastCapabilities}
                  active={activeCapability}
                  output={outputMode}
                />
              </div>
            </div>
          );
        })()}

        {/* Bloque viejo de controles + MilestonesBand + Scrubber (className="hidden")
            removido por brief 2026-05-16: los controles ahora viven arriba del
            Centro Operativo y la banda de hitos se retiró del demo. */}
      </div>
    </section>
  );
}

/* ════════════════════════════════════════════════════════════════════
   SCRUBBER (no fill, just thumb + ticks)
   ════════════════════════════════════════════════════════════════════ */

function Scrubber({ t, duration, timeline, onScrub }) {
  const trackRef = useRef(null);
  const [dragging, setDragging] = useState(false);

  const ticks = useMemo(() => {
    if (!duration) return [];
    return timeline
      .filter(e => ['user_msg','bot_msg','user_audio','user_image','user_pdf','time_jump','date_divider','follow_up','follow_up_update','email_notice','email','human_msg'].includes(e.k))
      .map(e => ({ at: e.at, pct: e.at / duration }));
  }, [timeline, duration]);

  const pct = duration ? Math.min(100, (t / duration) * 100) : 0;

  function handlePointer(clientX) {
    if (!trackRef.current) return;
    const r = trackRef.current.getBoundingClientRect();
    const x = Math.max(0, Math.min(r.width, clientX - r.left));
    onScrub((x / r.width) * duration);
  }

  function onDown(e) { setDragging(true); handlePointer(e.clientX); }
  useEffect(() => {
    if (!dragging) return;
    function mv(e) { handlePointer(e.clientX); }
    function up() { setDragging(false); }
    window.addEventListener('mousemove', mv);
    window.addEventListener('mouseup', up);
    return () => { window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up); };
  }, [dragging]);

  return (
    <div className="flex items-center gap-3 select-none">
      <div ref={trackRef} className="scrub-track flex-1" onMouseDown={onDown}>
        {ticks.map((tk, i) => (
          <div key={i} className="scrub-tick" data-passed={t >= tk.at} style={{ left: `${tk.pct * 100}%` }} />
        ))}
        <div className="scrub-thumb" style={{ left: `${pct}%` }} />
      </div>
      <div className="text-[11px] font-mono text-mute tabular w-[88px] text-right">
        {fmtTime(t)} <span className="text-dim">/ {fmtTime(duration)}</span>
      </div>
    </div>
  );
}

  window.HeroDemoV7Full = HeroDemoV7Full;
})();
