← back to New Items Dashboard
public/index.html
597 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 Items - Wines & Handbags</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
header {
text-align: center;
color: white;
margin-bottom: 30px;
}
h1 {
font-size: 3em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.subtitle {
font-size: 1.2em;
opacity: 0.9;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
color: #667eea;
font-size: 0.9em;
text-transform: uppercase;
margin-bottom: 10px;
letter-spacing: 1px;
}
.stat-value {
font-size: 2.5em;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.9em;
color: #666;
margin-top: 5px;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab {
flex: 1;
padding: 15px 30px;
background: rgba(255,255,255,0.2);
border: none;
border-radius: 8px;
color: white;
font-size: 1.1em;
cursor: pointer;
transition: all 0.3s;
font-weight: 600;
}
.tab:hover {
background: rgba(255,255,255,0.3);
}
.tab.active {
background: white;
color: #667eea;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.content {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
min-height: 500px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 300px), 1fr));
gap: 20px;
margin-top: 20px;
}
.grid-controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
padding: 12px 14px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
}
.grid-controls label {
font-size: 0.85em;
color: #374151;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.grid-controls select {
font-size: 0.9em;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #1f2937;
cursor: pointer;
}
.grid-controls input[type="range"] {
width: 160px;
cursor: pointer;
}
.grid-controls .density-val {
font-size: 0.8em;
color: #6b7280;
min-width: 48px;
font-variant-numeric: tabular-nums;
}
.item-card {
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.item-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
transform: translateY(-2px);
}
.new-badge {
position: absolute;
top: 10px;
right: 10px;
background: #10b981;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75em;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.item-image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 15px;
background: #f3f4f6;
}
.item-title {
font-size: 1.1em;
font-weight: 600;
margin-bottom: 10px;
color: #1f2937;
line-height: 1.4;
}
.item-details {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.9em;
color: #6b7280;
}
.item-detail {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-detail strong {
color: #374151;
}
.price {
font-size: 1.3em;
font-weight: bold;
color: #667eea;
margin-top: 10px;
}
.source-tag {
display: inline-block;
background: #f3f4f6;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.85em;
color: #6b7280;
margin-top: 10px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6b7280;
}
.empty-state h3 {
font-size: 1.5em;
margin-bottom: 10px;
color: #374151;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #6b7280;
font-size: 1.2em;
}
.last-updated {
text-align: center;
margin-top: 20px;
color: #6b7280;
font-size: 0.9em;
}
@media (max-width: 768px) {
h1 { font-size: 2em; }
.items-grid { grid-template-columns: 1fr; }
.tabs { flex-direction: column; }
}
</style>
<!-- Meta Pixel — INERT until set-fb-pixel.sh is run (does NOT load fbevents.js or hit Meta until flipped) -->
<script>
(function(){
var PIXEL_ID = '{{FACEBOOK_PIXEL_ID}}';
// Inert until flipped — the brace check prevents BOTH the loader IIFE
// AND the init call from running, so fbevents.js is never fetched.
if (PIXEL_ID.indexOf('{{') !== -1) return;
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', PIXEL_ID);
fbq('track', 'PageView');
})();
</script>
<!-- noscript fallback is added by set-fb-pixel.sh on flip (omitted here to avoid unflipped img phone-home) -->
<!-- End Meta Pixel -->
</head>
<body>
<div class="container">
<header>
<h1>🆕 NEW Items Dashboard</h1>
<p class="subtitle">Latest Wines & Handbags Just Listed</p>
</header>
<div class="stats" id="stats">
<div class="stat-card">
<h3>🍷 New Wines</h3>
<div class="stat-value" id="newWinesCount">-</div>
<div class="stat-label">Just discovered</div>
</div>
<div class="stat-card">
<h3>👜 New Handbags</h3>
<div class="stat-value" id="newHandbagsCount">-</div>
<div class="stat-label">Just listed</div>
</div>
<div class="stat-card">
<h3>📦 Total Wines Tracked</h3>
<div class="stat-value" id="totalWines">-</div>
<div class="stat-label">All time</div>
</div>
<div class="stat-card">
<h3>👝 Total Handbags</h3>
<div class="stat-value" id="totalHandbags">-</div>
<div class="stat-label">In database</div>
</div>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('wines')">🍷 New Wines</button>
<button class="tab" onclick="showTab('handbags')">👜 New Handbags</button>
</div>
<div class="content">
<div id="wines" class="tab-content active">
<div class="grid-controls">
<label>Sort:
<select id="wines-sort">
<option value="newest">Newest</option>
<option value="vendor">Vendor / Source</option>
<option value="series">Series / Vintage</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
</select>
</label>
<label>Density:
<input type="range" id="wines-density" min="180" max="480" step="20" value="300">
<span class="density-val" id="wines-density-val">300px</span>
</label>
</div>
<div id="wines-grid"><div class="loading">Loading new wines...</div></div>
</div>
<div id="handbags" class="tab-content">
<div class="grid-controls">
<label>Sort:
<select id="handbags-sort">
<option value="newest">Newest</option>
<option value="vendor">Brand</option>
<option value="series">Model</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
</select>
</label>
<label>Density:
<input type="range" id="handbags-density" min="180" max="480" step="20" value="300">
<span class="density-val" id="handbags-density-val">300px</span>
</label>
</div>
<div id="handbags-grid"><div class="loading">Loading new handbags...</div></div>
</div>
</div>
<div class="last-updated" id="lastUpdated"></div>
</div>
<script>
let winesData = [];
let handbagsData = [];
// ---- localStorage keys per Steve's standing rule: namespace by site+surface
const LS = {
winesSort: 'newItemsDash.wines.sort',
winesDensity: 'newItemsDash.wines.density',
handbagsSort: 'newItemsDash.handbags.sort',
handbagsDensity: 'newItemsDash.handbags.density',
};
// ---- Hydrate sort + density from localStorage BEFORE first fetch
function hydrateControls() {
const wSort = localStorage.getItem(LS.winesSort) || 'newest';
const wDen = parseInt(localStorage.getItem(LS.winesDensity) || '300', 10);
const hSort = localStorage.getItem(LS.handbagsSort) || 'newest';
const hDen = parseInt(localStorage.getItem(LS.handbagsDensity) || '300', 10);
const wSortEl = document.getElementById('wines-sort');
const wDenEl = document.getElementById('wines-density');
const hSortEl = document.getElementById('handbags-sort');
const hDenEl = document.getElementById('handbags-density');
if (wSortEl) wSortEl.value = wSort;
if (wDenEl) wDenEl.value = wDen;
if (hSortEl) hSortEl.value = hSort;
if (hDenEl) hDenEl.value = hDen;
applyDensity('wines', wDen);
applyDensity('handbags', hDen);
}
function applyDensity(which, px) {
const grid = document.getElementById(which + '-grid');
if (grid) grid.style.setProperty('--card-min', px + 'px');
const lbl = document.getElementById(which + '-density-val');
if (lbl) lbl.textContent = px + 'px';
}
function showTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tab).classList.add('active');
}
async function loadStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
if (data.success) {
document.getElementById('totalWines').textContent = data.wines.total.toLocaleString();
document.getElementById('totalHandbags').textContent = data.handbags.total.toLocaleString();
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadWines() {
try {
const sort = (document.getElementById('wines-sort') || {}).value || localStorage.getItem(LS.winesSort) || 'newest';
const res = await fetch('/api/new-wines?sort=' + encodeURIComponent(sort));
const data = await res.json();
winesData = data.wines || [];
document.getElementById('newWinesCount').textContent = winesData.length;
const container = document.getElementById('wines-grid');
if (winesData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No new wines yet</h3>
<p>${data.message || 'The crawler runs daily at 6:00 AM. Check back tomorrow!'}</p>
</div>
`;
return;
}
container.innerHTML = `
<div class="items-grid">
${winesData.map(wine => `
<div class="item-card">
<span class="new-badge">NEW</span>
<div class="item-title">${wine.name || 'Unknown Wine'}</div>
<div class="item-details">
${wine.vintage ? `<div class="item-detail"><span>Vintage:</span> <strong>${wine.vintage}</strong></div>` : ''}
${wine.rating ? `<div class="item-detail"><span>Rating:</span> <strong>⭐ ${wine.rating}/100</strong></div>` : ''}
${wine.availability ? `<div class="item-detail"><span>Stock:</span> <strong>${wine.availability}</strong></div>` : ''}
</div>
${wine.price ? `<div class="price">$${wine.price.toFixed(2)}</div>` : ''}
<span class="source-tag">${wine.source || 'Unknown'}</span>
</div>
`).join('')}
</div>
`;
// Re-apply density now that the grid was re-rendered
applyDensity('wines', parseInt(localStorage.getItem(LS.winesDensity) || '300', 10));
if (data.timestamp) {
document.getElementById('lastUpdated').textContent =
`Last crawl: ${new Date(data.timestamp).toLocaleString()}`;
}
} catch (error) {
console.error('Error loading wines:', error);
document.getElementById('wines-grid').innerHTML = `
<div class="empty-state">
<h3>Error loading wines</h3>
<p>Please try again later</p>
</div>
`;
}
}
async function loadHandbags() {
try {
const sort = (document.getElementById('handbags-sort') || {}).value || localStorage.getItem(LS.handbagsSort) || 'newest';
const res = await fetch('/api/new-handbags?sort=' + encodeURIComponent(sort));
const data = await res.json();
handbagsData = data.handbags || [];
document.getElementById('newHandbagsCount').textContent = handbagsData.length;
const container = document.getElementById('handbags-grid');
if (handbagsData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No new handbags yet</h3>
<p>The crawler runs daily at 6:00 AM. Check back tomorrow!</p>
</div>
`;
return;
}
container.innerHTML = `
<div class="items-grid">
${handbagsData.map(bag => `
<div class="item-card">
<span class="new-badge">NEW</span>
${bag.image_url ? `<img src="${bag.image_url}" class="item-image" alt="${bag.title}" onerror="this.style.display='none'">` : ''}
<div class="item-title">${bag.title || bag.brand + ' ' + (bag.model || 'Bag')}</div>
<div class="item-details">
${bag.brand ? `<div class="item-detail"><span>Brand:</span> <strong>${bag.brand}</strong></div>` : ''}
${bag.condition ? `<div class="item-detail"><span>Condition:</span> <strong>${bag.condition}</strong></div>` : ''}
${bag.listing_type ? `<div class="item-detail"><span>Type:</span> <strong>${bag.listing_type}</strong></div>` : ''}
</div>
<div class="price">
¥${bag.price_jpy.toLocaleString()}
${bag.price_usd ? `<span style="font-size:0.8em; color:#6b7280;">($${bag.price_usd.toFixed(2)})</span>` : ''}
</div>
<span class="source-tag">${bag.source || 'Unknown'}</span>
</div>
`).join('')}
</div>
`;
applyDensity('handbags', parseInt(localStorage.getItem(LS.handbagsDensity) || '300', 10));
} catch (error) {
console.error('Error loading handbags:', error);
document.getElementById('handbags-grid').innerHTML = `
<div class="empty-state">
<h3>Error loading handbags</h3>
<p>Please try again later</p>
</div>
`;
}
}
// ---- Wire up sort + density change handlers
function wireControls() {
const wSort = document.getElementById('wines-sort');
const wDen = document.getElementById('wines-density');
const hSort = document.getElementById('handbags-sort');
const hDen = document.getElementById('handbags-density');
if (wSort) wSort.addEventListener('change', () => {
localStorage.setItem(LS.winesSort, wSort.value);
loadWines();
});
if (wDen) wDen.addEventListener('input', () => {
const px = parseInt(wDen.value, 10);
localStorage.setItem(LS.winesDensity, String(px));
applyDensity('wines', px);
});
if (hSort) hSort.addEventListener('change', () => {
localStorage.setItem(LS.handbagsSort, hSort.value);
loadHandbags();
});
if (hDen) hDen.addEventListener('input', () => {
const px = parseInt(hDen.value, 10);
localStorage.setItem(LS.handbagsDensity, String(px));
applyDensity('handbags', px);
});
}
// ---- Boot: hydrate FIRST, then fetch, then wire handlers
hydrateControls();
wireControls();
loadStats();
loadWines();
loadHandbags();
// Refresh every 5 minutes
setInterval(() => {
loadStats();
loadWines();
loadHandbags();
}, 5 * 60 * 1000);
</script>
</body>
</html>