feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
367
pos/static/js/marketplace_external.js
Normal file
367
pos/static/js/marketplace_external.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* marketplace_external.js — MercadoLibre integration UI
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var API = '/pos/api/marketplace-ext';
|
||||
var TOKEN = localStorage.getItem('pos_token') || '';
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Authorization': 'Bearer ' + TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': localStorage.getItem('pos_device_id') || 'web',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
window.switchTab = function(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) {
|
||||
b.classList.toggle('is-active', b.dataset.tab === tab);
|
||||
b.setAttribute('aria-selected', b.dataset.tab === tab ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) {
|
||||
p.classList.toggle('is-active', p.id === 'panel-' + tab);
|
||||
});
|
||||
if (tab === 'listings') loadListings();
|
||||
if (tab === 'orders') loadOrders();
|
||||
};
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('is-open');
|
||||
}
|
||||
window.closeModal = closeModal;
|
||||
|
||||
// ─── Config / Connection ───────────────────────────────────────────────
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
var res = await fetch(API + '/config', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load config');
|
||||
var cfg = await res.json();
|
||||
|
||||
var statusDiv = document.getElementById('configStatus');
|
||||
var formDiv = document.getElementById('configForm');
|
||||
var connectedDiv = document.getElementById('configConnected');
|
||||
|
||||
if (cfg.connected) {
|
||||
statusDiv.innerHTML = '<span class="meli-status meli-status--active">● Conectado</span>';
|
||||
formDiv.style.display = 'none';
|
||||
connectedDiv.style.display = 'block';
|
||||
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
|
||||
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
|
||||
formDiv.style.display = 'block';
|
||||
connectedDiv.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('configStatus').innerHTML = '<p style="color:var(--color-danger);">Error cargando configuración</p>';
|
||||
}
|
||||
}
|
||||
|
||||
window.startOAuth = function() {
|
||||
var clientId = document.getElementById('cfgClientId').value.trim();
|
||||
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
||||
var category = document.getElementById('cfgCategory').value.trim();
|
||||
var shipping = document.getElementById('cfgShipping').value;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
alert('Client ID y Client Secret son requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config locally for the callback
|
||||
localStorage.setItem('meli_client_id', clientId);
|
||||
localStorage.setItem('meli_client_secret', clientSecret);
|
||||
localStorage.setItem('meli_category', category);
|
||||
localStorage.setItem('meli_shipping', shipping);
|
||||
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri);
|
||||
window.location.href = authUrl;
|
||||
};
|
||||
|
||||
window.disconnectMeli = async function() {
|
||||
if (!confirm('¿Desconectar MercadoLibre? Las publicaciones existentes no se eliminarán de ML.')) return;
|
||||
try {
|
||||
var res = await fetch(API + '/connect', { method: 'DELETE', headers: headers() });
|
||||
if (res.ok) {
|
||||
loadConfig();
|
||||
} else {
|
||||
alert('Error desconectando');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Listings ──────────────────────────────────────────────────────────
|
||||
|
||||
var listingsData = [];
|
||||
|
||||
window.loadListings = async function() {
|
||||
var container = document.getElementById('listingsContainer');
|
||||
container.innerHTML = '<p>Cargando...</p>';
|
||||
try {
|
||||
var res = await fetch(API + '/listings?page=1&per_page=50', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load listings');
|
||||
var data = await res.json();
|
||||
listingsData = data.items || [];
|
||||
renderListings();
|
||||
} catch (e) {
|
||||
container.innerHTML = '<p style="color:var(--color-danger);">Error cargando publicaciones</p>';
|
||||
}
|
||||
};
|
||||
|
||||
function renderListings() {
|
||||
var container = document.getElementById('listingsContainer');
|
||||
var statusFilter = document.getElementById('listingStatusFilter').value;
|
||||
var search = document.getElementById('listingSearch').value.toLowerCase();
|
||||
|
||||
var filtered = listingsData.filter(function(l) {
|
||||
if (statusFilter && l.external_status !== statusFilter) return false;
|
||||
if (search && !((l.title || '').toLowerCase().includes(search))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);padding:var(--space-4);">No hay publicaciones. Ve a Inventario y publica un producto.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(function(l) {
|
||||
var statusClass = 'meli-status--' + (l.external_status || 'pending');
|
||||
return '<div class="meli-card">'
|
||||
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
|
||||
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '</div>'
|
||||
+ '<span class="meli-status ' + statusClass + '">' + (l.external_status || '—') + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
|
||||
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—')
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);">'
|
||||
+ '<button class="btn btn--ghost btn--xs" onclick="syncListing(' + l.id + ')">Sync</button>'
|
||||
+ (l.external_status === 'active' ? '<button class="btn btn--ghost btn--xs" onclick="pauseListing(' + l.id + ')">Pausar</button>' : '<button class="btn btn--ghost btn--xs" onclick="activateListing(' + l.id + ')">Activar</button>')
|
||||
+ '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.filterListings = renderListings;
|
||||
|
||||
window.syncListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/sync', { method: 'POST', headers: headers() });
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('Sincronizado: $' + data.price + ' · Stock: ' + data.stock);
|
||||
loadListings();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.pauseListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/pause', { method: 'POST', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
window.activateListing = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id + '/activate', { method: 'POST', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
window.deleteListing = async function(id) {
|
||||
if (!confirm('¿Cerrar esta publicación en MercadoLibre?')) return;
|
||||
try {
|
||||
var res = await fetch(API + '/listings/' + id, { method: 'DELETE', headers: headers() });
|
||||
if (res.ok) { loadListings(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ─── Orders ────────────────────────────────────────────────────────────
|
||||
|
||||
var ordersData = [];
|
||||
|
||||
window.loadOrders = async function() {
|
||||
var tbody = document.getElementById('ordersTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6">Cargando...</td></tr>';
|
||||
try {
|
||||
var res = await fetch(API + '/orders?page=1&per_page=50', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load orders');
|
||||
var data = await res.json();
|
||||
ordersData = data.items || [];
|
||||
renderOrders();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-danger)">Error cargando órdenes</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
function renderOrders() {
|
||||
var tbody = document.getElementById('ordersTableBody');
|
||||
var statusFilter = document.getElementById('orderStatusFilter').value;
|
||||
var search = document.getElementById('orderSearch').value.toLowerCase();
|
||||
|
||||
var filtered = ordersData.filter(function(o) {
|
||||
if (statusFilter && o.status !== statusFilter) return false;
|
||||
if (search && !((o.buyer_name || '').toLowerCase().includes(search) || (o.external_order_id || '').includes(search))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-text-muted)">No hay órdenes.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(function(o) {
|
||||
var statusClass = 'meli-status--' + (o.status || 'pending');
|
||||
return '<tr>'
|
||||
+ '<td><a href="#" onclick="showOrderDetail(' + o.id + ');return false;">' + escapeHtml(o.external_order_id) + '</a></td>'
|
||||
+ '<td>' + escapeHtml(o.buyer_name || o.buyer_nickname || '—') + '</td>'
|
||||
+ '<td style="text-align:right">$' + (o.total_amount || 0).toFixed(2) + '</td>'
|
||||
+ '<td><span class="meli-status ' + statusClass + '">' + (o.status || '—') + '</span></td>'
|
||||
+ '<td>' + (o.created_at ? o.created_at.split('T')[0] : '—') + '</td>'
|
||||
+ '<td>'
|
||||
+ (o.status === 'pending' ? '<button class="btn btn--primary btn--xs" onclick="convertOrder(' + o.id + ')">Convertir a Venta</button> ' : '')
|
||||
+ '<button class="btn btn--ghost btn--xs" onclick="showOrderDetail(' + o.id + ')">Ver</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.filterOrders = renderOrders;
|
||||
|
||||
window.showOrderDetail = async function(id) {
|
||||
var modal = document.getElementById('orderModal');
|
||||
var body = document.getElementById('orderModalBody');
|
||||
var footer = document.getElementById('orderModalFooter');
|
||||
body.innerHTML = 'Cargando...';
|
||||
footer.innerHTML = '';
|
||||
modal.classList.add('is-open');
|
||||
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id, { headers: headers() });
|
||||
var o = await res.json();
|
||||
if (!res.ok) throw new Error(o.error || 'Error');
|
||||
|
||||
var itemsHtml = (o.items || []).map(function(it) {
|
||||
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
body.innerHTML = '<div style="margin-bottom:var(--space-4);">'
|
||||
+ '<p><strong>Comprador:</strong> ' + escapeHtml(o.buyer_name || '—') + ' (' + escapeHtml(o.buyer_nickname || '—') + ')</p>'
|
||||
+ '<p><strong>Email:</strong> ' + escapeHtml(o.buyer_email || '—') + '</p>'
|
||||
+ '<p><strong>Teléfono:</strong> ' + escapeHtml(o.buyer_phone || '—') + '</p>'
|
||||
+ '<p><strong>Total:</strong> $' + (o.total_amount || 0).toFixed(2) + '</p>'
|
||||
+ '<p><strong>Estado ML:</strong> ' + escapeHtml(o.external_status || '—') + '</p>'
|
||||
+ '<p><strong>Estado Nexus:</strong> ' + escapeHtml(o.status || '—') + '</p>'
|
||||
+ '</div>'
|
||||
+ '<h4 style="margin:var(--space-3) 0;">Items</h4>'
|
||||
+ '<table class="data-table"><thead><tr><th>Producto</th><th>Cantidad</th><th style="text-align:right">Precio</th></tr></thead><tbody>' + itemsHtml + '</tbody></table>';
|
||||
|
||||
footer.innerHTML = '';
|
||||
if (o.status === 'pending') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="convertOrder(' + o.id + ');closeModal(\'orderModal\')">Convertir a Venta</button> ';
|
||||
}
|
||||
if (o.status === 'confirmed') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'packed\')">Marcar Empacada</button> ';
|
||||
}
|
||||
if (o.status === 'packed') {
|
||||
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'shipped\')">Marcar Enviada</button> ';
|
||||
}
|
||||
footer.innerHTML += '<button class="btn btn--ghost" onclick="closeModal(\'orderModal\')">Cerrar</button>';
|
||||
} catch (e) {
|
||||
body.innerHTML = '<p style="color:var(--color-danger)">Error: ' + escapeHtml(e.message) + '</p>';
|
||||
}
|
||||
};
|
||||
|
||||
window.convertOrder = async function(id) {
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id + '/convert', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('Orden convertida a venta #' + data.sale_id);
|
||||
loadOrders();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.updateOrderStatus = async function(id, status) {
|
||||
try {
|
||||
var res = await fetch(API + '/orders/' + id + '/status', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ status: status })
|
||||
});
|
||||
if (res.ok) { loadOrders(); } else { alert('Error'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
};
|
||||
|
||||
// ─── Utils ─────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Handle OAuth callback
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var authCode = urlParams.get('code');
|
||||
if (authCode && window.location.pathname.includes('marketplace-external')) {
|
||||
(async function() {
|
||||
var clientId = localStorage.getItem('meli_client_id');
|
||||
var clientSecret = localStorage.getItem('meli_client_secret');
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
try {
|
||||
var res = await fetch(API + '/connect', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('¡Conectado exitosamente con MercadoLibre!');
|
||||
window.history.replaceState({}, document.title, '/pos/marketplace-external');
|
||||
loadConfig();
|
||||
} else {
|
||||
alert('Error conectando: ' + (data.error || 'Unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfig();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user