← back to NEW SKU Viewer
public/index.html
887 lines
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEW-SKU Vendor Viewer | Live Product Scanner</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 20px 0;
box-shadow: 0 2px 20px rgba(0,0,0,0.1);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.title {
color: #2d3748;
}
.title h1 {
font-size: 2.5rem;
margin-bottom: 5px;
background: linear-gradient(45deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.title p {
color: #666;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.stats {
display: flex;
gap: 20px;
margin: 20px 0;
flex-wrap: wrap;
}
.stat-card {
background: rgba(255, 255, 255, 0.9);
padding: 15px 25px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
backdrop-filter: blur(10px);
min-width: 120px;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #666;
font-size: 0.9rem;
margin-top: 5px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.9);
color: #667eea;
border: 2px solid #667eea;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.content {
padding: 20px 0;
}
.vendor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 350px), 1fr));
gap: 20px;
margin-top: 20px;
}
.grid-controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 10px 14px;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(10px);
border-radius: 10px;
}
.grid-controls label {
font-size: 0.85rem;
color: #4a5568;
font-weight: 500;
}
.grid-controls select {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #cbd5e0;
background: #fff;
font-size: 0.9rem;
color: #2d3748;
}
.grid-controls input[type="range"] {
width: 160px;
}
.vendor-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.vendor-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
}
.vendor-card.success {
border-left-color: #10b981;
}
.vendor-card.error {
border-left-color: #ef4444;
}
.vendor-card.warning {
border-left-color: #f59e0b;
}
.vendor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.vendor-name {
font-size: 1.2rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 5px;
}
.vendor-id {
color: #666;
font-size: 0.9rem;
font-family: monospace;
}
.status-badge {
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
}
.status-success {
background: #d1fae5;
color: #065f46;
}
.status-error {
background: #fee2e2;
color: #991b1b;
}
.status-warning {
background: #fef3c7;
color: #92400e;
}
.product-count {
font-size: 2rem;
font-weight: bold;
margin: 10px 0;
}
.product-count.has-products {
color: #10b981;
}
.product-count.no-products {
color: #6b7280;
}
.vendor-url {
color: #666;
font-size: 0.9rem;
word-break: break-all;
margin-bottom: 10px;
}
.error-message {
color: #ef4444;
font-size: 0.9rem;
margin-top: 10px;
padding: 8px;
background: #fee2e2;
border-radius: 6px;
}
.vendor-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-small {
padding: 6px 12px;
font-size: 0.8rem;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: white;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-bottom: 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.last-update {
color: rgba(255,255,255,0.8);
font-size: 0.9rem;
margin-top: 10px;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.1);
color: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.filter-btn.active {
background: rgba(255,255,255,0.9);
color: #667eea;
}
.search-box {
margin-bottom: 20px;
}
.search-input {
width: 100%;
max-width: 400px;
padding: 12px 20px;
border: none;
border-radius: 25px;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
font-size: 1rem;
outline: none;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.title h1 {
font-size: 2rem;
}
.vendor-grid {
grid-template-columns: 1fr;
}
.stats {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<div class="header-content">
<div class="title">
<h1><i class="fas fa-search"></i> NEW-SKU Vendor Viewer</h1>
<p>Live product scanner for all wallcovering vendors</p>
</div>
<div class="controls">
<button class="btn btn-primary" onclick="startScan()" id="scanBtn">
<i class="fas fa-sync"></i> Scan All Vendors
</button>
<button class="btn btn-secondary" onclick="refreshData()">
<i class="fas fa-refresh"></i> Refresh
</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-value" id="totalVendors">-</div>
<div class="stat-label">Total Vendors</div>
</div>
<div class="stat-card">
<div class="stat-value" id="successfulVendors">-</div>
<div class="stat-label">Working</div>
</div>
<div class="stat-card">
<div class="stat-value" id="vendorsWithProducts">-</div>
<div class="stat-label">With Products</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalProducts">-</div>
<div class="stat-label">Total Products</div>
</div>
</div>
<div class="content">
<div class="search-box">
<input type="text" class="search-input" placeholder="Search vendors..." id="searchInput" oninput="filterVendors()">
</div>
<div class="filters">
<div class="filter-btn active" onclick="setFilter('all')">All Vendors</div>
<div class="filter-btn" onclick="setFilter('success')">Working</div>
<div class="filter-btn" onclick="setFilter('with-products')">With Products</div>
<div class="filter-btn" onclick="setFilter('errors')">Errors</div>
</div>
<div class="grid-controls">
<label for="sortSelect">Sort:</label>
<select id="sortSelect" onchange="onSortChange()">
<option value="newest">Newest</option>
<option value="products">Most Products</option>
<option value="vendor">Vendor (SKU prefix) A→Z</option>
<option value="series">Series / Name A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="status">Status (working first)</option>
</select>
<label for="densityRange" style="margin-left:12px;">Density:</label>
<input type="range" id="densityRange" min="220" max="520" step="10" value="350" oninput="onDensityChange()">
<span id="densityVal" style="font-size:0.8rem;color:#666;min-width:48px;">350px</span>
</div>
<div id="content">
<div class="loading">
<div class="spinner"></div>
<h3>Loading vendor data...</h3>
<p>Scanning all NEW-SKU scrapers for available products</p>
</div>
</div>
</div>
</div>
<script>
let vendorData = {};
let currentFilter = 'all';
let searchQuery = '';
// --- sort + density persistence (rule: hydrate BEFORE first API call) ---
const LS_SORT = 'newSkuViewer.grid.sort';
const LS_DENSITY = 'newSkuViewer.grid.density';
function getSavedSort() {
try { return localStorage.getItem(LS_SORT) || 'newest'; } catch (e) { return 'newest'; }
}
function getSavedDensity() {
try { return parseInt(localStorage.getItem(LS_DENSITY) || '350', 10) || 350; } catch (e) { return 350; }
}
function hydrateGridControls() {
const savedSort = getSavedSort();
const savedDensity = getSavedDensity();
const sortEl = document.getElementById('sortSelect');
const rangeEl = document.getElementById('densityRange');
const valEl = document.getElementById('densityVal');
if (sortEl) sortEl.value = savedSort;
if (rangeEl) rangeEl.value = String(savedDensity);
if (valEl) valEl.textContent = savedDensity + 'px';
document.documentElement.style.setProperty('--card-min', savedDensity + 'px');
}
function onSortChange() {
const v = document.getElementById('sortSelect').value;
try { localStorage.setItem(LS_SORT, v); } catch (e) {}
loadVendorData();
}
function onDensityChange() {
const v = parseInt(document.getElementById('densityRange').value, 10) || 350;
try { localStorage.setItem(LS_DENSITY, String(v)); } catch (e) {}
document.getElementById('densityVal').textContent = v + 'px';
document.documentElement.style.setProperty('--card-min', v + 'px');
}
async function loadVendorData() {
try {
const sort = encodeURIComponent(getSavedSort());
const response = await fetch('/api/vendors?sort=' + sort);
const data = await response.json();
vendorData = data;
updateStats(data.stats);
renderVendors(data.vendors);
if (data.lastUpdate) {
updateLastUpdateTime(data.lastUpdate);
}
// Update scan button state
const scanBtn = document.getElementById('scanBtn');
if (data.isScanning) {
scanBtn.disabled = true;
scanBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Scanning...';
} else {
scanBtn.disabled = false;
scanBtn.innerHTML = '<i class="fas fa-sync"></i> Scan All Vendors';
}
} catch (error) {
console.error('Error loading vendor data:', error);
document.getElementById('content').innerHTML =
'<div class="error">Failed to load vendor data. Please try again.</div>';
}
}
function updateStats(stats) {
document.getElementById('totalVendors').textContent = stats.total || 0;
document.getElementById('successfulVendors').textContent = stats.successful || 0;
document.getElementById('vendorsWithProducts').textContent = stats.withProducts || 0;
document.getElementById('totalProducts').textContent = stats.totalProducts || 0;
}
function renderVendors(vendors) {
const filteredVendors = filterVendorData(vendors);
if (Object.keys(filteredVendors).length === 0) {
document.getElementById('content').innerHTML =
'<div style="text-align: center; padding: 40px; color: white;"><h3>No vendors found matching your criteria</h3></div>';
return;
}
// Server already sorted via ?sort= — preserve that order.
const vendorCards = Object.entries(filteredVendors)
.map(([vendorId, vendor]) => createVendorCard(vendorId, vendor))
.join('');
document.getElementById('content').innerHTML =
`<div class="vendor-grid">${vendorCards}</div>`;
}
function createVendorCard(vendorId, vendor) {
const statusClass = vendor.success ?
(vendor.products > 0 ? 'success' : 'warning') : 'error';
const statusBadge = vendor.success ?
(vendor.products > 0 ?
`<span class="status-badge status-success">Active</span>` :
`<span class="status-badge status-warning">No Products</span>`) :
`<span class="status-badge status-error">Error</span>`;
const productCountClass = vendor.products > 0 ? 'has-products' : 'no-products';
const viewProductsBtn = vendor.products > 0 ?
`<button class="btn btn-primary btn-small" onclick="viewProducts('${vendorId}')">
<i class="fas fa-eye"></i> View Products
</button>` : '';
const importBtn = vendor.products > 0 ?
`<button class="btn btn-secondary btn-small" onclick="importProducts('${vendorId}')">
<i class="fas fa-download"></i> Import
</button>` : '';
return `
<div class="vendor-card ${statusClass}">
<div class="vendor-header">
<div>
<div class="vendor-name">${vendor.vendorName || vendorId}</div>
<div class="vendor-id">${vendorId}</div>
</div>
${statusBadge}
</div>
<div class="product-count ${productCountClass}">
${vendor.products || 0} products
</div>
<div class="vendor-url">
<i class="fas fa-link"></i>
${vendor.url ? vendor.url.substring(0, 50) + '...' : 'No URL'}
</div>
${vendor.scraperUsed ?
`<div style="font-size: 0.8rem; color: #666; margin-top: 5px;">
<i class="fas fa-robot"></i> ${vendor.scraperUsed}
</div>` : ''}
${vendor.error ? `<div class="error-message">${vendor.error}</div>` : ''}
<div class="vendor-actions">
<button class="btn btn-secondary btn-small" onclick="testVendor('${vendorId}')">
<i class="fas fa-refresh"></i> Retest
</button>
${viewProductsBtn}
${importBtn}
</div>
</div>
`;
}
function filterVendorData(vendors) {
let filtered = { ...vendors };
// Apply filter
if (currentFilter !== 'all') {
filtered = Object.fromEntries(
Object.entries(filtered).filter(([id, vendor]) => {
switch (currentFilter) {
case 'success':
return vendor.success;
case 'with-products':
return vendor.products > 0;
case 'errors':
return !vendor.success;
default:
return true;
}
})
);
}
// Apply search
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = Object.fromEntries(
Object.entries(filtered).filter(([id, vendor]) =>
id.toLowerCase().includes(query) ||
(vendor.vendorName && vendor.vendorName.toLowerCase().includes(query))
)
);
}
return filtered;
}
function setFilter(filter) {
currentFilter = filter;
// Update filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
renderVendors(vendorData.vendors || {});
}
function filterVendors() {
searchQuery = document.getElementById('searchInput').value;
renderVendors(vendorData.vendors || {});
}
async function startScan() {
try {
const response = await fetch('/api/scan', { method: 'POST' });
const result = await response.json();
if (result.success) {
document.getElementById('scanBtn').disabled = true;
document.getElementById('scanBtn').innerHTML = '<i class="fas fa-spinner fa-spin"></i> Scanning...';
// Poll for updates every 5 seconds during scan
const pollInterval = setInterval(async () => {
await loadVendorData();
if (!vendorData.isScanning) {
clearInterval(pollInterval);
}
}, 5000);
}
} catch (error) {
console.error('Error starting scan:', error);
}
}
async function testVendor(vendorId) {
try {
const response = await fetch(`/api/test/${vendorId}`, { method: 'POST' });
const result = await response.json();
// Update the vendor in our local data
if (vendorData.vendors) {
vendorData.vendors[vendorId] = result;
renderVendors(vendorData.vendors);
}
} catch (error) {
console.error('Error testing vendor:', error);
}
}
function viewProducts(vendorId) {
const vendor = vendorData.vendors[vendorId];
if (vendor && vendor.products > 0) {
// Create a detailed product view modal
showProductModal(vendor);
} else {
alert('No products available for this vendor');
}
}
function showProductModal(vendor) {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 12px;
padding: 30px;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
`;
const sampleProducts = vendor.sampleProducts || [];
content.innerHTML = `
<button onclick="this.parentElement.parentElement.remove()" style="
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
">×</button>
<h2 style="color: #333; margin-bottom: 20px;">
<i class="fas fa-boxes"></i> ${vendor.vendorName || vendor.vendor} Products
</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; text-align: center;">
<div>
<div style="font-size: 24px; font-weight: bold; color: #667eea;">${vendor.products}</div>
<div style="font-size: 14px; color: #666;">Total Products</div>
</div>
<div>
<div style="font-size: 24px; font-weight: bold; color: #10b981;">${sampleProducts.length}</div>
<div style="font-size: 14px; color: #666;">Sample Shown</div>
</div>
<div>
<div style="font-size: 16px; font-weight: bold; color: #f59e0b;">✅</div>
<div style="font-size: 14px; color: #666;">${vendor.scraperUsed || 'Scraper'}</div>
</div>
</div>
</div>
${sampleProducts.length > 0 ? `
<h3 style="color: #333; margin-bottom: 15px;">Sample Products (${sampleProducts.length} of ${vendor.products})</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
${sampleProducts.map((product, index) => `
<div style="
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
background: #fafafa;
transition: all 0.3s ease;
" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.1)'"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'">
<div style="font-weight: bold; color: #333; margin-bottom: 8px; font-size: 14px;">
${product.title || product.name || `Product ${index + 1}`}
</div>
${product.color && product.color !== 'Multiple Colors' ? `
<div style="color: #667eea; font-size: 12px; margin-bottom: 8px;">
<i class="fas fa-palette"></i> ${product.color}
</div>
` : ''}
<div style="font-family: monospace; font-size: 11px; color: #666; margin-bottom: 8px;">
Code: ${product.code || 'N/A'}
</div>
<a href="${product.url}" target="_blank" rel="noopener noreferrer" style="
display: inline-block;
background: #667eea;
color: white;
text-decoration: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
transition: background 0.3s ease;
" onmouseover="this.style.background='#5a6fd8'" onmouseout="this.style.background='#667eea'">
<i class="fas fa-external-link-alt"></i> View Product
</a>
</div>
`).join('')}
</div>
` : `
<div style="text-align: center; padding: 40px; color: #666;">
<i class="fas fa-inbox" style="font-size: 48px; margin-bottom: 15px; color: #ccc;"></i>
<p>No product details available</p>
</div>
`}
<div style="margin-top: 20px; text-align: center; border-top: 1px solid #e5e7eb; padding-top: 20px;">
<button onclick="window.open('${vendor.url}', '_blank')" style="
background: #10b981;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
margin-right: 10px;
cursor: pointer;
font-weight: 500;
">
<i class="fas fa-globe"></i> Visit Vendor Site
</button>
<button onclick="importProducts('${vendor.vendor}')" style="
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
">
<i class="fas fa-download"></i> Import to Shopify
</button>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
function importProducts(vendorId) {
const vendor = vendorData.vendors[vendorId];
if (vendor && vendor.url) {
const importUrl = `http://45.61.58.125:9830/bulk-import-new?vendor=${vendorId}&url=${encodeURIComponent(vendor.url)}`;
window.open(importUrl, '_blank');
} else {
alert('No import URL available for this vendor');
}
}
function refreshData() {
loadVendorData();
}
function updateLastUpdateTime(timestamp) {
const date = new Date(timestamp);
const timeStr = date.toLocaleString();
let updateElement = document.querySelector('.last-update');
if (!updateElement) {
updateElement = document.createElement('div');
updateElement.className = 'last-update';
document.querySelector('.container').appendChild(updateElement);
}
updateElement.textContent = `Last updated: ${timeStr}`;
}
// Initialize the application — hydrate sort/density from localStorage
// BEFORE the first API call so the initial fetch uses the saved sort.
hydrateGridControls();
loadVendorData();
// Refresh data every 30 seconds
setInterval(loadVendorData, 30000);
</script>
</body>
</html>