0d5cc08bc0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1433 lines
47 KiB
JavaScript
1433 lines
47 KiB
JavaScript
// Pokemon Stock Monitor Dashboard - Frontend JavaScript
|
||
|
||
const API_BASE = '/api';
|
||
|
||
// State
|
||
let currentPage = 'dashboard';
|
||
let charts = {};
|
||
let currentEventFilter = null; // { type: 'new_drop'|'restock', period: 'today'|'week' }
|
||
let currentProductPage = 1;
|
||
const PRODUCTS_PER_PAGE = 50;
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initNavigation();
|
||
loadDashboard();
|
||
loadSuggestions();
|
||
initEventListeners();
|
||
|
||
// Auto-refresh dashboard every 30 seconds
|
||
setInterval(() => {
|
||
if (currentPage === 'dashboard') {
|
||
loadDashboard();
|
||
}
|
||
}, 30000);
|
||
});
|
||
|
||
// Navigation
|
||
function initNavigation() {
|
||
document.querySelectorAll('.nav-links a').forEach(link => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const page = e.target.dataset.page;
|
||
navigateTo(page);
|
||
});
|
||
});
|
||
}
|
||
|
||
function navigateTo(page) {
|
||
// Update active nav
|
||
document.querySelectorAll('.nav-links a').forEach(a => a.classList.remove('active'));
|
||
document.querySelector(`[data-page="${page}"]`).classList.add('active');
|
||
|
||
// Show page
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(`page-${page}`).classList.add('active');
|
||
|
||
currentPage = page;
|
||
|
||
// Load page content
|
||
switch (page) {
|
||
case 'dashboard':
|
||
loadDashboard();
|
||
break;
|
||
case 'products':
|
||
loadProducts();
|
||
break;
|
||
case 'news':
|
||
loadNews();
|
||
break;
|
||
case 'analytics':
|
||
loadAnalytics();
|
||
break;
|
||
case 'favorites':
|
||
loadFavorites();
|
||
break;
|
||
case 'control':
|
||
loadControlPanel();
|
||
break;
|
||
case 'users':
|
||
loadUsers();
|
||
break;
|
||
case 'settings':
|
||
loadSettings();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Event Listeners
|
||
function initEventListeners() {
|
||
// Product filters - clear event filter when user changes regular filters
|
||
['filterSite', 'filterCategory', 'filterStock', 'filterFavorites'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.addEventListener('change', () => {
|
||
currentEventFilter = null; // Clear event filter when using regular filters
|
||
currentProductPage = 1;
|
||
loadProducts();
|
||
});
|
||
}
|
||
});
|
||
|
||
// Clear filters button
|
||
document.getElementById('clearFiltersBtn')?.addEventListener('click', clearAllFilters);
|
||
|
||
// Add favorite button
|
||
document.getElementById('addFavoriteBtn')?.addEventListener('click', addFavorite);
|
||
|
||
// Modal close
|
||
document.querySelector('.modal-close')?.addEventListener('click', closeModal);
|
||
document.getElementById('productModal')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'productModal') closeModal();
|
||
});
|
||
}
|
||
|
||
// Clear all product filters
|
||
function clearAllFilters() {
|
||
document.getElementById('filterSite').value = '';
|
||
document.getElementById('filterCategory').value = '';
|
||
document.getElementById('filterStock').value = '';
|
||
document.getElementById('filterFavorites').checked = false;
|
||
currentEventFilter = null;
|
||
currentProductPage = 1;
|
||
loadProducts();
|
||
}
|
||
|
||
// API Helpers
|
||
async function api(endpoint, options = {}) {
|
||
try {
|
||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
}
|
||
});
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error('API Error:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Store display names and branding
|
||
const STORE_INFO = {
|
||
pokemoncenter: {
|
||
name: 'Pokemon Center',
|
||
logo: 'https://www.google.com/s2/favicons?domain=pokemoncenter.com&sz=128',
|
||
fallback: 'PC',
|
||
color: '#ffcb05',
|
||
bgColor: '#1d1d42'
|
||
},
|
||
target: {
|
||
name: 'Target',
|
||
logo: 'https://www.google.com/s2/favicons?domain=target.com&sz=128',
|
||
fallback: '◎',
|
||
color: '#cc0000',
|
||
bgColor: '#ffffff'
|
||
},
|
||
walmart: {
|
||
name: 'Walmart',
|
||
logo: 'https://www.google.com/s2/favicons?domain=walmart.com&sz=128',
|
||
fallback: '✱',
|
||
color: '#0071ce',
|
||
bgColor: '#ffffff'
|
||
},
|
||
bestbuy: {
|
||
name: 'Best Buy',
|
||
logo: 'https://www.google.com/s2/favicons?domain=bestbuy.com&sz=128',
|
||
fallback: 'BB',
|
||
color: '#fff200',
|
||
bgColor: '#0046be'
|
||
},
|
||
gamestop: {
|
||
name: 'GameStop',
|
||
logo: 'https://www.google.com/s2/favicons?domain=gamestop.com&sz=128',
|
||
fallback: 'GS',
|
||
color: '#ff0000',
|
||
bgColor: '#000000'
|
||
}
|
||
};
|
||
|
||
// All stores to show (even without data)
|
||
const ALL_STORES = ['pokemoncenter', 'target', 'gamestop', 'walmart', 'bestbuy'];
|
||
|
||
// Dashboard
|
||
async function loadDashboard() {
|
||
const stats = await api('/stats');
|
||
if (!stats) return;
|
||
|
||
// Update stats cards
|
||
document.getElementById('newDropsToday').textContent = stats.new_drops_today || 0;
|
||
document.getElementById('restocksToday').textContent = stats.restocks_today || 0;
|
||
document.getElementById('newDropsWeek').textContent = stats.new_drops_week || 0;
|
||
document.getElementById('restocksWeek').textContent = stats.restocks_week || 0;
|
||
|
||
// Update store cards
|
||
loadStoreCards(stats.sites || []);
|
||
|
||
// Update status
|
||
const statusDot = document.getElementById('monitorStatus');
|
||
const statusText = document.getElementById('statusText');
|
||
if (stats.last_check) {
|
||
statusDot.classList.add('active');
|
||
const lastCheck = new Date(stats.last_check.checked_at);
|
||
statusText.textContent = `Last check: ${formatTime(lastCheck)}`;
|
||
} else {
|
||
statusDot.classList.remove('active');
|
||
statusText.textContent = 'No checks yet';
|
||
}
|
||
|
||
// Load activity feed
|
||
loadActivityFeed();
|
||
}
|
||
|
||
function loadStoreCards(sites) {
|
||
const container = document.getElementById('storeCards');
|
||
|
||
// Create a map of site data for quick lookup
|
||
const siteDataMap = {};
|
||
(sites || []).forEach(s => {
|
||
siteDataMap[s.site] = s;
|
||
});
|
||
|
||
// Show all stores, even without data
|
||
container.innerHTML = ALL_STORES.map(storeKey => {
|
||
const info = STORE_INFO[storeKey];
|
||
const data = siteDataMap[storeKey] || { total_products: 0, in_stock_count: 0 };
|
||
const outOfStock = data.total_products - data.in_stock_count;
|
||
const hasData = data.total_products > 0;
|
||
|
||
return `
|
||
<div class="store-card ${hasData ? '' : 'no-data'}" onclick="goToStoreProducts('${storeKey}')">
|
||
<div class="store-logo-container" style="background: ${info.bgColor};">
|
||
<img src="${info.logo}" alt="${info.name}" class="store-logo-img"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||
<span class="store-icon-fallback" style="display:none; color: ${info.color};">${info.fallback}</span>
|
||
</div>
|
||
<div class="store-info">
|
||
<div class="store-name">${info.name}</div>
|
||
<div class="store-stats">
|
||
${hasData ? `
|
||
<span class="store-stat in-stock">
|
||
<span>●</span> ${data.in_stock_count} in stock
|
||
</span>
|
||
<span class="store-stat out-of-stock">
|
||
<span>○</span> ${outOfStock} sold out
|
||
</span>
|
||
` : `
|
||
<span class="store-stat no-data">Not tracking yet</span>
|
||
`}
|
||
</div>
|
||
</div>
|
||
<div class="store-arrow">›</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function goToStoreProducts(store) {
|
||
// Navigate to products page and filter by store
|
||
navigateTo('products');
|
||
document.getElementById('filterSite').value = store;
|
||
loadProducts();
|
||
}
|
||
|
||
async function loadActivityFeed() {
|
||
const data = await api('/events?limit=20');
|
||
if (!data || !data.events) return;
|
||
|
||
const feed = document.getElementById('activityFeed');
|
||
if (data.events.length === 0) {
|
||
feed.innerHTML = '<div class="loading">No recent activity</div>';
|
||
return;
|
||
}
|
||
|
||
feed.innerHTML = data.events.map(event => {
|
||
const storeInfo = STORE_INFO[event.site] || { name: event.site, color: '#333', bgColor: '#666', logo: '', fallback: '?' };
|
||
const badgeClass = event.event_type === 'restock' ? 'restock' : 'new-drop';
|
||
const badgeText = event.event_type === 'restock' ? 'Restock' : 'New';
|
||
const hasProductImage = event.image_url && !event.image_url.includes('data:') && event.image_url.length > 10;
|
||
|
||
// Build store logo HTML with fallback
|
||
const storeLogoHtml = `
|
||
<div class="activity-store-icon" style="background:${storeInfo.bgColor};">
|
||
<img src="${storeInfo.logo}" alt="${storeInfo.name}" class="activity-store-logo"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||
<span class="activity-store-fallback" style="display:none; color:${storeInfo.color};">${storeInfo.fallback}</span>
|
||
</div>
|
||
`;
|
||
|
||
return `
|
||
<div class="activity-item">
|
||
${hasProductImage
|
||
? `<img src="${event.image_url}" class="activity-thumb" alt="" onerror="handleActivityImageError(this, '${event.site}')">`
|
||
: storeLogoHtml
|
||
}
|
||
<div class="activity-details">
|
||
<div class="activity-name">
|
||
<a href="${event.url}" target="_blank">${cleanProductName(event.name)}</a>
|
||
<span class="activity-badge ${badgeClass}">${badgeText}</span>
|
||
</div>
|
||
<div class="activity-meta">${storeInfo.name} • ${event.current_price || 'N/A'}</div>
|
||
</div>
|
||
<div class="activity-time">${formatTime(new Date(event.recorded_at))}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function handleActivityImageError(img, site) {
|
||
const storeInfo = STORE_INFO[site] || { name: site, color: '#333', bgColor: '#666', logo: '', fallback: '?' };
|
||
const div = document.createElement('div');
|
||
div.className = 'activity-store-icon';
|
||
div.style.background = storeInfo.bgColor;
|
||
div.innerHTML = `
|
||
<img src="${storeInfo.logo}" alt="${storeInfo.name}" class="activity-store-logo"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||
<span class="activity-store-fallback" style="display:none; color:${storeInfo.color};">${storeInfo.fallback}</span>
|
||
`;
|
||
img.replaceWith(div);
|
||
}
|
||
|
||
// Navigate to products filtered by event type
|
||
function goToFilteredProducts(eventType, period) {
|
||
currentEventFilter = { type: eventType, period: period };
|
||
currentProductPage = 1;
|
||
navigateTo('products');
|
||
}
|
||
|
||
// Clear event filter
|
||
function clearEventFilter() {
|
||
currentEventFilter = null;
|
||
currentProductPage = 1;
|
||
loadProducts();
|
||
}
|
||
|
||
// Products
|
||
async function loadProducts() {
|
||
const site = document.getElementById('filterSite')?.value || '';
|
||
const category = document.getElementById('filterCategory')?.value || '';
|
||
const inStock = document.getElementById('filterStock')?.value || '';
|
||
const favoritesOnly = document.getElementById('filterFavorites')?.checked || false;
|
||
const offset = (currentProductPage - 1) * PRODUCTS_PER_PAGE;
|
||
|
||
let url = `/products?limit=${PRODUCTS_PER_PAGE}&offset=${offset}`;
|
||
if (site) url += `&site=${site}`;
|
||
if (category) url += `&category=${encodeURIComponent(category)}`;
|
||
if (inStock) url += `&in_stock=${inStock}`;
|
||
if (favoritesOnly) url += '&favorites_only=true';
|
||
|
||
// Apply event type filter if set
|
||
if (currentEventFilter) {
|
||
url += `&event_type=${currentEventFilter.type}&period=${currentEventFilter.period}`;
|
||
}
|
||
|
||
// Update filter badge visibility
|
||
updateFilterBadge();
|
||
|
||
const data = await api(url);
|
||
if (!data || !data.products) return;
|
||
|
||
const grid = document.getElementById('productsGrid');
|
||
|
||
if (data.products.length === 0) {
|
||
grid.innerHTML = '<div class="loading">No products found</div>';
|
||
renderPagination(0);
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = data.products.map(product => {
|
||
const storeInfo = STORE_INFO[product.site] || { name: product.site, color: '#333', bgColor: '#666', logo: '', fallback: '?' };
|
||
const cleanName = cleanProductName(product.name);
|
||
const hasImage = product.image_url && !product.image_url.includes('data:') && product.image_url.length > 10;
|
||
|
||
return `
|
||
<div class="product-card ${product.in_stock ? 'in-stock' : 'out-of-stock'} ${product.is_favorite ? 'favorite' : ''}"
|
||
onclick="showProductDetails(${product.id})">
|
||
<div class="product-image-container">
|
||
${hasImage
|
||
? `<img src="${product.image_url}" class="product-image" alt="" onerror="handleProductImageError(this, '${product.site}')">`
|
||
: `<div class="product-store-icon" style="background:${storeInfo.bgColor};">
|
||
<img src="${storeInfo.logo}" alt="${storeInfo.name}" class="product-store-logo"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||
<span class="product-store-fallback" style="display:none; color:${storeInfo.color};">${storeInfo.fallback}</span>
|
||
</div>`
|
||
}
|
||
<div class="product-store-badge" style="background:${storeInfo.bgColor};">
|
||
<img src="${storeInfo.logo}" alt="${storeInfo.name}" class="store-badge-logo"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||
<span class="store-badge-fallback" style="display:none; color:${storeInfo.color};">${storeInfo.fallback}</span>
|
||
</div>
|
||
</div>
|
||
<div class="product-name">${cleanName}</div>
|
||
<div class="product-meta">
|
||
<span class="product-price">${product.current_price || 'N/A'}</span>
|
||
<span class="product-stock ${product.in_stock ? 'in-stock' : 'out-of-stock'}">
|
||
${product.in_stock ? 'IN STOCK' : 'OUT OF STOCK'}
|
||
</span>
|
||
</div>
|
||
<div class="product-actions">
|
||
<span class="product-site">${storeInfo.name}</span>
|
||
<button class="btn-favorite ${product.is_favorite ? 'active' : ''}"
|
||
onclick="event.stopPropagation(); toggleFavorite('${product.url}', '${cleanName.replace(/'/g, "\\'")}', ${product.favorite_id || 'null'})">
|
||
${product.is_favorite ? '★' : '☆'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
renderPagination(data.total || 0);
|
||
}
|
||
|
||
function renderPagination(total) {
|
||
const container = document.getElementById('productsPagination');
|
||
if (!container) return;
|
||
|
||
const totalPages = Math.ceil(total / PRODUCTS_PER_PAGE);
|
||
if (totalPages <= 1) {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const start = (currentProductPage - 1) * PRODUCTS_PER_PAGE + 1;
|
||
const end = Math.min(currentProductPage * PRODUCTS_PER_PAGE, total);
|
||
|
||
container.innerHTML = `
|
||
<div class="pagination">
|
||
<button class="btn-page" onclick="changeProductPage(${currentProductPage - 1})" ${currentProductPage === 1 ? 'disabled' : ''}>« Prev</button>
|
||
<span class="page-info">Page ${currentProductPage} of ${totalPages} (${start}–${end} of ${total})</span>
|
||
<button class="btn-page" onclick="changeProductPage(${currentProductPage + 1})" ${currentProductPage === totalPages ? 'disabled' : ''}>Next »</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function changeProductPage(page) {
|
||
currentProductPage = page;
|
||
loadProducts();
|
||
document.getElementById('page-products').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
// Handle product image errors by showing store icon
|
||
function handleProductImageError(img, site) {
|
||
const storeInfo = STORE_INFO[site] || { name: site, color: '#333', bgColor: '#666', logo: '', fallback: '?' };
|
||
const container = img.parentElement;
|
||
img.style.display = 'none';
|
||
|
||
// Create fallback element
|
||
const fallback = document.createElement('div');
|
||
fallback.className = 'product-store-icon';
|
||
fallback.style.background = storeInfo.bgColor;
|
||
fallback.innerHTML = `
|
||
<img src="${storeInfo.logo}" alt="${storeInfo.name}" class="product-store-logo"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||
<span class="product-store-fallback" style="display:none; color:${storeInfo.color};">${storeInfo.fallback}</span>
|
||
`;
|
||
container.insertBefore(fallback, img);
|
||
}
|
||
|
||
async function showProductDetails(productId) {
|
||
const product = await api(`/products/${productId}`);
|
||
const history = await api(`/products/${productId}/history`);
|
||
|
||
if (!product) return;
|
||
|
||
document.getElementById('modalProductName').textContent = cleanProductName(product.name);
|
||
const storeInfo = STORE_INFO[product.site] || { name: product.site, color: '#333', bgColor: '#666', icon: '?' };
|
||
|
||
document.getElementById('modalProductDetails').innerHTML = `
|
||
<p><strong>Store:</strong> ${storeInfo.name}</p>
|
||
<p><strong>Category:</strong> ${product.category || 'Unknown'}</p>
|
||
<p><strong>Price:</strong> ${product.current_price || 'N/A'}</p>
|
||
<p><strong>Status:</strong> ${product.in_stock ? '<span style="color: #00c853;">In Stock</span>' : '<span style="color: #c62828;">Out of Stock</span>'}</p>
|
||
<p><strong>First Seen:</strong> ${formatDate(product.first_seen)}</p>
|
||
<p><strong>Last Seen:</strong> ${formatDate(product.last_seen)}</p>
|
||
<p><a href="${product.url}" target="_blank" style="color: #ffcb05;">View on ${storeInfo.name} →</a></p>
|
||
${history && history.price_history && history.price_history.length > 0 ? `
|
||
<h4>Price History</h4>
|
||
<ul>
|
||
${history.price_history.map(p => `<li>${p.price} - ${formatDate(p.recorded_at)}</li>`).join('')}
|
||
</ul>
|
||
` : ''}
|
||
`;
|
||
|
||
document.getElementById('productModal').classList.add('show');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('productModal').classList.remove('show');
|
||
}
|
||
|
||
// Analytics
|
||
async function loadAnalytics() {
|
||
await Promise.all([
|
||
loadDropTimingChart(),
|
||
loadStockDurationChart(),
|
||
loadSiteChart()
|
||
]);
|
||
}
|
||
|
||
async function loadDropTimingChart() {
|
||
const data = await api('/analytics/drops');
|
||
if (!data || !data.drop_timing) return;
|
||
|
||
const ctx = document.getElementById('dropTimingChart');
|
||
if (!ctx) return;
|
||
|
||
// Destroy existing chart
|
||
if (charts.dropTiming) charts.dropTiming.destroy();
|
||
|
||
// Fill in missing hours
|
||
const hourlyData = new Array(24).fill(0);
|
||
data.drop_timing.forEach(d => {
|
||
hourlyData[parseInt(d.hour)] = d.count;
|
||
});
|
||
|
||
charts.dropTiming = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: Array.from({length: 24}, (_, i) => `${i}:00`),
|
||
datasets: [{
|
||
label: 'Drops/Restocks',
|
||
data: hourlyData,
|
||
backgroundColor: 'rgba(255, 203, 5, 0.6)',
|
||
borderColor: 'rgba(255, 203, 5, 1)',
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { display: false }
|
||
},
|
||
scales: {
|
||
y: { beginAtZero: true, ticks: { color: '#aaa' } },
|
||
x: { ticks: { color: '#aaa' } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadStockDurationChart() {
|
||
const data = await api('/analytics/stock');
|
||
if (!data || !data.stock_duration) return;
|
||
|
||
const ctx = document.getElementById('stockDurationChart');
|
||
if (!ctx) return;
|
||
|
||
if (charts.stockDuration) charts.stockDuration.destroy();
|
||
|
||
const labels = data.stock_duration.map(d => d.category || 'Unknown');
|
||
const values = data.stock_duration.map(d => d.avg_minutes_in_stock || 0);
|
||
|
||
charts.stockDuration = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
label: 'Avg Minutes In Stock',
|
||
data: values,
|
||
backgroundColor: 'rgba(0, 200, 83, 0.6)',
|
||
borderColor: 'rgba(0, 200, 83, 1)',
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
indexAxis: 'y',
|
||
plugins: {
|
||
legend: { display: false }
|
||
},
|
||
scales: {
|
||
x: { beginAtZero: true, ticks: { color: '#aaa' } },
|
||
y: { ticks: { color: '#aaa' } }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadSiteChart() {
|
||
const data = await api('/analytics/sites');
|
||
if (!data || !data.sites) return;
|
||
|
||
const ctx = document.getElementById('siteChart');
|
||
if (!ctx) return;
|
||
|
||
if (charts.site) charts.site.destroy();
|
||
|
||
charts.site = new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: data.sites.map(s => s.site),
|
||
datasets: [{
|
||
data: data.sites.map(s => s.total_products),
|
||
backgroundColor: [
|
||
'rgba(255, 203, 5, 0.8)',
|
||
'rgba(0, 153, 255, 0.8)',
|
||
'rgba(0, 200, 83, 0.8)',
|
||
'rgba(198, 40, 40, 0.8)'
|
||
]
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom',
|
||
labels: { color: '#eee' }
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Favorites
|
||
async function loadFavorites() {
|
||
const data = await api('/favorites');
|
||
if (!data || !data.favorites) return;
|
||
|
||
const list = document.getElementById('favoritesList');
|
||
|
||
if (data.favorites.length === 0) {
|
||
list.innerHTML = '<div class="loading">No favorites yet. Add some above!</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = data.favorites.map(fav => `
|
||
<div class="favorite-item">
|
||
<div class="favorite-icon">${fav.type === 'category' ? '📁' : '🔗'}</div>
|
||
<div class="favorite-details">
|
||
<div class="favorite-name">${escapeHtml(fav.display_name || fav.value)}</div>
|
||
<div class="favorite-type">${fav.type === 'category' ? 'Category' : 'Product'}</div>
|
||
</div>
|
||
<span class="favorite-priority ${fav.priority}">${fav.priority.toUpperCase()}</span>
|
||
<button class="btn-delete" onclick="deleteFavorite(${fav.id})">🗑</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function loadSuggestions() {
|
||
const data = await api('/favorites/suggestions');
|
||
if (!data || !data.suggestions) return;
|
||
|
||
const container = document.getElementById('suggestions');
|
||
container.innerHTML = data.suggestions.slice(0, 10).map(s => `
|
||
<span class="suggestion-tag" onclick="document.getElementById('favValue').value = '${s}'">${s}</span>
|
||
`).join('');
|
||
}
|
||
|
||
async function addFavorite() {
|
||
const type = document.getElementById('favType').value;
|
||
const value = document.getElementById('favValue').value.trim();
|
||
const priority = document.getElementById('favPriority').value;
|
||
|
||
if (!value) {
|
||
alert('Please enter a value');
|
||
return;
|
||
}
|
||
|
||
await api('/favorites', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ type, value, priority })
|
||
});
|
||
|
||
document.getElementById('favValue').value = '';
|
||
loadFavorites();
|
||
}
|
||
|
||
async function deleteFavorite(id) {
|
||
if (!confirm('Remove this favorite?')) return;
|
||
|
||
await api(`/favorites/${id}`, { method: 'DELETE' });
|
||
loadFavorites();
|
||
}
|
||
|
||
async function toggleFavorite(url, name, favoriteId) {
|
||
if (favoriteId) {
|
||
await api(`/favorites/${favoriteId}`, { method: 'DELETE' });
|
||
} else {
|
||
await api('/favorites', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
type: 'product',
|
||
value: url,
|
||
display_name: name,
|
||
priority: 'high'
|
||
})
|
||
});
|
||
}
|
||
loadProducts();
|
||
}
|
||
|
||
// Utilities
|
||
function updateFilterBadge() {
|
||
const badge = document.getElementById('eventFilterBadge');
|
||
if (!badge) return;
|
||
|
||
if (currentEventFilter) {
|
||
const typeLabel = currentEventFilter.type === 'new_drop' ? 'New Drops' : 'Restocks';
|
||
const periodLabel = currentEventFilter.period === 'today' ? 'Today' : '7 Days';
|
||
badge.innerHTML = `
|
||
<span class="filter-badge-text">${typeLabel} - ${periodLabel}</span>
|
||
<span class="filter-badge-close" onclick="clearEventFilter()">×</span>
|
||
`;
|
||
badge.style.display = 'flex';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function formatTime(date) {
|
||
const now = new Date();
|
||
const diff = (now - date) / 1000;
|
||
|
||
if (diff < 60) return 'Just now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return 'N/A';
|
||
return new Date(dateStr).toLocaleString();
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function cleanProductName(name) {
|
||
if (!name) return '';
|
||
|
||
// Fix common UTF-8 encoding issues (mojibake)
|
||
return name
|
||
// Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...)
|
||
.replace(/[\w-]*_svg__[\w\s{}:#.;-]+/gi, '')
|
||
.replace(/\.review-full-[\w\s{}:#.;-]+/gi, '')
|
||
.replace(/\{[^}]*\}/g, '') // Remove any remaining {css} blocks
|
||
.replace(/\d+\s*Reviews?$/i, '') // Remove trailing "X Reviews"
|
||
// Fix Pokémon - multiple encoding patterns
|
||
.replace(/Pok[éÃ\u00c3\u00a9\ufffd]+mon/gi, 'Pokémon')
|
||
.replace(/Pokémon/gi, 'Pokémon')
|
||
.replace(/Pokémon/gi, 'Pokémon')
|
||
.replace(/Pokテゥmon/gi, 'Pokémon')
|
||
// Keep "Pokemon" as-is for non-encoded versions
|
||
// Fix dashes - â€" is UTF-8 em-dash mangled
|
||
.replace(/â€"/g, '–')
|
||
.replace(/â€"/g, '-')
|
||
.replace(/â€""/g, '–')
|
||
// Fix apostrophes and quotes
|
||
.replace(/’/g, "'")
|
||
.replace(/‘/g, "'")
|
||
.replace(/“/g, '"')
|
||
.replace(/â€[^a-zA-Z0-9]/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/'/g, "'")
|
||
.replace(/"/g, '"')
|
||
.replace(/"/g, '"')
|
||
// Fix accented characters
|
||
.replace(/é/g, 'é')
|
||
.replace(/è/g, 'è')
|
||
.replace(/Ã /g, 'à')
|
||
.replace(/â/g, 'â')
|
||
.replace(/î/g, 'î')
|
||
.replace(/ô/g, 'ô')
|
||
.replace(/û/g, 'û')
|
||
// Fix trademark symbols
|
||
.replace(/â„¢/g, '™')
|
||
.replace(/®/g, '®')
|
||
// Clean up any remaining garbled chars
|
||
.replace(/Â/g, '')
|
||
// Clean up any double spaces
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
// ==================== Control Panel ====================
|
||
|
||
let controlPanelInterval = null;
|
||
|
||
async function loadControlPanel() {
|
||
const state = await api('/scrapers/state');
|
||
if (!state) return;
|
||
|
||
// Update monitor status
|
||
updateMonitorStatus(state);
|
||
|
||
// Update check interval input
|
||
document.getElementById('checkIntervalInput').value = state.check_interval || 60;
|
||
|
||
// Render scrapers grid
|
||
renderScrapersGrid(state.scrapers);
|
||
|
||
// Load Chrome Extension status
|
||
loadExtensionStatus();
|
||
|
||
// Setup event listeners for control panel buttons
|
||
setupControlPanelListeners();
|
||
|
||
// Auto-refresh control panel every 5 seconds
|
||
if (controlPanelInterval) clearInterval(controlPanelInterval);
|
||
controlPanelInterval = setInterval(async () => {
|
||
if (currentPage === 'control') {
|
||
const newState = await api('/scrapers/state');
|
||
if (newState) {
|
||
updateMonitorStatus(newState);
|
||
renderScrapersGrid(newState.scrapers);
|
||
}
|
||
loadExtensionStatus();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
async function loadExtensionStatus() {
|
||
const badge = document.getElementById('extensionBadge');
|
||
const productCount = document.getElementById('extProductCount');
|
||
const skuCount = document.getElementById('extSkuCount');
|
||
const lastSync = document.getElementById('extLastSync');
|
||
|
||
const stats = await api('/extension/stats');
|
||
|
||
if (stats && stats.last_sync) {
|
||
// Extension has synced data
|
||
badge.textContent = 'Connected';
|
||
badge.className = 'extension-badge connected';
|
||
productCount.textContent = stats.total_products || 0;
|
||
skuCount.textContent = stats.total_skus || 0;
|
||
lastSync.textContent = formatTime(new Date(stats.last_sync));
|
||
} else {
|
||
// No sync yet
|
||
badge.textContent = 'No Data';
|
||
badge.className = 'extension-badge disconnected';
|
||
productCount.textContent = '-';
|
||
skuCount.textContent = '-';
|
||
lastSync.textContent = 'Never';
|
||
}
|
||
}
|
||
|
||
function updateMonitorStatus(state) {
|
||
const badge = document.getElementById('monitorBadge');
|
||
const statusText = document.getElementById('monitorStatusText');
|
||
const lastCheck = document.getElementById('controlLastCheck');
|
||
const startBtn = document.getElementById('startMonitorBtn');
|
||
const stopBtn = document.getElementById('stopMonitorBtn');
|
||
|
||
if (state.monitor_running) {
|
||
badge.textContent = 'Running';
|
||
badge.className = 'monitor-badge running';
|
||
statusText.textContent = `Monitor is running (PID: ${state.monitor_pid})`;
|
||
startBtn.disabled = true;
|
||
stopBtn.disabled = false;
|
||
} else {
|
||
badge.textContent = 'Stopped';
|
||
badge.className = 'monitor-badge stopped';
|
||
statusText.textContent = 'Monitor is not running';
|
||
startBtn.disabled = false;
|
||
stopBtn.disabled = true;
|
||
}
|
||
|
||
if (state.last_check) {
|
||
lastCheck.textContent = formatTime(new Date(state.last_check));
|
||
} else {
|
||
lastCheck.textContent = 'Never';
|
||
}
|
||
}
|
||
|
||
function renderScrapersGrid(scrapers) {
|
||
const grid = document.getElementById('scrapersGrid');
|
||
|
||
grid.innerHTML = Object.entries(scrapers).map(([key, data]) => {
|
||
const info = STORE_INFO[key] || { name: key, logo: '', fallback: key.substring(0, 2).toUpperCase(), bgColor: '#333', color: '#fff' };
|
||
const enabledClass = data.enabled ? 'enabled' : 'disabled';
|
||
const runningClass = data.running ? 'running' : '';
|
||
|
||
return `
|
||
<div class="scraper-card ${enabledClass} ${runningClass}">
|
||
<div class="scraper-logo" style="background: ${info.bgColor};">
|
||
<img src="${info.logo}" alt="${info.name}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||
<span class="scraper-logo-fallback" style="display:none; color: ${info.color};">${info.fallback}</span>
|
||
</div>
|
||
<div class="scraper-info">
|
||
<div class="scraper-name">${info.name}</div>
|
||
<div class="scraper-status">
|
||
${data.running ? '<span class="status-running">● Running</span>' :
|
||
data.enabled ? '<span class="status-enabled">● Enabled</span>' :
|
||
'<span class="status-disabled">○ Disabled</span>'}
|
||
</div>
|
||
${data.last_run ? `<div class="scraper-last-run">Last: ${formatTime(new Date(data.last_run))}</div>` : ''}
|
||
${data.last_error ? `<div class="scraper-error">${data.last_error}</div>` : ''}
|
||
</div>
|
||
<label class="toggle-switch">
|
||
<input type="checkbox" ${data.enabled ? 'checked' : ''} onchange="toggleScraper('${key}', this.checked)">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function setupControlPanelListeners() {
|
||
// Start/Stop buttons
|
||
document.getElementById('startMonitorBtn')?.addEventListener('click', startMonitor);
|
||
document.getElementById('stopMonitorBtn')?.addEventListener('click', stopMonitor);
|
||
|
||
// Save interval button
|
||
document.getElementById('saveIntervalBtn')?.addEventListener('click', saveCheckInterval);
|
||
|
||
// Quick actions
|
||
document.getElementById('runCheckNowBtn')?.addEventListener('click', runCheckNow);
|
||
document.getElementById('viewLogsBtn')?.addEventListener('click', viewLogs);
|
||
}
|
||
|
||
async function toggleScraper(scraper, enabled) {
|
||
const result = await api(`/scrapers/${scraper}/toggle`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
|
||
if (result && result.success) {
|
||
// Refresh the control panel
|
||
loadControlPanel();
|
||
} else {
|
||
alert('Failed to toggle scraper: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
async function startMonitor() {
|
||
const btn = document.getElementById('startMonitorBtn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="btn-icon">⌛</span> Starting...';
|
||
|
||
const result = await api('/scrapers/start', { method: 'POST' });
|
||
|
||
if (result && result.success) {
|
||
loadControlPanel();
|
||
} else {
|
||
alert('Failed to start monitor: ' + (result?.error || 'Unknown error'));
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<span class="btn-icon">▶</span> Start Monitor';
|
||
}
|
||
}
|
||
|
||
async function stopMonitor() {
|
||
const btn = document.getElementById('stopMonitorBtn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="btn-icon">⌛</span> Stopping...';
|
||
|
||
const result = await api('/scrapers/stop', { method: 'POST' });
|
||
|
||
if (result && result.success) {
|
||
loadControlPanel();
|
||
} else {
|
||
alert('Failed to stop monitor: ' + (result?.error || 'Unknown error'));
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<span class="btn-icon">■</span> Stop Monitor';
|
||
}
|
||
}
|
||
|
||
async function saveCheckInterval() {
|
||
const input = document.getElementById('checkIntervalInput');
|
||
const interval = parseInt(input.value);
|
||
|
||
if (isNaN(interval) || interval < 10) {
|
||
alert('Interval must be at least 10 seconds');
|
||
return;
|
||
}
|
||
|
||
const result = await api('/scrapers/interval', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ interval })
|
||
});
|
||
|
||
if (result && result.success) {
|
||
alert('Check interval saved!');
|
||
} else {
|
||
alert('Failed to save interval: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
async function runCheckNow() {
|
||
const result = await api('/check', { method: 'POST' });
|
||
if (result) {
|
||
alert(result.message || 'Check triggered');
|
||
}
|
||
}
|
||
|
||
function viewLogs() {
|
||
// Open monitor.log in a new window or show in modal
|
||
alert('View monitor.log file in the project directory for detailed logs.');
|
||
}
|
||
|
||
// ==================== News ====================
|
||
|
||
let newsInitialized = false;
|
||
|
||
async function loadNews() {
|
||
// Initialize event listeners once
|
||
if (!newsInitialized) {
|
||
initNewsEventListeners();
|
||
newsInitialized = true;
|
||
}
|
||
|
||
// Load stats and articles
|
||
await Promise.all([
|
||
loadNewsStats(),
|
||
loadNewsArticles()
|
||
]);
|
||
}
|
||
|
||
function initNewsEventListeners() {
|
||
// Filter changes
|
||
document.getElementById('filterNewsSource')?.addEventListener('change', loadNewsArticles);
|
||
document.getElementById('filterNewsSentiment')?.addEventListener('change', loadNewsArticles);
|
||
document.getElementById('filterDropRelated')?.addEventListener('change', loadNewsArticles);
|
||
|
||
// Refresh button
|
||
document.getElementById('refreshNewsBtn')?.addEventListener('click', refreshNews);
|
||
|
||
// Manual news input
|
||
document.getElementById('addManualNewsBtn')?.addEventListener('click', addManualNews);
|
||
}
|
||
|
||
async function loadNewsStats() {
|
||
const stats = await api('/news/stats');
|
||
if (!stats) return;
|
||
|
||
// Update sentiment bar
|
||
const total = (stats.by_sentiment?.positive || 0) +
|
||
(stats.by_sentiment?.neutral || 0) +
|
||
(stats.by_sentiment?.negative || 0);
|
||
|
||
if (total > 0) {
|
||
const positivePercent = (stats.by_sentiment?.positive || 0) / total * 100;
|
||
const neutralPercent = (stats.by_sentiment?.neutral || 0) / total * 100;
|
||
const negativePercent = (stats.by_sentiment?.negative || 0) / total * 100;
|
||
|
||
document.getElementById('sentimentPositiveBar').style.width = positivePercent + '%';
|
||
document.getElementById('sentimentNeutralBar').style.width = neutralPercent + '%';
|
||
document.getElementById('sentimentNegativeBar').style.width = negativePercent + '%';
|
||
|
||
document.getElementById('sentimentText').textContent =
|
||
`${Math.round(positivePercent)}% Positive, ${Math.round(neutralPercent)}% Neutral, ${Math.round(negativePercent)}% Negative`;
|
||
} else {
|
||
document.getElementById('sentimentText').textContent = 'No sentiment data yet';
|
||
}
|
||
|
||
// Update today count
|
||
document.getElementById('todayArticles').textContent = stats.today || 0;
|
||
}
|
||
|
||
async function loadNewsArticles() {
|
||
const source = document.getElementById('filterNewsSource')?.value || '';
|
||
const sentiment = document.getElementById('filterNewsSentiment')?.value || '';
|
||
const dropRelated = document.getElementById('filterDropRelated')?.checked || false;
|
||
|
||
let url = '/news?limit=50';
|
||
if (source) url += `&source=${source}`;
|
||
if (sentiment) url += `&sentiment=${sentiment}`;
|
||
if (dropRelated) url += '&drop_related=true';
|
||
|
||
const data = await api(url);
|
||
if (!data || !data.articles) return;
|
||
|
||
const feed = document.getElementById('newsFeed');
|
||
|
||
if (data.articles.length === 0) {
|
||
feed.innerHTML = '<div class="loading">No news articles yet. Click "Refresh News" to fetch from sources.</div>';
|
||
return;
|
||
}
|
||
|
||
feed.innerHTML = data.articles.map(article => {
|
||
const sourceIcon = getSourceIcon(article.source);
|
||
const sentimentBadge = getSentimentBadge(article.sentiment_label, article.sentiment_score);
|
||
const timeAgo = formatTime(new Date(article.published_at || article.fetched_at));
|
||
const keywords = article.keywords || [];
|
||
const hasCorrelation = article.is_drop_related || article.is_restock_related;
|
||
|
||
return `
|
||
<div class="news-card">
|
||
<div class="news-header">
|
||
<span class="news-source">
|
||
<span class="source-icon">${sourceIcon}</span>
|
||
${escapeHtml(article.source_account)}
|
||
</span>
|
||
<span class="news-time">${timeAgo}</span>
|
||
${sentimentBadge}
|
||
</div>
|
||
${article.title ? `<div class="news-title">${escapeHtml(article.title)}</div>` : ''}
|
||
<div class="news-content">${escapeHtml(article.content)}</div>
|
||
${article.url ? `<a href="${article.url}" target="_blank" class="news-link">View Original</a>` : ''}
|
||
${keywords.length > 0 ? `
|
||
<div class="news-keywords">
|
||
${keywords.slice(0, 5).map(kw => `<span class="keyword-tag">${escapeHtml(kw)}</span>`).join('')}
|
||
</div>
|
||
` : ''}
|
||
${hasCorrelation ? `
|
||
<div class="news-correlation">
|
||
<span class="correlation-badge">
|
||
${article.is_drop_related ? 'Drop Related' : ''}
|
||
${article.is_restock_related ? 'Restock Related' : ''}
|
||
</span>
|
||
</div>
|
||
` : ''}
|
||
<div class="news-actions">
|
||
<button class="btn-delete-small" onclick="deleteNewsArticle(${article.id})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function getSourceIcon(source) {
|
||
switch (source) {
|
||
case 'twitter':
|
||
return '<span style="color: #1DA1F2;">X</span>';
|
||
case 'pokemon_official':
|
||
return '<span style="color: #FFCB05;">⚡</span>';
|
||
case 'discord_manual':
|
||
return '<span style="color: #5865F2;">💬</span>';
|
||
default:
|
||
return '<span>📄</span>';
|
||
}
|
||
}
|
||
|
||
function getSentimentBadge(label, score) {
|
||
if (!label) return '';
|
||
|
||
let badgeClass = 'neutral';
|
||
let symbol = '●';
|
||
|
||
if (label === 'positive') {
|
||
badgeClass = 'positive';
|
||
symbol = '+';
|
||
} else if (label === 'negative') {
|
||
badgeClass = 'negative';
|
||
symbol = '−';
|
||
}
|
||
|
||
const scoreText = score !== null ? ` (${score.toFixed(2)})` : '';
|
||
return `<span class="sentiment-badge ${badgeClass}">${symbol} ${label}${scoreText}</span>`;
|
||
}
|
||
|
||
async function refreshNews() {
|
||
const btn = document.getElementById('refreshNewsBtn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="btn-icon">⌛</span> Fetching...';
|
||
|
||
const result = await api('/news/refresh', { method: 'POST' });
|
||
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<span class="btn-icon">↻</span> Refresh News';
|
||
|
||
if (result && result.success) {
|
||
const msg = `Fetched ${result.fetched} articles.`;
|
||
if (result.errors && result.errors.length > 0) {
|
||
alert(msg + '\n\nErrors:\n' + result.errors.join('\n'));
|
||
}
|
||
// Reload news
|
||
loadNews();
|
||
} else {
|
||
alert('Failed to refresh news');
|
||
}
|
||
}
|
||
|
||
async function addManualNews() {
|
||
const source = document.getElementById('manualNewsSource')?.value || 'Other';
|
||
const title = document.getElementById('manualNewsTitle')?.value.trim() || null;
|
||
const content = document.getElementById('manualNewsContent')?.value.trim();
|
||
const url = document.getElementById('manualNewsUrl')?.value.trim() || null;
|
||
|
||
if (!content) {
|
||
alert('Please enter the message content');
|
||
return;
|
||
}
|
||
|
||
const result = await api('/news/manual', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
source_account: source,
|
||
title: title,
|
||
content: content,
|
||
url: url
|
||
})
|
||
});
|
||
|
||
if (result && result.success) {
|
||
// Clear form
|
||
document.getElementById('manualNewsTitle').value = '';
|
||
document.getElementById('manualNewsContent').value = '';
|
||
document.getElementById('manualNewsUrl').value = '';
|
||
|
||
// Show analysis result
|
||
const analysis = result.analysis;
|
||
alert(`News added!\n\nSentiment: ${analysis.sentiment_label} (${analysis.sentiment_score.toFixed(2)})\nDrop related: ${analysis.is_drop_related}\nRestock related: ${analysis.is_restock_related}`);
|
||
|
||
// Reload news
|
||
loadNews();
|
||
} else {
|
||
alert('Failed to add news: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
async function deleteNewsArticle(articleId) {
|
||
if (!confirm('Delete this news article?')) return;
|
||
|
||
const result = await api(`/news/${articleId}`, { method: 'DELETE' });
|
||
|
||
if (result && result.success) {
|
||
loadNewsArticles();
|
||
} else {
|
||
alert('Failed to delete article');
|
||
}
|
||
}
|
||
|
||
// ==================== Users Page ====================
|
||
|
||
let usersInitialized = false;
|
||
|
||
async function loadUsers() {
|
||
// Initialize event listeners once
|
||
if (!usersInitialized) {
|
||
document.getElementById('addUserBtn')?.addEventListener('click', addUser);
|
||
usersInitialized = true;
|
||
}
|
||
|
||
const data = await api('/users');
|
||
if (!data) return;
|
||
|
||
renderUsersList(data.users || []);
|
||
}
|
||
|
||
function renderUsersList(users) {
|
||
const container = document.getElementById('usersList');
|
||
if (!container) return;
|
||
|
||
if (users.length === 0) {
|
||
container.innerHTML = '<p class="empty-state">No users yet. Add one above!</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = users.map(user => `
|
||
<div class="user-card" data-user-id="${user.id}">
|
||
<div class="user-header">
|
||
<span class="user-name">${escapeHtml(user.name)}</span>
|
||
<span class="user-location">${user.zip_code ? `${user.zip_code} (${user.radius_miles}mi)` : 'No location set'}</span>
|
||
</div>
|
||
<div class="user-details">
|
||
<div class="form-inline">
|
||
<input type="text" class="user-zip-input" placeholder="Zip Code" value="${user.zip_code || ''}" maxlength="10">
|
||
<input type="number" class="user-radius-input" placeholder="Radius" value="${user.radius_miles || 25}" min="5" max="100" style="width: 80px;">
|
||
<button class="btn-secondary btn-small" onclick="updateUserLocation(${user.id})">Update</button>
|
||
<button class="btn-secondary btn-small" onclick="viewUserStores(${user.id})">View Stores</button>
|
||
<button class="btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
|
||
</div>
|
||
</div>
|
||
<div class="user-stores" id="userStores-${user.id}" style="display: none;"></div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function addUser() {
|
||
const nameInput = document.getElementById('newUserName');
|
||
const zipInput = document.getElementById('newUserZip');
|
||
const radiusInput = document.getElementById('newUserRadius');
|
||
|
||
const name = nameInput.value.trim();
|
||
const zip_code = zipInput.value.trim() || null;
|
||
const radius_miles = parseInt(radiusInput.value) || 25;
|
||
|
||
if (!name) {
|
||
alert('Please enter a name');
|
||
return;
|
||
}
|
||
|
||
const result = await api('/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, zip_code, radius_miles })
|
||
});
|
||
|
||
if (result && result.success) {
|
||
nameInput.value = '';
|
||
zipInput.value = '';
|
||
radiusInput.value = '25';
|
||
loadUsers();
|
||
} else {
|
||
alert('Failed to add user: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
async function updateUserLocation(userId) {
|
||
const card = document.querySelector(`[data-user-id="${userId}"]`);
|
||
const zip_code = card.querySelector('.user-zip-input').value.trim();
|
||
const radius_miles = parseInt(card.querySelector('.user-radius-input').value) || 25;
|
||
|
||
if (!zip_code) {
|
||
alert('Please enter a zip code');
|
||
return;
|
||
}
|
||
|
||
const result = await api(`/users/${userId}/location`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ zip_code, radius_miles })
|
||
});
|
||
|
||
if (result && result.success) {
|
||
loadUsers();
|
||
} else {
|
||
alert('Failed to update location: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
async function viewUserStores(userId) {
|
||
const storesDiv = document.getElementById(`userStores-${userId}`);
|
||
|
||
// Toggle visibility
|
||
if (storesDiv.style.display !== 'none') {
|
||
storesDiv.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
storesDiv.innerHTML = '<p class="loading">Loading stores...</p>';
|
||
storesDiv.style.display = 'block';
|
||
|
||
const result = await api(`/users/${userId}/stores`);
|
||
|
||
if (!result || result.error) {
|
||
storesDiv.innerHTML = `<p class="error">${result?.error || 'Failed to load stores'}</p>`;
|
||
return;
|
||
}
|
||
|
||
let html = `<div class="stores-grid">`;
|
||
for (const [retailer, stores] of Object.entries(result.stores)) {
|
||
if (stores.length > 0) {
|
||
html += `<div class="retailer-section">
|
||
<h4>${retailer} (${stores.length} stores)</h4>
|
||
<ul class="store-list">
|
||
${stores.slice(0, 5).map(s => `
|
||
<li>
|
||
<strong>${escapeHtml(s.name)}</strong> (${s.distance_miles.toFixed(1)} mi)<br>
|
||
<span class="store-address">${escapeHtml(s.address)}, ${escapeHtml(s.city)}, ${s.state}</span>
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>`;
|
||
}
|
||
}
|
||
html += '</div>';
|
||
|
||
storesDiv.innerHTML = html;
|
||
}
|
||
|
||
async function deleteUser(userId) {
|
||
if (!confirm('Delete this user?')) return;
|
||
|
||
const result = await api(`/users/${userId}`, { method: 'DELETE' });
|
||
|
||
if (result && result.success) {
|
||
loadUsers();
|
||
} else {
|
||
alert('Failed to delete user: ' + (result?.error || 'Unknown error'));
|
||
}
|
||
}
|
||
|
||
// ==================== Settings Page ====================
|
||
|
||
let settingsInitialized = false;
|
||
|
||
async function loadSettings() {
|
||
// Initialize event listeners once
|
||
if (!settingsInitialized) {
|
||
document.getElementById('checkBrokenUrlsBtn')?.addEventListener('click', checkBrokenUrls);
|
||
document.getElementById('cleanupBrokenUrlsBtn')?.addEventListener('click', cleanupBrokenUrls);
|
||
document.getElementById('deduplicateBtn')?.addEventListener('click', deduplicateProducts);
|
||
document.getElementById('deduplicateEventsBtn')?.addEventListener('click', deduplicateEvents);
|
||
settingsInitialized = true;
|
||
}
|
||
}
|
||
|
||
async function checkBrokenUrls() {
|
||
const resultEl = document.getElementById('brokenUrlsResult');
|
||
resultEl.textContent = 'Checking...';
|
||
|
||
const result = await api('/extension/broken-urls');
|
||
|
||
if (result) {
|
||
resultEl.textContent = `Found ${result.count} products with broken Pokemon Center URLs.`;
|
||
} else {
|
||
resultEl.textContent = 'Failed to check broken URLs';
|
||
}
|
||
}
|
||
|
||
async function cleanupBrokenUrls() {
|
||
if (!confirm('This will delete all Pokemon Center products with broken URLs. Continue?')) return;
|
||
|
||
const resultEl = document.getElementById('brokenUrlsResult');
|
||
resultEl.textContent = 'Cleaning up...';
|
||
|
||
const result = await api('/extension/broken-urls/cleanup', { method: 'POST' });
|
||
|
||
if (result && result.success) {
|
||
resultEl.textContent = `Deleted ${result.deleted} products with broken URLs.`;
|
||
} else {
|
||
resultEl.textContent = 'Failed to cleanup broken URLs';
|
||
}
|
||
}
|
||
|
||
async function deduplicateProducts() {
|
||
if (!confirm('This will remove duplicate products. Continue?')) return;
|
||
|
||
const resultEl = document.getElementById('deduplicateResult');
|
||
resultEl.textContent = 'Removing duplicates...';
|
||
|
||
const result = await api('/products/deduplicate', { method: 'POST' });
|
||
|
||
if (result && result.success) {
|
||
let message = `Found ${result.duplicates_found} duplicate groups, removed ${result.products_removed} products.`;
|
||
if (Object.keys(result.by_site).length > 0) {
|
||
message += ' By site: ' + Object.entries(result.by_site).map(([site, count]) => `${site}: ${count}`).join(', ');
|
||
}
|
||
resultEl.textContent = message;
|
||
} else {
|
||
resultEl.textContent = 'Failed to deduplicate products';
|
||
}
|
||
}
|
||
|
||
async function deduplicateEvents() {
|
||
if (!confirm('This will remove duplicate events from the activity feed. Continue?')) return;
|
||
|
||
const resultEl = document.getElementById('deduplicateEventsResult');
|
||
resultEl.textContent = 'Cleaning up events...';
|
||
|
||
const result = await api('/events/deduplicate', { method: 'POST' });
|
||
|
||
if (result && result.success) {
|
||
resultEl.textContent = `Removed ${result.events_removed} duplicate events.`;
|
||
} else {
|
||
resultEl.textContent = 'Failed to clean up events';
|
||
}
|
||
}
|