// 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 (
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á.'}