Sonando
▶ Sonando
Cola de peticiones
↓ Pide una canción al DJ ↓
Tus Favoritas (Max 3)

Escribe arriba para pedirle una canción al DJ.

0
Revisar pedido
$ 0
Tocar para revisar
Recibo ${escapeHtml(receipt.folio || '')}
${bizLogo ? `
` : ''}${bizName ? `
${escapeHtml(bizName)}
` : ''}

Cuenta cobrada

${escapeHtml(receipt.folio || '')}
Mesa: ${escapeHtml(receipt.tableName || tableId || '')}
Cliente: ${escapeHtml(receipt.customerName || 'Cliente')}
Fecha: ${escapeHtml(receipt.createdAt ? new Date(receipt.createdAt).toLocaleString() : new Date().toLocaleString())}
${items.map(item => `
${Number(item.quantity || 0)}x ${escapeHtml(item.name || '')}
${money(item.unitPrice || 0)} c/u
${money(Number(item.quantity || 0) * Number(item.unitPrice || 0))}
`).join('')}
Subtotal${money(receipt.subtotal || 0)}
${Number(receipt.discount || 0) > 0 ? `
Descuento-${money(receipt.discount)}
` : ''}
Total pagado${money(receipt.total || 0)}
${paymentRows || 'Pago registrado'}
¡Gracias por tu visita!
NO ES FACTURA ELECTRÓNICA DE VENTA
Documento equivalente - Tiquete de Caja
Art. 616-2 E.T. · No somos grandes contribuyentes
No somos responsables de IVA
`; } // Convierte una imagen (misma-origen) a data URI base64 para embeberla. async function _logoToDataUrl(src) { try { const abs = absoluteAssetUrl(src); if (!abs) return ''; if (abs.startsWith('data:')) return abs; const res = await fetch(abs, { cache: 'force-cache' }); if (!res.ok) return ''; const blob = await res.blob(); return await new Promise((resolve) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = () => resolve(''); fr.readAsDataURL(blob); }); } catch (_e) { return ''; } } async function downloadClientReceipt(receipt = {}) { // Embeber el logo como data URI para que el recibo descargado lo muestre // siempre (offline / sin depender de una URL remota que puede fallar). const logoData = await _logoToDataUrl(receipt.businessLogo || LOGO_SRC); const receiptForHtml = logoData ? { ...receipt, businessLogo: logoData } : receipt; const blob = new Blob([clientReceiptHtml(receiptForHtml)], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `recibo-${String(receipt.folio || receipt.saleId || Date.now()).replace(/[^\w.-]+/g, '-')}.html`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function handleReceiptPaidNotification(notification) { const receipt = notification?.meta || {}; const receiptKey = `shownReceipt:${notification?.id || receipt.saleId || receipt.folio || Date.now()}`; if (sessionStorage.getItem(receiptKey) === '1') return; sessionStorage.setItem(receiptKey, '1'); if (tableId) localStorage.setItem(`lastReceipt:${branchId}:${tableId}`, JSON.stringify(receipt)); const bizLogo = receipt.businessLogo || LOGO_SRC; const bizName = receipt.businessName || window.__businessName || 'Negocio'; Swal.fire({ icon: 'success', title: `${escapeHtml(bizName)}`, html: `

${escapeHtml(notification?.message || 'Tu cuenta ya fue cobrada.')}

Total: ${money(receipt.total || 0)}

`, showCancelButton: true, confirmButtonText: 'Descargar recibo', cancelButtonText: 'Cerrar', background: '#161b22', color: '#fff' }).then(result => { if (result.isConfirmed) downloadClientReceipt(receipt); }); } window.downloadClientReceipt = downloadClientReceipt; function paymentMethodIcon(value) { return { cash: 'fa-money-bill-wave', card: 'fa-credit-card', transfer: 'fa-building-columns', mixed: 'fa-wallet' }[value] || 'fa-money-bill-wave'; } function renderWelcomeModal() { const body = document.getElementById('welcomeModalBody'); if (!body) return; const paymentMethods = ['cash', 'card', 'transfer', 'mixed']; body.innerHTML = `
Mesa ${escapeHtml(tableId || '')}

Antes de ver el menú

Elige cómo pedir y cómo deseas pagar.

${accountMode === 'individual' ? `
${savedCustomers.length ? `` : ''}
` : ''}
Método de pago
${paymentMethods.map(m => `
`).join('')}
${allProducts.length ? 'Menú listo ✔' : 'Cargando menú...'}
`; } window.renderWelcomeModal = renderWelcomeModal; function renderOrderStartScreen() { if (!welcomeModalInst) { welcomeModalInst = new bootstrap.Modal(document.getElementById('welcomeModal')); } renderWelcomeModal(); welcomeModalInst.show(); const grid = document.getElementById('productsGrid'); if (!grid) return; const searchBox = document.getElementById('productSearch')?.closest('.mb-3'); const categoryRail = document.getElementById('categoryRail'); if (searchBox) searchBox.classList.add('d-none'); if (categoryRail) categoryRail.classList.add('d-none'); } function saveCustomerFromInput() { const input = document.getElementById('customerNameInput'); const name = String(input?.value || '').trim().slice(0, 40); if (!name) { quickToast('Escribe un nombre', 'warning'); return; } customerName = name; if (!savedCustomers.includes(name)) { savedCustomers.push(name); localStorage.setItem(`tableCustomers:${branchId}:${tableId}`, JSON.stringify(savedCustomers)); } localStorage.setItem(`customerName:${branchId}:${tableId}`, customerName); accountProfileReady = true; localStorage.setItem(`accountProfileReady:${branchId}:${tableId}`, '1'); renderAccountModeUI(); quickToast(`Usuario guardado: ${name}`, 'success'); } function saveCustomerFromStart() { const input = document.getElementById('startCustomerNameInput'); const name = String(input?.value || '').trim().slice(0, 40); if (!name) { quickToast('Escribe un nombre', 'warning'); return; } customerName = name; if (!savedCustomers.includes(name)) { savedCustomers.push(name); localStorage.setItem(`tableCustomers:${branchId}:${tableId}`, JSON.stringify(savedCustomers)); } localStorage.setItem(`customerName:${branchId}:${tableId}`, customerName); renderAccountModeUI(); renderOrderStartScreen(); quickToast(`Usuario guardado: ${name}`, 'success'); } function continueToMenu() { if (accountMode === 'individual') { const input = document.getElementById('welcomeNameInput') || document.getElementById('startCustomerNameInput'); const typed = String(input?.value || customerName || '').trim().slice(0, 40); if (!typed) { quickToast('Escribe o selecciona el usuario', 'warning'); return; } customerName = typed; if (!savedCustomers.includes(typed)) { savedCustomers.push(typed); localStorage.setItem(`tableCustomers:${branchId}:${tableId}`, JSON.stringify(savedCustomers)); } localStorage.setItem(`customerName:${branchId}:${tableId}`, customerName); } accountProfileReady = true; localStorage.setItem(`accountProfileReady:${branchId}:${tableId}`, '1'); if (welcomeModalInst) welcomeModalInst.hide(); renderAccountModeUI(); document.getElementById('productSearch')?.closest('.mb-3')?.classList.remove('d-none'); document.getElementById('categoryRail')?.classList.remove('d-none'); renderCategoryRail(); applyProductFilters(); } function isAccountProfileComplete() { return accountMode === 'table' || !!customerName.trim(); } window.setAccountMode = setAccountMode; window.setCustomerName = setCustomerName; window.setPaymentMethod = setPaymentMethod; window.saveCustomerFromInput = saveCustomerFromInput; window.saveCustomerFromStart = saveCustomerFromStart; window.continueToMenu = continueToMenu; window.renderOrderStartScreen = renderOrderStartScreen; const API_BASE_URL = (location.hostname === 'localhost' || location.hostname === '127.0.0.1' || location.hostname === 'puntodeventaconga.pages.dev') ? '' : 'https://puntodeventaconga.pages.dev'; // URL Administrador a la URL de tu backend (ej: 'https://api.tudominio.com') al subir a Cloudflare async function apiFetch(url, opts = {}) { url = API_BASE_URL + url; const shouldAttachToken = token && !url.includes('/api/products'); if (!opts.headers) opts.headers = {}; opts.headers['x-branch-id'] = branchId; if (window.CLIENT_TENANT_SLUG) opts.headers['x-tenant-slug'] = window.CLIENT_TENANT_SLUG; opts.cache = url.includes('/api/products') ? 'default' : 'no-store'; // Add token to query or body if (shouldAttachToken) { if (url.includes('?')) url += `&token=${token}`; else url += `?token=${token}`; } const response = await fetch(url, opts); // 403 securityError: limpiamos el token guardado y dejamos que el // siguiente intento bootstrappee una sesión nueva. Ya NO recargamos // la página ni mostramos modal bloqueante: el QR impreso debe poder // funcionar siempre aunque el token impreso haya caducado. if (response.status === 403) { try { const data = await response.clone().json(); if (data && data.securityError) { if (tableId) { localStorage.removeItem(`tableToken:${branchId}:${tableId}`); } token = null; } } catch (_e) { /* no-op */ } } return response; } function switchTab(t) { currentTab = t; document.querySelectorAll('[id^="sec-"]').forEach(s => s.classList.add('d-none')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.getElementById(`sec-${t}`).classList.remove('d-none'); document.getElementById(`tab-${t}`).classList.add('active'); if (t === 'account') loadAccount(); if (t === 'music') { loadNowPlaying(); loadMusicVs(); } } // Allow deep-linking to a tab from QR/URL, e.g. ?tab=music const initialTab = urlParams.get('tab'); if (initialTab && ['menu', 'music', 'account'].includes(initialTab)) { switchTab(initialTab); } function getProductCategories() { const cats = Array.from(new Set(allProducts.map(p => String(p.category || '').trim()).filter(Boolean))).sort(); return [...cats, 'Todos']; } function renderCategoryRail() { const rail = document.getElementById('categoryRail'); const categories = getProductCategories(); rail.innerHTML = categories.map(category => ` `).join(''); } function applyProductFilters() { const query = normalizeText(currentProductQuery); const items = allProducts.filter((product) => { const category = String(product.category || '').trim(); const matchesCategory = currentCategory === 'Todos' || category === currentCategory; const haystack = normalizeText(`${product.name || ''} ${product.category || ''}`); const matchesQuery = !query || haystack.includes(query); return matchesCategory && matchesQuery; }); renderProducts(items); } function filterCat(category) { currentCategory = category || 'Todos'; renderCategoryRail(); applyProductFilters(); } window.filterCat = filterCat; let _priorityFee = 5000; async function init() { tableModal = new bootstrap.Modal(document.getElementById('tableSelectorModal')); apiFetch('/api/settings/music').then(r => r.ok ? r.json() : null).then(s => { if (s && s.priorityFee != null) _priorityFee = Number(s.priorityFee) || 5000; }).catch(() => {}); updateTableLabel(); renderAccountModeUI(); if (!tableId) { document.getElementById('productsGrid').innerHTML = `

Escanea el QR de tu mesa

Para ver el menú y pedir, por favor escanea el código que está en tu mesa.

`; return; } if (!accountProfileReady) renderOrderStartScreen(); document.getElementById('productSearch').addEventListener('input', (e) => { currentProductQuery = e.target.value || ''; clearTimeout(productSearchTimer); productSearchTimer = setTimeout(() => applyProductFilters(), 120); }); document.getElementById('musicInput').addEventListener('input', (e) => { clearTimeout(musicTimer); const value = String(e.target.value || '').trim(); if (!value) { renderMusicEmpty(); return; } // Show loading immediately to clear previous "Escribe arriba..." text document.getElementById('musicResults').innerHTML = `

Buscando en vivo...

`; musicTimer = setTimeout(() => searchMusic(value), 450); }); try { // Use cached products only if cache is fresh (< 5 min) to avoid showing stale/deleted items const _tenantCacheKey = `clientProducts:${window.CLIENT_TENANT_SLUG || 'conga'}:${branchId}`; // Limpiar caché del tenant anterior (sin slug) para evitar mezcla de datos localStorage.removeItem(`clientProducts:${branchId}`); localStorage.removeItem(`clientProducts:${branchId}:ts`); const cacheRaw = localStorage.getItem(_tenantCacheKey); const cacheTs = Number(localStorage.getItem(`${_tenantCacheKey}:ts`) || 0); const cacheAgeMs = Date.now() - cacheTs; const cachedProducts = cacheRaw ? JSON.parse(cacheRaw) : []; if (Array.isArray(cachedProducts) && cachedProducts.length && cacheAgeMs < 5 * 60 * 1000) { allProducts = cachedProducts; const cachedCategories = getProductCategories(); currentCategory = cachedCategories.length > 0 ? cachedCategories[0] : 'Todos'; renderCategoryRail(); if (accountProfileReady) applyProductFilters(); else renderOrderStartScreen(); } const res = await apiFetch('/api/products'); const productsPayload = await res.json().catch(() => []); if (!res.ok || !Array.isArray(productsPayload)) { throw new Error(productsPayload?.error || `HTTP ${res.status}`); } allProducts = productsPayload; productsLoadedAt = Date.now(); localStorage.setItem(_tenantCacheKey, JSON.stringify(allProducts.slice(0, 400))); localStorage.setItem(`${_tenantCacheKey}:ts`, String(Date.now())); const categories = getProductCategories(); currentCategory = categories.length > 0 ? categories[0] : 'Todos'; renderCategoryRail(); if (accountProfileReady) applyProductFilters(); else renderOrderStartScreen(); } catch (e) { if (Array.isArray(allProducts) && allProducts.length) { if (accountProfileReady) applyProductFilters(); else renderOrderStartScreen(); return; } document.getElementById('productsGrid').innerHTML = `
No se pudo cargar el menú.
`; } renderMusicEmpty(); renderFavorites(); } async function showTableSelector() { quickToast('Escanea el QR de tu mesa para iniciar', 'info'); } function selectTable(id) { quickToast('La mesa solo se activa escaneando el QR', 'info'); } window.selectTable = selectTable; window.showTableSelector = function() { quickToast('Escanea el QR de tu mesa para iniciar', 'info'); }; window.switchTab = switchTab; function productCardHtml(p) { const effectiveStock = getProductEffectiveStock(p); const isTracked = p.trackStock !== false; const isOutOfStock = isTracked && effectiveStock <= 0; const cartQty = getCartProductQuantity(p.id); const reachedLimit = isTracked && cartQty >= effectiveStock; const stockText = !isTracked ? 'Disponible' : (isOutOfStock ? 'Producto agotado' : 'Disponible'); return ` `; } function appendProductBatch(token) { if (token !== menuRenderToken) return; const grid = document.getElementById('productsGrid'); if (!grid) return; const nextItems = visibleProducts.slice(renderedProductCount, renderedProductCount + MENU_BATCH_SIZE); if (!nextItems.length) return; grid.insertAdjacentHTML('beforeend', nextItems.map(productCardHtml).join('')); renderedProductCount += nextItems.length; if (renderedProductCount < visibleProducts.length) { const scheduler = window.requestIdleCallback || ((cb) => setTimeout(cb, 16)); scheduler(() => appendProductBatch(token)); } } function renderProducts(items) { const grid = document.getElementById('productsGrid'); const countLabel = document.getElementById('productCountLabel'); if (countLabel) { countLabel.textContent = items.length ? 'Menú disponible' : 'Sin resultados'; } visibleProducts = Array.isArray(items) ? items : []; renderedProductCount = 0; const token = ++menuRenderToken; if (!visibleProducts.length) { grid.innerHTML = `
No encontramos productos
Ajusta la búsqueda o cambia la categoría.
`; return; } grid.innerHTML = ''; appendProductBatch(token); } function addToCart(pid) { const product = findProductById(pid); if (!product) return; const existing = cart.find(i => sameProductId(i.productId, pid)); const nextQuantity = Number(existing?.quantity || 0) + 1; if (!canAddProductQuantity(product, nextQuantity)) { showStockUnavailable(product); return; } if (existing) existing.quantity = nextQuantity; else cart.push({ productId: pid, name: product.name, price: product.salePrice, quantity: 1 }); updateCartUI(); applyProductFilters(); quickToast(`Agregado: ${product.name}`, 'success'); } function changeCartQty(pid, delta) { const item = cart.find(i => sameProductId(i.productId, pid)); if (!item) return; const nextQuantity = Number(item.quantity || 0) + Number(delta || 0); const product = findProductById(pid); if (delta > 0 && product && !canAddProductQuantity(product, nextQuantity)) { showStockUnavailable(product); return; } item.quantity = nextQuantity; if (item.quantity <= 0) cart = cart.filter(i => !sameProductId(i.productId, pid)); updateCartUI(); renderCartReview(); applyProductFilters(); } function removeFromCart(pid) { cart = cart.filter(i => !sameProductId(i.productId, pid)); updateCartUI(); renderCartReview(); applyProductFilters(); } // Toast no bloqueante (mucho más rápido que Swal modal) function quickToast(text, icon = 'success') { Swal.fire({ toast: true, position: 'top', icon, title: text, showConfirmButton: false, timer: 1600, timerProgressBar: true, background: '#161b22', color: '#fff' }); } let cartReviewModal = null; function openCartReview() { if (!cart.length) return; if (!cartReviewModal) cartReviewModal = new bootstrap.Modal(document.getElementById('cartReviewModal')); renderCartReview(); cartReviewModal.show(); } function renderCartReview() { const cont = document.getElementById('cartReviewItems'); const totalEl = document.getElementById('cartReviewTotal'); if (!cont) return; if (!cart.length) { cont.innerHTML = `
Tu carrito está vacío.
`; totalEl.textContent = money(0); if (cartReviewModal) cartReviewModal.hide(); return; } const total = cart.reduce((acc, i) => acc + (i.price * i.quantity), 0); cont.innerHTML = cart.map(i => { const product = findProductById(i.productId); const photo = product?.imageUrl || LOGO_SRC; const effectiveStock = product ? getProductEffectiveStock(product) : Infinity; const canIncrease = !product || canAddProductQuantity(product, Number(i.quantity || 0) + 1); const stockWarning = product && product.trackStock !== false && effectiveStock <= 0 ? '
Producto agotado
' : (product && product.trackStock !== false && Number(i.quantity || 0) >= effectiveStock ? '
Máximo disponible
' : ''); return `
${escapeHtml(i.name)}
${escapeHtml(i.name)}
${money(i.price)} c/u
${stockWarning}
${i.quantity}
${money(i.price * i.quantity)}
`; }).join(''); totalEl.textContent = money(total); } window.addToCart = addToCart; window.changeCartQty = changeCartQty; window.removeFromCart = removeFromCart; window.quickToast = quickToast; window.openCartReview = openCartReview; window.renderCartReview = renderCartReview; function updateCartUI() { const bar = document.getElementById('cartBar'); const qty = document.getElementById('cartQty'); const totalText = document.getElementById('cartTotalText'); const totalQty = cart.reduce((acc, i) => acc + i.quantity, 0); const totalMoney = cart.reduce((acc, i) => acc + (i.price * i.quantity), 0); if (totalQty > 0) { bar.classList.add('active'); qty.textContent = totalQty; totalText.textContent = `${money(totalMoney)} · ${totalQty} item${totalQty === 1 ? '' : 's'}`; } else { bar.classList.remove('active'); qty.textContent = '0'; totalText.textContent = money(0); } } async function sendOrder() { const items = cart.map(i => ({ productId: i.productId, quantity: i.quantity })); if (!items.length) return; if (accountMode === 'individual' && !customerName.trim()) { quickToast('Escribe tu nombre para tu cuenta personal', 'warning'); return; } if (!tableId) { quickToast('Escanea el QR de tu mesa para enviar el pedido', 'warning'); return; } const sendBtn = document.getElementById('cartSendBtn'); if (sendBtn) { sendBtn.disabled = true; sendBtn.innerHTML = ' Enviando...'; } try { const shouldRefreshProducts = !productsLoadedAt || (Date.now() - productsLoadedAt) > 60000 || !Array.isArray(allProducts) || allProducts.length === 0; if (shouldRefreshProducts) { const productsRes = await apiFetch('/api/products'); const productsPayload = await productsRes.json().catch(() => []); if (productsRes.ok && Array.isArray(productsPayload) && productsPayload.length) { allProducts = productsPayload; productsLoadedAt = Date.now(); const _tck = `clientProducts:${window.CLIENT_TENANT_SLUG || 'conga'}:${branchId}`; localStorage.setItem(_tck, JSON.stringify(allProducts.slice(0, 400))); localStorage.setItem(`${_tck}:ts`, String(Date.now())); renderCategoryRail(); applyProductFilters(); renderCartReview(); } } } catch (_e) { } if (!validateCartStock()) { renderCartReview(); if (sendBtn) { sendBtn.disabled = false; sendBtn.innerHTML = ' Enviar pedido'; } return; } debugLog('[ORDER] Sending', { tableId, branchId, itemsCount: items.length, token: token ? 'YES' : 'NO' }); try { const res = await apiFetch('/api/client/order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tableId, items, token, accountMode, customerName, paymentMethod }) }); debugLog('[ORDER] Response status:', res.status, 'ok:', res.ok); if (res.ok) { const data = await res.json(); if (data.token) { token = data.token; localStorage.setItem(`tableToken:${branchId}:${tableId}`, token); } cart = []; updateCartUI(); renderCartReview(); if (cartReviewModal) cartReviewModal.hide(); quickToast('¡Pedido enviado!', 'success'); if (currentTab === 'account' && typeof loadAccount === 'function') loadAccount(); } else { const errorData = await res.json().catch(() => ({})); debugLog('[ORDER] FAILED status:', res.status, 'error:', errorData); updateCartUI(); renderCartReview(); Swal.fire({ icon: 'error', title: String(errorData.error || '').toLowerCase().includes('stock') ? 'Producto agotado' : `Error ${res.status}`, text: errorData.error || 'No se pudo enviar el pedido', background: '#161b22', color: '#fff' }); } } catch (e) { debugLog('[ORDER] EXCEPTION:', e.message); updateCartUI(); renderCartReview(); quickToast('Error de conexión, intenta de nuevo', 'error'); } finally { if (sendBtn) { sendBtn.disabled = false; sendBtn.innerHTML = ' Enviar pedido'; } } } window.sendOrder = sendOrder; async function callWaiter() { if (!tableId) { quickToast('Escanea el QR de tu mesa para llamar al mesero', 'warning'); return; } const btn = document.getElementById('waiterCallBtn'); const originalHtml = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = ' LLAMANDO...'; } try { const res = await apiFetch('/api/client/waiter-call', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tableId, token }) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'No se pudo llamar al mesero'); if (data.token) { token = data.token; localStorage.setItem(`tableToken:${branchId}:${tableId}`, token); } quickToast('Mesero avisado', 'success'); } catch (e) { quickToast(e.message || 'Error de conexión', 'error'); } finally { if (btn) { btn.disabled = false; btn.innerHTML = originalHtml || ' LLAMAR MESERO'; } } } window.callWaiter = callWaiter; function renderMusicEmpty() { document.getElementById('musicResults').innerHTML = `

Escribe arriba para pedirle una canción al DJ.

`; } function renderFavorites() { const favSec = document.getElementById('favoritesSection'); const favList = document.getElementById('favoritesList'); if (favoriteSongs.length === 0) { favSec.classList.add('d-none'); return; } favSec.classList.remove('d-none'); favList.innerHTML = favoriteSongs.map(v => `
${escapeHtml(v.title || 'Canción')}
${escapeHtml(v.title || 'Sin título')}
${escapeHtml(v.artist || 'Artista')}
`).join(''); } function toggleFavorite(title, artist, thumbnail) { const id = title + '||' + artist; const idx = favoriteSongs.findIndex(s => (s.title + '||' + s.artist) === id); if (idx !== -1) { favoriteSongs.splice(idx, 1); } else { if (favoriteSongs.length >= 3) { Swal.fire({ icon: 'warning', title: 'Límite alcanzado', text: 'Solo puedes tener 3 canciones favoritas.', background: '#161b22', color: '#fff' }); return; } favoriteSongs.push({ title, artist, thumbnail }); } localStorage.setItem('favoriteSongs', JSON.stringify(favoriteSongs)); renderFavorites(); // Update stars in search results without re-fetching document.querySelectorAll('#musicResults .music-card').forEach(card => { const btn = card.querySelector('button[aria-label="Favorito"]'); if (btn) { const t = btn.dataset.title; const a = btn.dataset.artist; const isFav = favoriteSongs.some(s => s.title === t && s.artist === a); const icon = btn.querySelector('i'); if (isFav) { icon.className = 'fa-solid text-warning fa-star fa-lg'; } else { icon.className = 'fa-regular text-secondary fa-star fa-lg'; } } }); } window.toggleFavorite = toggleFavorite; async function searchMusic(q) { const res = await apiFetch(`/api/search-songs?q=${encodeURIComponent(q)}`); const items = await res.json(); const grid = document.getElementById('musicResults'); if (!Array.isArray(items) || !items.length) { grid.innerHTML = `

No encontramos canciones con ese nombre.

`; return; } grid.innerHTML = items.map(v => { const isFav = favoriteSongs.some(s => s.title === v.title && s.artist === v.artist); const starClass = isFav ? 'fa-solid text-warning' : 'fa-regular text-secondary'; return `
${escapeHtml(v.title || 'Canción')}
${escapeHtml(v.title || 'Sin título')}
${escapeHtml(v.artist || 'Artista')}
`}).join(''); } async function reqMusic(name, artist, priority = false, thumbnail = '', videoId = '', duration = '') { if (priority) { const confirm = await Swal.fire({ icon: 'question', title: '🚀 ¿Próxima canción?', html: `Pagas $${Number(_priorityFee).toLocaleString('es-CO')} para que "${name}" sea la siguiente en sonar.
El cargo se agrega a tu cuenta.`, showCancelButton: true, confirmButtonText: 'Sí, ¡que suene ya!', cancelButtonText: 'Cancelar', confirmButtonColor: '#facc15', background: '#161b22', color: '#fff' }); if (!confirm.isConfirmed) return; } // Feedback inmediato no bloqueante quickToast(priority ? '🚀 Enviando boost...' : 'Enviando al DJ...', 'info'); try { const res = await apiFetch('/api/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, artist, tableNumber: tableId, token, priority, thumbnail, videoId, duration }) }); const data = await res.json().catch(() => ({})); if (res.ok) { quickToast(priority ? '🚀 ¡Tu canción es la próxima!' : '🎧 Canción enviada al DJ', 'success'); } else { quickToast(data.error || 'No se pudo pedir la canción', 'warning'); } } catch (e) { quickToast('Error de conexión', 'error'); } } window.reqMusic = reqMusic; async function loadAccount() { const cont = document.getElementById('accountSummary'); if (cont.dataset.loaded !== '1') { cont.innerHTML = '
'; } try { const res = await apiFetch(`/api/client-tab?table=${encodeURIComponent(tableId)}&accountMode=${encodeURIComponent(accountMode)}&customerName=${encodeURIComponent(customerName)}`); const data = await res.json(); if (!data.found || !data.items || data.items.length === 0) { const html = `
Sin consumos todavía
Cuando hagas tu primer pedido, aparecerá aquí.
`; if (cont.dataset.signature !== html) { cont.innerHTML = html; cont.dataset.signature = html; } cont.dataset.loaded = '1'; return; } const html = `
${data.items.map(item => { const product = allProducts.find(p => p.id === Number(item.productId)); const photoUrl = product && product.imageUrl ? product.imageUrl : LOGO_SRC; return `
${escapeHtml(item.name)}
${escapeHtml(item.name)}
Cant: ${item.quantity}
${money(item.quantity * item.unitPrice)}
`; }).join('')}
Total ${money(data.total)}
`; if (cont.dataset.signature !== html) { cont.innerHTML = html; cont.dataset.signature = html; } cont.dataset.loaded = '1'; } catch (e) { cont.innerHTML = '
Error al cargar cuenta.
'; } } async function refreshClientAccount() { const cont = document.getElementById('accountSummary'); if (cont) { cont.dataset.loaded = '0'; cont.innerHTML = '
Actualizando cuenta...
'; } await loadAccount(); await pollNotifications(); quickToast('Cuenta actualizada', 'success'); } window.refreshClientAccount = refreshClientAccount; async function pollNotifications() { if (!tableId) return; try { const res = await apiFetch(`/api/client/notifications?table=${encodeURIComponent(tableId)}`); const notifications = await res.json(); if (notifications && notifications.length > 0) { notifications.forEach(n => { if (typeof __handleClientNotification === 'function') { __handleClientNotification(n); return; } if (n.type === 'receipt_paid') { handleReceiptPaidNotification(n); return; } }); } } catch (e) { // Ignore silent poll errors } } init(); // ── Now Playing ───────────────────────────────────────────────────── let _npRequestSong = null; // canción de petición activa let _npDeckState = { a: null, b: null }; // estado de los decks DJ function __renderNowPlaying() { const bar = document.getElementById('nowPlayingBar'); if (!bar) return; // Prioridad: deck activo → petición de cola (deck es más real-time) const activeDeck = _npDeckState.a || _npDeckState.b; // La canción "pedida" (nowPlaying del hub) puede quedar pegada si el DJ // paró sin limpiar. Solo se muestra si su playedAt es reciente; si no, // se considera terminada y se oculta (evita el "sonando" fantasma). let reqSong = _npRequestSong; if (reqSong && !activeDeck) { const ts = reqSong.playedAt ? Date.parse(reqSong.playedAt) : NaN; const durSec = Number(reqSong.duration) || 0; const maxAgeMs = (durSec > 0 ? durSec + 120 : 12 * 60) * 1000; if (isNaN(ts) || (Date.now() - ts) > maxAgeMs) reqSong = null; } const song = activeDeck || reqSong || null; const fromRequest = !activeDeck && !!reqSong; if (song && song.name) { document.getElementById('npTitle').textContent = song.name || '–'; document.getElementById('npArtist').textContent = song.artist || '–'; const thumb = document.getElementById('npThumb'); if (song.thumbnail) { thumb.src = song.thumbnail; thumb.style.display = ''; } else { thumb.src = LOGO_SRC; } const badge = bar.querySelector('.np-now-badge'); if (badge) badge.textContent = fromRequest ? '▶ Sonando' : '🎧 DJ en vivo'; bar.style.borderColor = fromRequest ? '' : 'rgba(167,139,250,0.35)'; bar.style.background = fromRequest ? '' : 'linear-gradient(135deg,rgba(167,139,250,0.08) 0%,rgba(167,139,250,0.03) 100%)'; bar.classList.remove('d-none'); } else { bar.classList.add('d-none'); } } function __updateNowPlaying(song) { _npRequestSong = (song && song.name) ? song : null; __renderNowPlaying(); } function __updateDeckState(deckState) { _npDeckState = deckState || { a: null, b: null }; __renderNowPlaying(); } function __updateDjQueue(songs) { const bar = document.getElementById('djQueueBar'); const itemsEl = document.getElementById('djQueueBarItems'); if (!bar || !itemsEl) return; // Filtrar canciones de las últimas 8 horas para no mostrar datos viejos const _8h = 8 * 60 * 60 * 1000; const recent = (songs || []).filter(s => { const ts = s.requestedAt ? Date.parse(s.requestedAt) : Date.parse(s.queuedAt || ''); return !isNaN(ts) ? (Date.now() - ts < _8h) : true; }).sort((a, b) => { if (!!a.isPriority !== !!b.isPriority) return a.isPriority ? -1 : 1; return (Date.parse(a.requestedAt || a.queuedAt || '') || 0) - (Date.parse(b.requestedAt || b.queuedAt || '') || 0); }); if (recent.length === 0) { bar.classList.add('d-none'); return; } bar.classList.remove('d-none'); songs = recent; const _esc = (s) => String(s || '').replace(/&/g,'&').replace(//g,'>'); itemsEl.innerHTML = songs.slice(0, 8).map((s, i) => { const isQueued = s.status === 'queued'; const isPriority = !!s.isPriority; const borderColor = isPriority ? 'rgba(245,158,11,0.35)' : isQueued ? 'rgba(167,139,250,0.15)' : 'rgba(255,255,255,0.06)'; const numColor = isPriority ? '#f59e0b' : isQueued ? '#a78bfa' : '#64748b'; return `
${isPriority?`⚡ BOOST`:''} ${i+1}
${s.thumbnail?``:``} ${isPriority?``:''}
${_esc(s.name)}
${s.artist?`
${_esc(s.artist)}
`:''}
${isPriority?'PRIORITARIA':isQueued?'EN COLA':'PENDIENTE'}
`; }).join(''); } async function loadNowPlaying() { try { const res = await apiFetch('/api/songs/now-playing'); const song = res.ok ? await res.json().catch(() => null) : null; __updateNowPlaying(song); } catch (_e) {} } window.loadNowPlaying = loadNowPlaying; // ── Music VS ──────────────────────────────────────────────────────── let _vsVotedBattleId = sessionStorage.getItem('vsVotedBattle') || null; let _vsVotedChoice = sessionStorage.getItem('vsVotedChoice') || null; async function loadMusicVs() { try { const res = await apiFetch('/api/music-vs'); const battle = res.ok ? await res.json().catch(() => null) : null; renderMusicVs(battle); } catch (_e) { renderMusicVs(null); } } window.loadMusicVs = loadMusicVs; function renderMusicVs(battle) { const sec = document.getElementById('musicVsSection'); if (!sec) return; const divider = document.getElementById('vsSearchDivider'); if (!battle || !battle.active) { sec.classList.add('d-none'); sec.innerHTML = ''; if (divider) divider.classList.add('d-none'); return; } sec.classList.remove('d-none'); if (divider) divider.classList.remove('d-none'); const total = (battle.votesA || 0) + (battle.votesB || 0); const pctA = total ? Math.round((battle.votesA || 0) / total * 100) : 0; const pctB = total ? Math.round((battle.votesB || 0) / total * 100) : 0; const myVote = _vsVotedBattleId === String(battle.id) ? _vsVotedChoice : null; const thumbA = battle.songA.thumbnail || LOGO_SRC; const thumbB = battle.songB.thumbnail || LOGO_SRC; const leader = total === 0 ? null : ((battle.votesA || 0) === (battle.votesB || 0) ? null : ((battle.votesA || 0) > (battle.votesB || 0) ? 'a' : 'b')); sec.innerHTML = `
Batalla en vivo · ¿Qué quieres escuchar?
A
${escapeHtml(battle.songA.name)}
${escapeHtml(battle.songA.artist || '–')}
${pctA}%
VS
B
${escapeHtml(battle.songB.name)}
${escapeHtml(battle.songB.artist || '–')}
${pctB}%
${total} voto${total !== 1 ? 's' : ''}
`; } async function voteInVs(battleId, choice) { const voterKey = tableId ? `table_${branchId}_${tableId}` : `anon_${Date.now()}`; try { const res = await apiFetch('/api/music-vs/vote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ battleId, choice, tableId, voterKey }) }); const data = await res.json().catch(() => ({})); if (res.ok || res.status === 409) { _vsVotedBattleId = battleId; _vsVotedChoice = res.status === 409 ? (_vsVotedChoice || choice) : choice; sessionStorage.setItem('vsVotedBattle', _vsVotedBattleId); sessionStorage.setItem('vsVotedChoice', _vsVotedChoice); if (data.votes) { // Optimistic update: patch the currently-rendered battle with new vote counts // No need for an extra HTTP fetch — SSE/WS will deliver other voters' updates. const sec = document.getElementById('musicVsSection'); if (sec && !sec.classList.contains('d-none')) { const cardA = sec.querySelector('.vs-song-card:nth-child(1)'); const cardB = sec.querySelector('.vs-song-card:nth-child(3)'); const fillA = cardA && cardA.querySelector('.vs-bar-fill'); const fillB = cardB && cardB.querySelector('.vs-bar-fill'); const pctElA = cardA && cardA.querySelector('.vs-pct'); const pctElB = cardB && cardB.querySelector('.vs-pct'); const total = (data.votes.a || 0) + (data.votes.b || 0); const pa = total ? Math.round((data.votes.a || 0) / total * 100) : 0; const pb = total ? Math.round((data.votes.b || 0) / total * 100) : 0; if (fillA) fillA.style.width = pa + '%'; if (fillB) fillB.style.width = pb + '%'; if (pctElA) pctElA.textContent = pa + '%'; if (pctElB) pctElB.textContent = pb + '%'; // Flash the chosen card + flying lightning bolt const myCard = choice === 'a' ? cardA : cardB; if (myCard) { myCard.classList.remove('vs-flash'); void myCard.offsetWidth; myCard.classList.add('vs-flash'); const bolt = document.createElement('i'); bolt.className = 'fa-solid fa-bolt vs-bolt'; myCard.appendChild(bolt); setTimeout(() => bolt.remove(), 950); } // Recompute leader crown if (cardA && cardB) { const aWin = (data.votes.a || 0) > (data.votes.b || 0); const bWin = (data.votes.b || 0) > (data.votes.a || 0); cardA.classList.toggle('vs-leader', aWin); cardB.classList.toggle('vs-leader', bWin); } } } if (res.ok) quickToast('¡Voto registrado!', 'success'); else quickToast('Ya votaste en esta batalla', 'info'); } else { quickToast(data.error || 'No se pudo votar', 'warning'); } } catch (_e) { quickToast('Error de conexión', 'error'); } } window.voteInVs = voteInVs; // Fallback poll: only if BOTH WS and SSE are down setInterval(() => { if (!__hubWsOk && !__sseOk && currentTab === 'music' && !document.hidden) { loadNowPlaying(); loadMusicVs(); } }, 15000); // ===================================================================== // SSE realtime al BranchHub (Fase 4): empuja cuenta, productos y // notificaciones de ESTA mesa sin polling. Si el SSE está activo, el // polling fallback queda dormido. // ===================================================================== let __sseOk = false; let __sseSrc = null; let __sseReconnectTimer = null; let __sseDelay = 1000; let __hubWsOk = false; let __hubWs = null; let __hubWsReconnectTimer = null; let __hubWsDelay = 1000; let __hubPingTimer = null; let __hubLastMessageAt = 0; let __hubLastPongAt = 0; // Persistir IDs vistos en sessionStorage para sobrevivir recargas de página const __SEEN_KEY = `clientNotifSeen:${branchId}:${tableId || ''}`; const __clientNotificationSeen = new Set( JSON.parse(sessionStorage.getItem(__SEEN_KEY) || '[]') ); function __markSeen(id) { __clientNotificationSeen.add(id); try { const arr = [...__clientNotificationSeen].slice(-200); sessionStorage.setItem(__SEEN_KEY, JSON.stringify(arr)); } catch (_) {} } function __handleClientNotification(n, opts = {}) { if (!n || !n.id || __clientNotificationSeen.has(n.id)) return; // Una llamada a mesero ya atendida pierde sentido rápidamente. Si // quedó pendiente por una desconexión, no debe aparecer al pedir. if (n.type === 'waiter_confirmed') { const ts = n.createdAt ? Date.parse(n.createdAt) : 0; if (!ts || Date.now() - ts > 5 * 60 * 1000) { __markSeen(n.id); return; } } // "música sonando" NO se muestra como toast en ningún canal (hub ni SSE): // se replicaba al (re)conectar y generaba el aviso fantasma de canción. // El panel "sonando ahora" ya muestra qué suena de verdad. if (n.type === 'music_playing') { __markSeen(n.id); return; } // En carga inicial (fromSnapshot), solo mostrar notificaciones recientes (< 10 min) if (opts.fromSnapshot) { const ts = n.createdAt ? Date.parse(n.createdAt) : 0; if (isNaN(ts) || (Date.now() - ts > 10 * 60 * 1000)) { __markSeen(n.id); // marcar vista para que no reaparezca return; } } __markSeen(n.id); if (n.type === 'receipt_paid') { handleReceiptPaidNotification(n); return; } Swal.fire({ icon: 'info', title: n.title || '', text: n.message || '', background: '#161b22', color: '#fff', timer: 5000, toast: true, position: 'top-end', showConfirmButton: false }); } function __renderAccountFromTable(table) { const cont = document.getElementById('accountSummary'); if (!cont) return; if (!table || !Array.isArray(table.orders) || table.orders.length === 0) { const html = `
Sin consumos todavía
Cuando hagas tu primer pedido, aparecerá aquí.
`; if (cont.dataset.signature !== html) { cont.innerHTML = html; cont.dataset.signature = html; } cont.dataset.loaded = '1'; return; } let total = 0; const allItems = []; const sourceOrders = accountMode === 'individual' ? table.orders.filter(order => String(order.customerName || '').toLowerCase() === String(customerName || '').toLowerCase()) : table.orders; sourceOrders.forEach(order => { if (Array.isArray(order.items)) { order.items.forEach(it => { total += Number(it.quantity || 0) * Number(it.unitPrice || 0); allItems.push({ ...it, customerName: order.customerName }); }); } }); const html = `
${allItems.map(item => { const product = allProducts.find(p => p.id === Number(item.productId)); const photoUrl = product && product.imageUrl ? product.imageUrl : LOGO_SRC; return `
${escapeHtml(item.name || '')}
${escapeHtml(item.name || '')}
Cant: ${item.quantity}
${money(item.quantity * item.unitPrice)}
`; }).join('')}
Total ${money(total)}
`; if (cont.dataset.signature !== html) { cont.innerHTML = html; cont.dataset.signature = html; } cont.dataset.loaded = '1'; } function __matchesClientTable(item) { if (!item || !tableId) return false; const tableKey = String(tableId || '').toLowerCase(); return String(item.id || '') === String(tableId || '') || String(item.name || '').toLowerCase() === tableKey; } function __matchesClientNotification(n) { if (!n || !tableId) return false; const normalizeTableRef = value => String(value || '') .toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/^mesa\s*/i, '') .trim(); const tableKey = normalizeTableRef(tableId); return normalizeTableRef(n.tableId) === tableKey || normalizeTableRef(n.tableNumber) === tableKey; } const _hubQueueSongs = new Map(); // id → song (pending/queued) function __hubQueueChanged() { __updateDjQueue([..._hubQueueSongs.values()]); } function __handleHubRealtimeMessage(data) { if (!data || !data.type) return; if (data.type === 'snapshot') { const myTable = (data.tables || []).find(__matchesClientTable) || null; if (myTable) __renderAccountFromTable(myTable); (data.clientNotifications || []).filter(n => !n.read && __matchesClientNotification(n)).forEach(n => __handleClientNotification(n, { fromSnapshot: true })); if (Array.isArray(data.products)) { allProducts = data.products.filter(p => p && (p.branchId === branchId || (!p.branchId && branchId === 'sede-1'))); productsLoadedAt = Date.now(); if (typeof applyProductFilters === 'function') applyProductFilters(); } // Now playing y cola desde snapshot del DO __updateNowPlaying(data.nowPlaying || null); __updateDeckState(data.deckState || { a: null, b: null }); _hubQueueSongs.clear(); for (const s of (data.songs || [])) _hubQueueSongs.set(String(s.id), s); __hubQueueChanged(); // VS directo desde el snapshot del DO (igual que deckState) — no // depender del HTTP /api/music-vs, que puede no resolver el tenant. if (typeof data.battle !== 'undefined') renderMusicVs(data.battle || null); else if (currentTab === 'music') loadMusicVs(); return; } if (data.type === 'table_upsert' && __matchesClientTable(data.item)) { __renderAccountFromTable(data.item); return; } if (data.type === 'update' && data.collection === 'tables' && __matchesClientTable(data.item)) { __renderAccountFromTable(data.item); return; } if (data.type === 'client_notification_new' && __matchesClientNotification(data.item)) { __handleClientNotification(data.item); return; } if ((data.type === 'product_upsert' || (data.type === 'update' && data.collection === 'products')) && data.item) { const idx = allProducts.findIndex(p => String(p.id) === String(data.item.id)); if (idx >= 0) allProducts[idx] = data.item; else if (!data.item.branchId || data.item.branchId === branchId || (!data.item.branchId && branchId === 'sede-1')) allProducts.push(data.item); productsLoadedAt = Date.now(); if (typeof applyProductFilters === 'function') applyProductFilters(); return; } if (data.type === 'product_remove' || (data.type === 'delete' && data.collection === 'products')) { const removedId = data.id || data.item?.id; if (removedId) allProducts = allProducts.filter(p => String(p.id) !== String(removedId)); productsLoadedAt = Date.now(); if (typeof applyProductFilters === 'function') applyProductFilters(); return; } // Now Playing directo desde el DO if (data.type === 'now_playing') { __updateNowPlaying(data.song || null); return; } // Deck state: qué está sonando en los decks A y B if (data.type === 'deck_state') { __updateDeckState(data.deckState || { a: null, b: null }); return; } // Cola: canción agregada/actualizada (pending o queued) if (data.type === 'song_upsert' && data.item) { _hubQueueSongs.set(String(data.item.id), data.item); __hubQueueChanged(); return; } // Cola: canción removida (pasó a playing, completed, o eliminada) if (data.type === 'song_remove') { const removedId = String(data.id || data.item?.id || ''); if (removedId) _hubQueueSongs.delete(removedId); __hubQueueChanged(); return; } // Now Playing legacy: song status changed to 'playing' via hub update if ((data.type === 'update' && data.collection === 'songs') && data.item) { if (data.item.status === 'playing') __updateNowPlaying(data.item); return; } // Music VS: battle created/updated/closed via hub (WS raw event or update) if (data.type === 'music_vs') { renderMusicVs(data.battle || null); return; } if ((data.type === 'update' && data.collection === 'musicVs') && data.item) { renderMusicVs(data.item.active ? data.item : null); return; } } function __hubWsConnect() { if (!tableId || __hubWs) return; try { const hubBranch = HUB_SLUG ? `${HUB_SLUG}:${branchId}` : branchId; const wsUrl = `wss://branch-hub.sebasur2011.workers.dev/${encodeURIComponent(hubBranch)}/connect`; __hubWs = new WebSocket(wsUrl); __hubWs.addEventListener('open', () => { __hubWsOk = true; __hubWsDelay = 1000; __hubLastMessageAt = Date.now(); __hubLastPongAt = Date.now(); clearInterval(__hubPingTimer); __hubPingTimer = setInterval(() => { const now = Date.now(); // Un socket puede quedar OPEN aunque la red móvil ya haya muerto. // Si no recibimos ni pong ni ningún evento en 45s, cerrarlo para // activar SSE/polling y reconectar desde cero. if (now - Math.max(__hubLastPongAt, __hubLastMessageAt) > 45000) { try { __hubWs?.close(); } catch (_) {} __hubWsScheduleReconnect(); return; } try { if (__hubWs && __hubWs.readyState === WebSocket.OPEN) __hubWs.send('ping'); } catch (_) { __hubWsScheduleReconnect(); } }, 15000); try { __hubWs.send(JSON.stringify({ type: 'request_snapshot' })); } catch (_) {} }); __hubWs.addEventListener('message', (ev) => { __hubLastMessageAt = Date.now(); if (ev.data === 'pong') { __hubLastPongAt = Date.now(); return; } let data; try { data = JSON.parse(ev.data); } catch (_) { return; } __handleHubRealtimeMessage(data); }); __hubWs.addEventListener('close', __hubWsScheduleReconnect); __hubWs.addEventListener('error', __hubWsScheduleReconnect); } catch (_e) { __hubWsScheduleReconnect(); } } function __hubWsScheduleReconnect() { __hubWsOk = false; __hubLastMessageAt = 0; __hubLastPongAt = 0; clearInterval(__hubPingTimer); try { if (__hubWs) __hubWs.close(); } catch (_) {} __hubWs = null; if (__hubWsReconnectTimer) return; __hubWsReconnectTimer = setTimeout(() => { __hubWsReconnectTimer = null; __hubWsDelay = Math.min(__hubWsDelay * 2, 30000); __hubWsConnect(); }, __hubWsDelay); } function __sseConnect() { if (!tableId) return; if (__hubWsOk) return; try { if (__sseSrc) { try { __sseSrc.close(); } catch (_) {} __sseSrc = null; } const _sseSlug = HUB_SLUG; const url = `${API_BASE_URL}/api/client/sse?branchId=${encodeURIComponent(branchId)}&table=${encodeURIComponent(tableId)}${_sseSlug ? '&tenantSlug=' + encodeURIComponent(_sseSlug) : ''}`; __sseSrc = new EventSource(url); __sseSrc.addEventListener('open', () => { __sseOk = true; __sseDelay = 1000; console.log('[client-sse] connected'); }); __sseSrc.addEventListener('message', (ev) => { let data; try { data = JSON.parse(ev.data); } catch (_) { return; } if (!data || !data.type) return; if (data.type === 'products' && Array.isArray(data.items)) { allProducts = data.items.filter(p => p && (p.branchId === branchId || (!p.branchId && branchId === 'sede-1'))); productsLoadedAt = Date.now(); if (typeof applyProductFilters === 'function') applyProductFilters(); } else if (data.type === 'table') { if (__matchesClientTable(data.table)) __renderAccountFromTable(data.table); } else if (data.type === 'notification' && data.item) { __handleClientNotification(data.item); } else if (data.type === 'now_playing') { __updateNowPlaying(data.song); } else if (data.type === 'queue_update') { __updateDjQueue(data.songs); } else if (data.type === 'deck_state') { __updateDeckState(data.deckState); } else if (data.type === 'music_vs') { renderMusicVs(data.battle); } }); __sseSrc.addEventListener('error', () => { __sseOk = false; try { __sseSrc.close(); } catch (_) {} __sseSrc = null; if (!__sseReconnectTimer) { __sseReconnectTimer = setTimeout(() => { __sseReconnectTimer = null; __sseDelay = Math.min(__sseDelay * 2, 30000); __sseConnect(); }, __sseDelay); } }); } catch (_e) { __sseOk = false; } } if (tableId) { // El navegador no se conecta directamente al Worker interno. SSE // pasa por la API del negocio, que aplica tenant, sede y filtros de mesa. __sseConnect(); } // Reconciliación periódica independiente del estado aparente del socket. // En móviles una conexión puede quedar OPEN pero congelada, así que el // polling garantiza que ninguna confirmación quede perdida. setInterval(() => { if (!document.hidden) pollNotifications(); }, 20000); setInterval(() => { if (tableId && currentTab === 'account' && !document.hidden) { loadAccount(); } }, 60000); // En móviles, el SO puede congelar el WebSocket/SSE al poner la app en // segundo plano (pantalla bloqueada, cambio de app) sin disparar los // eventos close/error — el cliente queda "conectado" en apariencia pero // sin recibir nada, y por eso tocaba recargar para ver pedidos/recibos // nuevos. Al volver a primer plano, forzamos una reconexión limpia. let __lastHiddenAt = 0; document.addEventListener('visibilitychange', () => { if (document.hidden) { __lastHiddenAt = Date.now(); return; } const wasHiddenForAWhile = __lastHiddenAt && (Date.now() - __lastHiddenAt) > 3000; if (wasHiddenForAWhile) { try { if (__hubWs) { __hubWs.close(); } } catch (_) {} __hubWs = null; __hubWsOk = false; try { if (__sseSrc) { __sseSrc.close(); } } catch (_) {} __sseSrc = null; __sseOk = false; } if (tableId && !__hubWsOk && !__sseOk) { __sseConnect(); } if (tableId && currentTab === 'account') loadAccount(); });