// multimedia.jsx — Videos de campaña para el Estado de WhatsApp. // // El punto de esta pantalla: un LINK en un Estado de WhatsApp no reproduce nada, // queda como texto con miniatura. Por eso acá no se comparte un link: se le pasa // el ARCHIVO al teléfono, y el menú nativo de compartir se lo entrega a WhatsApp. // // Detalle que decide el diseño: en iOS, navigator.share() sólo funciona si se // llama dentro del gesto del dedo. Por eso el video se descarga ANTES (al abrir // la ficha) y el botón sólo se habilita cuando ya está en memoria. (function () { const TABLA = 'multimedia_campana'; const TABLA_USO = 'multimedia_uso'; // En la base la marca va sin tilde y sin apóstrofe, igual que // productos_catalogo.catalogo, para que un cruce por marca entre las dos // tablas no se rompa. El acento se pone acá, al mostrar. El orden de las // pestañas es fijo y no depende de lo que traiga la base: una lista que se // reordena sola según qué se cargó cambia de lugar bajo el dedo. const ORDEN_MARCAS = ['Esika', 'CyZone', 'Lbel']; const NOMBRE_MARCA = { Esika: 'Ésika', CyZone: 'CyZone', Lbel: "L'Bel" }; const nombreMarca = m => NOMBRE_MARCA[m] || m || ''; // Límites reales del Estado de WhatsApp. Si un video los pasa, se avisa — // no se deja que la vendedora lo descubra cuando ya publicó. const LIMITE_SEG = 60; const LIMITE_BYTES = 16 * 1024 * 1024; // ── capacidad del teléfono ───────────────────────────── // No se promete "poner en tu estado" si el navegador no sabe compartir // archivos. En ese caso el botón dice lo que de verdad va a hacer. function puedeCompartirArchivos() { try { if (typeof navigator === 'undefined' || !navigator.canShare || !navigator.share) return false; const prueba = new File([new Blob(['x'], { type: 'video/mp4' })], 'prueba.mp4', { type: 'video/mp4' }); return navigator.canShare({ files: [prueba] }); } catch (_) { return false; } } function fmtDuracion(seg) { if (!seg && seg !== 0) return null; const m = Math.floor(seg / 60); const s = Math.floor(seg % 60); return `${m}:${String(s).padStart(2, '0')}`; } function fmtPeso(bytes) { if (!bytes) return null; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } async function registrarUso(multimediaId, vendedoraId, via, detalle) { // Silencio no es lo mismo que "no pasó nada": sin este registro, una pantalla // que nadie usa y una pantalla rota se ven iguales. try { const sb = await window.getSupabase(); if (!sb) return; await sb.from(TABLA_USO).insert({ multimedia_id: multimediaId, vendedora_id: vendedoraId || null, via, detalle: detalle ? String(detalle).slice(0, 300) : null, }); } catch (_) { /* el registro nunca puede romper la pantalla */ } } // ── ficha de un video ────────────────────────────────── function VideoSheet({ video, vendedora, onClose }) { const T = window.T; const compartible = React.useMemo(puedeCompartirArchivos, []); const [archivo, setArchivo] = React.useState(null); // File listo en memoria const [pct, setPct] = React.useState(0); const [estado, setEstado] = React.useState('cargando'); // cargando | listo | error | enviando | ok const [error, setError] = React.useState(''); const abortRef = React.useRef(null); // Descarga anticipada: al terminar, el botón se habilita. React.useEffect(() => { let vivo = true; const ctrl = new AbortController(); abortRef.current = ctrl; (async () => { try { setEstado('cargando'); setPct(0); const res = await fetch(video.video_url, { signal: ctrl.signal }); if (!res.ok) throw new Error(`El video no respondió (${res.status})`); const total = Number(res.headers.get('content-length')) || video.peso_bytes || 0; let blob; if (res.body && res.body.getReader && total > 0) { const reader = res.body.getReader(); const trozos = []; let leido = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; trozos.push(value); leido += value.length; if (vivo) setPct(Math.min(99, Math.round((leido / total) * 100))); } blob = new Blob(trozos, { type: 'video/mp4' }); } else { blob = await res.blob(); } if (!vivo) return; const nombre = `${(video.titulo || 'video').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'video'}.mp4`; setArchivo(new File([blob], nombre, { type: 'video/mp4' })); setPct(100); setEstado('listo'); } catch (e) { if (!vivo || e.name === 'AbortError') return; setError(e.message || 'No se pudo traer el video'); setEstado('error'); registrarUso(video.id, vendedora?.id, 'fallo', e.message); } })(); return () => { vivo = false; ctrl.abort(); }; }, [video.id, video.video_url]); // Este handler NO puede tener await antes del share: iOS exige que la // llamada salga dentro del gesto. Por eso el archivo ya está listo acá. function alTocarBoton() { if (!archivo) return; if (compartible) { setEstado('enviando'); navigator.share({ files: [archivo], title: video.titulo || 'Inova' }) .then(() => { setEstado('ok'); registrarUso(video.id, vendedora?.id, 'compartido'); }) .catch((e) => { setEstado('listo'); // Cancelar no es un fallo: ella cambió de opinión. if (e && e.name === 'AbortError') return; setError(e?.message || 'El teléfono no aceptó el video'); registrarUso(video.id, vendedora?.id, 'fallo', e?.message); }); return; } // Sin compartir nativo: se descarga y ella lo sube desde la galería. const url = URL.createObjectURL(archivo); const a = document.createElement('a'); a.href = url; a.download = archivo.name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); setEstado('ok'); registrarUso(video.id, vendedora?.id, 'descargado'); } const muyLargo = video.duracion_seg && video.duracion_seg > LIMITE_SEG; const muyPesado = (video.peso_bytes || archivo?.size || 0) > LIMITE_BYTES; const etiquetaBoton = estado === 'cargando' ? `Preparando el video… ${pct}%` : estado === 'enviando' ? 'Abriendo WhatsApp…' : estado === 'ok' ? (compartible ? '✓ Listo, elige Estado en WhatsApp' : '✓ Video guardado en tu teléfono') : compartible ? 'Poner en mi estado' : 'Descargar el video'; return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, zIndex: 90, background: 'rgba(20,14,12,0.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', }} >
{video.titulo}
{[nombreMarca(video.marca), fmtDuracion(video.duracion_seg), fmtPeso(video.peso_bytes)] .filter(Boolean).join(' · ')}
{(muyLargo || muyPesado) && (
{muyLargo &&
Dura más de {LIMITE_SEG} segundos. WhatsApp lo va a cortar.
} {muyPesado &&
Pesa más de 16 MB. WhatsApp puede rechazarlo.
}
)} {estado === 'error' && (
{error}
)}
{compartible ? 'Se abre el menú de tu teléfono. Toca WhatsApp y después Estado.' : 'El video queda en tu galería. Ábrelo desde WhatsApp › Estado.'}
); } // ── pantalla ─────────────────────────────────────────── // El boton fisico "atras" del telefono. La app es una sola pagina y nunca // escribe historial, asi que ese boton salia de la app entera en vez de // volver al inicio. // // Medido el 20-ago-2026 en un telefono de 843 px de alto: con 96 videos la // cuadricula mide 18.553 px -- 22 pantallas. La unica salida era una palabra // de 13 px alla arriba. Por eso se reporta como "entre y no puedo salir": // no faltaba el boton, faltaba poder alcanzarlo. // // Dos niveles: si hay una hoja de video abierta, atras cierra la hoja; si no, // cierra la pantalla. Al salir por el boton de arriba se retira la entrada // que empujamos, para no comerse el siguiente "atras" de la vendedora. function useBotonAtras(hayHoja, cerrarHoja, cerrarPantalla) { const ref = React.useRef(null); ref.current = { hayHoja, cerrarHoja, cerrarPantalla }; React.useEffect(() => { let nuestra = true; window.history.pushState({ inovaMultimedia: true }, ''); const alVolver = () => { nuestra = false; const s = ref.current; if (s.hayHoja) { s.cerrarHoja(); nuestra = true; window.history.pushState({ inovaMultimedia: true }, ''); } else { s.cerrarPantalla(); } }; window.addEventListener('popstate', alVolver); return () => { window.removeEventListener('popstate', alVolver); if (nuestra) window.history.back(); }; }, []); } function MultimediaScreen({ vendedora, campanaId, onClose }) { const T = window.T; const [videos, setVideos] = React.useState(null); // null = cargando const [campana, setCampana] = React.useState(campanaId || null); const [abierto, setAbierto] = React.useState(null); const [fallo, setFallo] = React.useState(''); const [marca, setMarca] = React.useState(null); // null = todas useBotonAtras(!!abierto, () => setAbierto(null), onClose); React.useEffect(() => { let vivo = true; (async () => { try { const sb = await window.getSupabase(); if (!sb) throw new Error('Sin conexión'); // 20-ago-2026: NO se confía en campanaId. Viene de catalogCampaign en // index.html, que arranca con el literal 'C12' y sólo se corrige // cuando termina de cargar la configuración de campañas. // // Medido hoy en la app corriendo: en el arranque en frío // getCatalogCampaignId() devuelve 'C12', y esta pantalla pedía los // videos de C12 — mostraba "Todavía no hay videos de C12" con 19 // videos cargados en C13. La vendedora lee eso como que no hay, y se // va. Se curaba sola al cargar la configuración, pero para entonces // ya se fue. // // Cuál campaña tiene el catálogo a la vista es un dato de la base, no // del estado de React. Se pregunta. El prop queda de respaldo por si // la consulta falla — mejor una campaña dudosa que ninguna. let camp = null; const { data: visibles } = await sb.from('campanas') .select('id').eq('catalogo_visible', true).eq('cerrada', false) .order('id', { ascending: false }).limit(1); camp = (visibles && visibles[0]) ? visibles[0].id : (campanaId || null); if (!vivo) return; setCampana(camp); let q = sb.from(TABLA).select('*').eq('activo', true).order('orden', { ascending: true }); if (camp) q = q.eq('campana_id', camp); const { data, error } = await q; if (error) throw error; if (!vivo) return; setVideos(data || []); } catch (e) { if (!vivo) return; setFallo(e.message || 'No se pudieron cargar los videos'); setVideos([]); } })(); return () => { vivo = false; }; }, [campanaId]); // 20-ago-2026: pestañas por marca. Con 19 videos se podía barrer la // cuadrícula entera; con ~96 no. La vendedora que hoy va a publicar Ésika // no quiere scrollear 60 videos de otras marcas para llegar. // Sólo aparece la marca que tiene videos. const marcas = React.useMemo(() => { if (!videos) return []; const hay = new Set(videos.map(v => v.marca).filter(Boolean)); const conocidas = ORDEN_MARCAS.filter(m => hay.has(m)); const otras = [...hay].filter(m => !ORDEN_MARCAS.includes(m)).sort(); return [...conocidas, ...otras]; }, [videos]); // Si la marca elegida se queda sin videos (cambió la campaña), se vuelve a // "todas" en lugar de mostrar una cuadrícula vacía con una pestaña marcada. React.useEffect(() => { if (marca && !marcas.includes(marca)) setMarca(null); }, [marcas, marca]); const enPantalla = (videos && marca) ? videos.filter(v => v.marca === marca) : videos; const cuentaDe = m => (videos || []).filter(v => v.marca === m).length; const pastilla = (activa) => ({ padding: '9px 15px', borderRadius: 999, cursor: 'pointer', border: `1px solid ${activa ? T.ink : T.hairlineStrong}`, background: activa ? T.ink : 'transparent', color: activa ? T.paper : T.inkSoft, fontFamily: T.mono, fontSize: 10.5, letterSpacing: 1.2, textTransform: 'uppercase', whiteSpace: 'nowrap', flex: '0 0 auto', }); return (
{campana || ''}
Para tu estado
Videos listos para subir a tu Estado de WhatsApp. Un toque y el video pasa a tu teléfono.
{marcas.length > 1 && (
{marcas.map(m => ( ))}
)} {videos === null && (
Cargando…
)} {videos && videos.length === 0 && (
Todavía no hay videos {campana ? `de ${campana}` : ''}
{fallo || 'Cuando se carguen los de esta campaña, aparecen acá.'}
)} {videos && videos.length > 0 && (
{enPantalla.map(v => ( ))}
)} {abierto && ( setAbierto(null)} /> )}
); } Object.assign(window, { MultimediaScreen, MultimediaVideoSheet: VideoSheet }); })();