← back to Designer Wallcoverings
auto-save: 2026-06-24T07:25:27 (2 files) — vendor-scrapers/china-seas-refresh DW-Agents/vendor-command-center/server.js.backup-20260624-072033
fa89fca73d6fc39e9badbe2b9838edbae58bed6a · 2026-06-24 07:25:33 -0700 · Steve Abrams
Files touched
A DW-Agents/vendor-command-center/server.js.backup-20260624-072033
Diff
commit fa89fca73d6fc39e9badbe2b9838edbae58bed6a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 24 07:25:33 2026 -0700
auto-save: 2026-06-24T07:25:27 (2 files) — vendor-scrapers/china-seas-refresh DW-Agents/vendor-command-center/server.js.backup-20260624-072033
---
.../server.js.backup-20260624-072033 | 11907 +++++++++++++++++++
1 file changed, 11907 insertions(+)
diff --git a/DW-Agents/vendor-command-center/server.js.backup-20260624-072033 b/DW-Agents/vendor-command-center/server.js.backup-20260624-072033
new file mode 100644
index 00000000..696176c4
--- /dev/null
+++ b/DW-Agents/vendor-command-center/server.js.backup-20260624-072033
@@ -0,0 +1,11907 @@
+/**
+ * Victor - Vendor Command Center Dashboard
+ * Central vendor management hub for Designer Wallcoverings
+ * Port: 9660
+ */
+
+require('dotenv').config({ path: '/root/.env' });
+if (!process.env.SHOPIFY_ADMIN_TOKEN || !process.env.SHOPIFY_ORDERS_TOKEN) {
+ console.error('FATAL: SHOPIFY_ADMIN_TOKEN / SHOPIFY_ORDERS_TOKEN not set in /root/.env');
+ process.exit(1);
+}
+
+// Global crash handlers — surface errors to PM2 error log instead of silent exit
+process.on('uncaughtException', (err) => {
+ console.error('[Victor] UNCAUGHT EXCEPTION:', err.stack || err.message);
+ process.exit(1);
+});
+process.on('unhandledRejection', (reason) => {
+ console.error('[Victor] UNHANDLED REJECTION:', reason);
+});
+
+const express = require('express');
+const { Pool } = require('pg');
+const http = require('http');
+const { scoreWebsite } = require('./website-scorer');
+const stdWorker = require('./standardization-worker');
+const { processImage, uploadCroppedToShopify } = require('./logo-cropper');
+const { validateVendor, getDiscontinuationSummary } = require('./discontinuation-validator');
+
+const app = express();
+const PORT = 9660;
+
+// --- Database ---
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp',
+ max: 10,
+ idleTimeoutMillis: 30000,
+});
+
+// --- Live Activity Log (ring buffer, max 200) ---
+const activityLog = [];
+const MAX_ACTIVITY = 200;
+function logActivity(type, message, details) {
+ const entry = {
+ ts: new Date().toISOString(),
+ type: type, // 'sync', 'health', 'api', 'agent', 'error', 'info'
+ message: message,
+ details: details || null
+ };
+ activityLog.unshift(entry);
+ if (activityLog.length > MAX_ACTIVITY) activityLog.length = MAX_ACTIVITY;
+}
+logActivity('info', 'Victor starting up on port ' + PORT);
+
+// --- Agent Health Cache (30s TTL) ---
+const healthCache = {};
+const HEALTH_CACHE_TTL = 30000;
+
+function checkAgentHealthCached(port) {
+ const now = Date.now();
+ if (healthCache[port] && (now - healthCache[port].ts) < HEALTH_CACHE_TTL) {
+ return Promise.resolve(healthCache[port].online);
+ }
+ const prevState = healthCache[port] ? healthCache[port].online : null;
+ return new Promise((resolve) => {
+ const authHeader = 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64');
+ const req = http.get({
+ hostname: '127.0.0.1', port: port, path: '/health', timeout: 3000,
+ headers: { 'Authorization': authHeader }
+ }, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => {
+ const ok = res.statusCode >= 200 && res.statusCode < 400;
+ if (prevState !== null && prevState !== ok) {
+ logActivity('health', `Agent on port ${port} went ${ok ? 'ONLINE' : 'OFFLINE'}`);
+ }
+ healthCache[port] = { online: ok, ts: now };
+ resolve(ok);
+ });
+ });
+ req.on('error', () => {
+ if (prevState === true) logActivity('health', `Agent on port ${port} went OFFLINE (unreachable)`);
+ healthCache[port] = { online: false, ts: now }; resolve(false);
+ });
+ req.on('timeout', () => {
+ req.destroy();
+ if (prevState === true) logActivity('health', `Agent on port ${port} went OFFLINE (timeout)`);
+ healthCache[port] = { online: false, ts: now }; resolve(false);
+ });
+ });
+}
+
+// --- Middleware ---
+app.use(express.json());
+
+// Auth middleware — session-cookie first, basic-auth fallback.
+// 2026-06-12 refactor: the browser used to embed btoa('admin:<password>') in
+// every client fetch (the password shipped to the client). Now a successful
+// basic-auth mints an httpOnly `vcc_session` cookie derived from the admin
+// password via HMAC; same-origin fetches authenticate via that cookie, so NO
+// credential is ever embedded in browser JS. Rotating the password rotates the
+// HMAC secret, which invalidates every old cookie automatically.
+const crypto = require('crypto');
+function vccSessionToken() {
+ const secret = process.env.VCC_ADMIN_PASS || process.env.GEORGE_BASIC_AUTH_PASS || '';
+ if (!secret) return '';
+ return crypto.createHmac('sha256', secret).update('vcc-session-v1').digest('hex');
+}
+function vccParseCookies(req) {
+ const out = {};
+ (req.headers.cookie || '').split(';').forEach(function (p) {
+ const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = p.slice(i + 1).trim();
+ });
+ return out;
+}
+function requireAuth(req, res, next) {
+ const expected = vccSessionToken();
+ // 1. Valid session cookie → in (constant-time compare; length-guarded).
+ if (expected) {
+ const got = vccParseCookies(req).vcc_session || '';
+ if (got.length === expected.length &&
+ crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return next();
+ }
+ // 2. Basic-auth fallback (browser dialog / page load) — on success mint the cookie.
+ const auth = req.headers.authorization;
+ if (!auth || !auth.startsWith('Basic ')) {
+ res.setHeader('WWW-Authenticate', 'Basic realm="Vendor Command Center"');
+ return res.status(401).send('Authentication required');
+ }
+ const decoded = Buffer.from(auth.split(' ')[1], 'base64').toString();
+ const idx = decoded.indexOf(':');
+ const user = decoded.slice(0, idx), pass = decoded.slice(idx + 1);
+ const ok = (process.env.VCC_ADMIN_PASS && user === 'admin' && pass === process.env.VCC_ADMIN_PASS) ||
+ (process.env.GEORGE_BASIC_AUTH_PASS && user === 'admin' && pass === process.env.GEORGE_BASIC_AUTH_PASS);
+ if (ok) {
+ if (expected) res.setHeader('Set-Cookie', 'vcc_session=' + expected + '; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200');
+ return next();
+ }
+ res.setHeader('WWW-Authenticate', 'Basic realm="Vendor Command Center"');
+ return res.status(401).send('Invalid credentials');
+}
+
+// --- Health Check (no auth) ---
+app.get('/health', async (req, res) => {
+ try {
+ const result = await pool.query('SELECT COUNT(*) as cnt FROM vendor_registry');
+ res.json({
+ status: 'healthy',
+ service: 'vendor-command-center',
+ codename: 'Victor',
+ port: PORT,
+ vendors: parseInt(result.rows[0].cnt),
+ timestamp: new Date().toISOString(),
+ uptime: process.uptime()
+ });
+ } catch (err) {
+ res.status(500).json({ status: 'error', error: err.message });
+ }
+});
+
+// Apply auth to all other routes
+app.use(requireAuth);
+
+// --- API: Get all vendors (with agent health status + Nora mailer counts) ---
+app.get('/api/vendors', async (req, res) => {
+ try {
+ const result = await pool.query(
+ 'SELECT * FROM vendor_registry ORDER BY vendor_name ASC'
+ );
+ // Get Nora pipeline run counts per vendor
+ let noraMap = {};
+ try {
+ const noraRes = await pool.query(`
+ SELECT vendor_name,
+ COUNT(*) as total_mailers,
+ COUNT(*) FILTER (WHERE status IN ('completed','awaiting_approval','needs_confirmation')) as active_mailers,
+ MAX(created_at) as latest_mailer
+ FROM nora_pipeline_runs
+ WHERE vendor_name IS NOT NULL AND vendor_name != '' AND vendor_name != 'Unknown Vendor'
+ GROUP BY vendor_name
+ `);
+ for (const r of noraRes.rows) {
+ noraMap[r.vendor_name] = { total: parseInt(r.total_mailers), active: parseInt(r.active_mailers), latest: r.latest_mailer };
+ }
+ } catch (e) { /* nora table may not exist */ }
+
+ // Check health for all agents in parallel (cached 30s)
+ const vendors = await Promise.all(result.rows.map(async (v) => {
+ const nora = noraMap[v.vendor_name] || { total: 0, active: 0, latest: null };
+ if (!v.agent_port) return { ...v, agent_status: 'no-port', nora_mailers: nora.total, nora_active: nora.active, nora_latest: nora.latest };
+ const online = await checkAgentHealthCached(v.agent_port);
+ return { ...v, agent_status: online ? 'online' : 'offline', nora_mailers: nora.total, nora_active: nora.active, nora_latest: nora.latest };
+ }));
+ res.json({ success: true, data: vendors, count: vendors.length });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Get single vendor ---
+app.get('/api/vendors/:code', async (req, res) => {
+ try {
+ const result = await pool.query(
+ 'SELECT * FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (result.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ res.json({ success: true, data: result.rows[0] });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Update vendor fields ---
+app.put('/api/vendors/:code', async (req, res) => {
+ try {
+ const allowedFields = [
+ 'is_private_label', 'private_label_name', 'learnings', 'crawl_cron',
+ 'crawl_priority', 'is_active', 'has_credentials', 'login_url',
+ 'last_product_update', 'last_price_update', 'last_image_update',
+ 'last_spec_update', 'last_discontinued_check', 'last_blog_post',
+ 'last_instagram_post', 'next_scheduled_crawl',
+ 'display_prices', 'sample_price', 'skip_shopify',
+ 'vendor_discount_pct', 'pricing_unit', 'pricing_notes',
+ 'show_on_brands_page'
+ ];
+ const updates = [];
+ const values = [];
+ let idx = 1;
+ for (const field of allowedFields) {
+ if (req.body[field] !== undefined) {
+ updates.push(field + ' = $' + idx);
+ values.push(req.body[field]);
+ idx++;
+ }
+ }
+ if (updates.length === 0) {
+ return res.status(400).json({ success: false, error: 'No valid fields to update' });
+ }
+ updates.push('updated_at = NOW()');
+ values.push(req.params.code);
+ const sql = 'UPDATE vendor_registry SET ' + updates.join(', ') + ' WHERE vendor_code = $' + idx + ' RETURNING *';
+ const result = await pool.query(sql, values);
+ if (result.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ res.json({ success: true, data: result.rows[0] });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Proxy helper ---
+function proxyToAgent(agentPort, path, method, body) {
+ return new Promise((resolve) => {
+ const postData = body ? JSON.stringify(body) : '';
+ const options = {
+ hostname: '127.0.0.1',
+ port: agentPort,
+ path: path,
+ method: method,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(postData),
+ 'Authorization': 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64')
+ },
+ timeout: 10000
+ };
+ const proxyReq = http.request(options, (proxyRes) => {
+ let respBody = '';
+ proxyRes.on('data', (chunk) => { respBody += chunk; });
+ proxyRes.on('end', () => {
+ try { resolve({ success: true, status: proxyRes.statusCode, data: JSON.parse(respBody) }); }
+ catch (_e) { resolve({ success: true, status: proxyRes.statusCode, data: respBody }); }
+ });
+ });
+ proxyReq.on('error', (err) => {
+ resolve({ success: false, error: 'Agent not reachable: ' + err.message });
+ });
+ proxyReq.on('timeout', () => {
+ proxyReq.destroy();
+ resolve({ success: false, error: 'Agent request timed out' });
+ });
+ if (postData) proxyReq.write(postData);
+ proxyReq.end();
+ });
+}
+
+// --- API: Proxy scan to vendor agent ---
+app.post('/api/vendors/:code/scan', async (req, res) => {
+ try {
+ const vendor = await pool.query(
+ 'SELECT agent_port, agent_name FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (vendor.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ const { agent_port, agent_name } = vendor.rows[0];
+ if (!agent_port) {
+ return res.status(400).json({ success: false, error: 'No agent port configured' });
+ }
+ logActivity('agent', `Scan triggered for ${agent_name} (port ${agent_port})`);
+ const result = await proxyToAgent(agent_port, '/api/scan', 'POST', req.body || {});
+ res.json({ agent: agent_name, port: agent_port, ...result });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Trigger price update ---
+app.post('/api/vendors/:code/update-prices', async (req, res) => {
+ try {
+ const vendor = await pool.query(
+ 'SELECT agent_port, agent_name FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (vendor.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ const { agent_port, agent_name } = vendor.rows[0];
+ if (!agent_port) {
+ return res.status(400).json({ success: false, error: 'No agent port configured' });
+ }
+ const result = await proxyToAgent(agent_port, '/api/update-prices', 'POST', { action: 'update-prices' });
+ res.json({ agent: agent_name, port: agent_port, ...result });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Calendar data ---
+app.get('/api/calendar', async (req, res) => {
+ try {
+ const result = await pool.query(
+ 'SELECT vendor_name, vendor_code, agent_name, crawl_cron, crawl_priority, next_scheduled_crawl, last_product_update, is_active FROM vendor_registry ORDER BY crawl_priority DESC, vendor_name ASC'
+ );
+ res.json({ success: true, data: result.rows });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Update cron schedule ---
+app.put('/api/calendar/:code', async (req, res) => {
+ try {
+ const { crawl_cron, next_scheduled_crawl } = req.body;
+ const result = await pool.query(
+ 'UPDATE vendor_registry SET crawl_cron = COALESCE($1, crawl_cron), next_scheduled_crawl = COALESCE($2, next_scheduled_crawl), updated_at = NOW() WHERE vendor_code = $3 RETURNING *',
+ [crawl_cron || null, next_scheduled_crawl || null, req.params.code]
+ );
+ if (result.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ res.json({ success: true, data: result.rows[0] });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Global stats ---
+app.get('/api/stats', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) as total_vendors,
+ COUNT(*) FILTER (WHERE is_active) as active_vendors,
+ COUNT(*) FILTER (WHERE is_private_label) as private_label_count,
+ COUNT(*) FILTER (WHERE skip_shopify) as skip_shopify_count,
+ COUNT(*) FILTER (WHERE has_credentials) as with_credentials,
+ COUNT(*) FILTER (WHERE crawl_priority = 'HIGH') as high_priority,
+ COUNT(*) FILTER (WHERE crawl_priority = 'MEDIUM') as medium_priority,
+ COUNT(*) FILTER (WHERE crawl_priority = 'LOW') as low_priority,
+ COALESCE(SUM(total_products), 0) as total_products,
+ COALESCE(SUM(catalog_count), 0) as total_catalog,
+ COALESCE(SUM(shopify_count), 0) as total_shopify,
+ COALESCE(SUM(products_with_cost), 0) as total_with_cost,
+ COALESCE(SUM(products_with_images), 0) as total_with_images,
+ COALESCE(SUM(discontinued_count), 0) as total_discontinued,
+ COALESCE(SUM(catalog_not_on_shopify), 0) as total_not_on_shopify,
+ COALESCE(SUM(total_products) - SUM(products_with_images), 0) as missing_images,
+ COALESCE(SUM(catalog_with_width), 0) as total_with_width,
+ COALESCE(SUM(catalog_with_repeat), 0) as total_with_repeat,
+ COALESCE(SUM(catalog_with_about_vendor), 0) as total_with_about_vendor,
+ COALESCE(SUM(catalog_with_all_images), 0) as total_with_all_images,
+ COALESCE(SUM(catalog_with_spec_sheet), 0) as total_with_spec_sheet,
+ COALESCE(SUM(catalog_with_body_html), 0) as total_with_body_html,
+ COALESCE(SUM(shopify_draft), 0) as total_draft,
+ COALESCE(SUM(shopify_archived), 0) as total_archived,
+ COALESCE(SUM(shopify_draft), 0) as drafts_needing_review,
+ COALESCE(SUM(shopify_archived), 0) as already_archived,
+ MIN(next_scheduled_crawl) FILTER (WHERE next_scheduled_crawl > NOW()) as next_crawl,
+ ROUND(AVG(spec_completeness) FILTER (WHERE catalog_table IS NOT NULL), 1) as avg_spec_completeness,
+ COUNT(*) FILTER (WHERE spec_completeness < 50 AND catalog_table IS NOT NULL) as vendors_below_50_spec
+ FROM vendor_registry
+ `);
+ const stats = result.rows[0];
+ stats.cost_coverage_pct = stats.total_products > 0
+ ? ((stats.total_with_cost / stats.total_products) * 100).toFixed(1)
+ : '0.0';
+ stats.image_coverage_pct = stats.total_products > 0
+ ? ((stats.total_with_images / stats.total_products) * 100).toFixed(1)
+ : '0.0';
+ res.json({ success: true, data: stats });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: System status (vendor counts, catalog counts, Shopify counts, health) ---
+app.get('/api/status', async (req, res) => {
+ try {
+ const statsResult = await pool.query(`
+ SELECT
+ COUNT(*) as total_vendors,
+ COUNT(*) FILTER (WHERE is_active) as active_vendors,
+ COUNT(*) FILTER (WHERE is_private_label) as private_label_count,
+ COALESCE(SUM(catalog_count), 0) as total_catalog,
+ COALESCE(SUM(shopify_count), 0) as total_shopify,
+ COALESCE(SUM(products_with_cost), 0) as total_with_cost,
+ COALESCE(SUM(products_with_images), 0) as total_with_images,
+ COALESCE(SUM(discontinued_count), 0) as total_discontinued,
+ COALESCE(SUM(catalog_not_on_shopify), 0) as total_not_on_shopify,
+ COALESCE(SUM(catalog_with_width), 0) as total_with_width,
+ COALESCE(SUM(catalog_with_repeat), 0) as total_with_repeat,
+ ROUND(AVG(spec_completeness) FILTER (WHERE catalog_table IS NOT NULL), 1) as avg_spec_completeness
+ FROM vendor_registry
+ `);
+ const stats = statsResult.rows[0];
+
+ // Top 10 vendors by catalog count
+ const topVendors = await pool.query(`
+ SELECT vendor_name, vendor_code, catalog_count, shopify_count, agent_port
+ FROM vendor_registry
+ WHERE catalog_count > 0
+ ORDER BY catalog_count DESC
+ LIMIT 10
+ `);
+
+ res.json({
+ success: true,
+ status: 'operational',
+ service: 'vendor-command-center',
+ port: 9660,
+ uptime: process.uptime(),
+ timestamp: new Date().toISOString(),
+ vendors: {
+ total: parseInt(stats.total_vendors),
+ active: parseInt(stats.active_vendors),
+ private_label: parseInt(stats.private_label_count)
+ },
+ catalog: {
+ total_products: parseInt(stats.total_catalog),
+ on_shopify: parseInt(stats.total_shopify),
+ not_on_shopify: parseInt(stats.total_not_on_shopify),
+ with_cost: parseInt(stats.total_with_cost),
+ with_images: parseInt(stats.total_with_images),
+ with_width: parseInt(stats.total_with_width),
+ with_repeat: parseInt(stats.total_with_repeat),
+ discontinued: parseInt(stats.total_discontinued),
+ avg_spec_completeness: parseFloat(stats.avg_spec_completeness) || 0
+ },
+ top_vendors: topVendors.rows
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, status: 'error', error: err.message });
+ }
+});
+
+// --- API: Live catalog totals (real-time from pg_stat, no sync needed) ---
+app.get('/api/live-totals', async (req, res) => {
+ try {
+ // Get approximate live row counts from PostgreSQL stats (instant, no table scan)
+ // Exclude non-vendor catalogs (connie = competitive intel, vendor_catalog = generic)
+ const liveRes = await pool.query(`
+ SELECT relname, n_live_tup as approx_count
+ FROM pg_stat_user_tables
+ WHERE relname LIKE '%_catalog'
+ AND relname NOT IN ('connie_our_catalog', 'connie_competitor_catalog', 'vendor_catalog')
+ ORDER BY n_live_tup DESC
+ `);
+ const catalogTotal = liveRes.rows.reduce((sum, r) => sum + parseInt(r.approx_count || 0), 0);
+
+ // Also get shopify live count
+ const shopRes = await pool.query(`
+ SELECT n_live_tup as approx_count FROM pg_stat_user_tables WHERE relname = 'shopify_products'
+ `);
+ const shopifyTotal = shopRes.rows.length > 0 ? parseInt(shopRes.rows[0].approx_count || 0) : 0;
+
+ // Get vendor_registry totals (pre-computed, for specs/cost/images)
+ const regRes = await pool.query(`
+ SELECT
+ COALESCE(SUM(products_with_cost), 0) as with_cost,
+ COALESCE(SUM(products_with_images), 0) as with_images,
+ COALESCE(SUM(catalog_with_width), 0) as with_width,
+ COALESCE(SUM(catalog_with_repeat), 0) as with_repeat,
+ COALESCE(SUM(catalog_not_on_shopify), 0) as not_on_shopify,
+ COALESCE(SUM(shopify_draft), 0) as drafts_needing_review,
+ COALESCE(SUM(discontinued_count), 0) as discontinued_in_catalog
+ FROM vendor_registry
+ `);
+ const reg = regRes.rows[0];
+
+ res.json({
+ success: true,
+ catalog_total: catalogTotal,
+ shopify_total: shopifyTotal,
+ with_cost: parseInt(reg.with_cost),
+ with_images: parseInt(reg.with_images),
+ with_width: parseInt(reg.with_width),
+ with_repeat: parseInt(reg.with_repeat),
+ not_on_shopify: parseInt(reg.not_on_shopify),
+ to_discontinue: parseInt(reg.drafts_needing_review),
+ tables: liveRes.rows.map(r => ({ name: r.relname, count: parseInt(r.approx_count) })),
+ ts: new Date().toISOString()
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Spec completeness detail for a vendor ---
+app.get('/api/spec-completeness/:code', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ 'SELECT vendor_name, catalog_table, spec_completeness, spec_fields_present, spec_fields_total FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (rows.length === 0) return res.status(404).json({ success: false, error: 'Vendor not found' });
+
+ const v = rows[0];
+ const catTable = v.catalog_table;
+ if (!catTable) {
+ return res.json({ success: true, vendor: v.vendor_name, pct: 0, present: 0, total: REQUIRED_CATALOG_FIELDS.length, fields: {} });
+ }
+
+ // Get actual columns
+ const colRes = await pool.query(
+ `SELECT column_name FROM information_schema.columns WHERE table_name = $1`,
+ [catTable]
+ );
+ const actualCols = new Set(colRes.rows.map(r => r.column_name));
+
+ // Build per-field detail
+ const fields = {};
+ for (const field of REQUIRED_CATALOG_FIELDS) {
+ const aliases = FIELD_ALIASES[field] || [field];
+ const matchedCol = aliases.find(a => actualCols.has(a));
+ if (matchedCol) {
+ // Count non-null/non-empty values
+ try {
+ const countRes = await pool.query(
+ `SELECT COUNT(*) as filled FROM ${catTable} WHERE ${matchedCol} IS NOT NULL AND ${matchedCol}::text != ''`
+ );
+ const totalRes = await pool.query(`SELECT COUNT(*) as total FROM ${catTable}`);
+ fields[field] = {
+ status: 'present',
+ column: matchedCol,
+ filled: parseInt(countRes.rows[0].filled) || 0,
+ total: parseInt(totalRes.rows[0].total) || 0
+ };
+ } catch (_) {
+ fields[field] = { status: 'present', column: matchedCol, filled: 0, total: 0 };
+ }
+ } else {
+ fields[field] = { status: 'missing', column: null, filled: 0, total: 0 };
+ }
+ }
+
+ res.json({
+ success: true,
+ vendor: v.vendor_name,
+ pct: parseFloat(v.spec_completeness) || 0,
+ present: v.spec_fields_present || 0,
+ total: v.spec_fields_total || REQUIRED_CATALOG_FIELDS.length,
+ fields
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Spec completeness summary (all vendors) ---
+app.get('/api/spec-completeness', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT vendor_code, vendor_name, catalog_table, spec_completeness, spec_fields_present, spec_fields_total
+ FROM vendor_registry ORDER BY spec_completeness ASC`
+ );
+ const below50 = rows.filter(r => parseFloat(r.spec_completeness) < 50 && r.catalog_table);
+ res.json({
+ success: true,
+ data: rows,
+ below_50_count: below50.length,
+ below_50: below50.map(r => ({ code: r.vendor_code, name: r.vendor_name, pct: parseFloat(r.spec_completeness) })),
+ required_fields: REQUIRED_CATALOG_FIELDS
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Bulk action ---
+app.post('/api/bulk-action', async (req, res) => {
+ try {
+ const { action, vendorCodes } = req.body;
+ if (!action || !vendorCodes || !Array.isArray(vendorCodes) || vendorCodes.length === 0) {
+ return res.status(400).json({ success: false, error: 'action and vendorCodes[] required' });
+ }
+
+ const results = [];
+ for (const code of vendorCodes) {
+ const vendor = await pool.query(
+ 'SELECT agent_port, agent_name FROM vendor_registry WHERE vendor_code = $1',
+ [code]
+ );
+ if (vendor.rows.length === 0) {
+ results.push({ code, success: false, error: 'Not found' });
+ continue;
+ }
+ const { agent_port, agent_name } = vendor.rows[0];
+
+ let path = '/api/scan';
+ if (action === 'update-prices') path = '/api/update-prices';
+ else if (action === 'update-images') path = '/api/update-images';
+ else if (action === 'check-discontinued') path = '/api/check-discontinued';
+ else if (action === 'update-specs') path = '/api/update-specs';
+
+ const agentResult = await proxyToAgent(agent_port, path, 'POST', { action });
+ results.push({ code, agent: agent_name, ...agentResult });
+ }
+ res.json({ success: true, action, results });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Check agent health ---
+app.get('/api/vendors/:code/agent-health', async (req, res) => {
+ try {
+ const vendor = await pool.query(
+ 'SELECT agent_port, agent_name FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (vendor.rows.length === 0) {
+ return res.status(404).json({ success: false, error: 'Vendor not found' });
+ }
+ const { agent_port, agent_name } = vendor.rows[0];
+
+ const agentHealth = await new Promise((resolve) => {
+ const options = {
+ hostname: '127.0.0.1',
+ port: agent_port,
+ path: '/health',
+ method: 'GET',
+ timeout: 3000
+ };
+ const proxyReq = http.request(options, (proxyRes) => {
+ let body = '';
+ proxyRes.on('data', (chunk) => { body += chunk; });
+ proxyRes.on('end', () => {
+ try { resolve({ online: true, data: JSON.parse(body) }); }
+ catch (_e) { resolve({ online: true, data: body }); }
+ });
+ });
+ proxyReq.on('error', () => resolve({ online: false }));
+ proxyReq.on('timeout', () => { proxyReq.destroy(); resolve({ online: false }); });
+ proxyReq.end();
+ });
+
+ res.json({ success: true, agent: agent_name, port: agent_port, ...agentHealth });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Bulk scan (POST /api/bulk-scan) ---
+app.post('/api/bulk-scan', async (req, res) => {
+ try {
+ const { codes } = req.body;
+ if (!codes || !Array.isArray(codes) || codes.length === 0) {
+ return res.status(400).json({ success: false, error: 'codes[] required' });
+ }
+ logActivity('agent', `Bulk scan triggered for ${codes.length} vendors`);
+ const { rows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, agent_port FROM vendor_registry WHERE vendor_code = ANY($1)',
+ [codes]
+ );
+ const results = await Promise.allSettled(rows.map(v => {
+ return new Promise((resolve) => {
+ if (!v.agent_port) return resolve({ vendor: v.vendor_name, code: v.vendor_code, status: 'no-port' });
+ const postData = JSON.stringify({});
+ const options = {
+ hostname: '127.0.0.1', port: v.agent_port, path: '/api/scan',
+ method: 'POST', timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(postData),
+ 'Authorization': 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64')
+ }
+ };
+ const proxyReq = http.request(options, (proxyRes) => {
+ let data = '';
+ proxyRes.on('data', (c) => { data += c; });
+ proxyRes.on('end', () => resolve({ vendor: v.vendor_name, code: v.vendor_code, status: 'triggered' }));
+ });
+ proxyReq.on('error', () => resolve({ vendor: v.vendor_name, code: v.vendor_code, status: 'unreachable' }));
+ proxyReq.on('timeout', () => { proxyReq.destroy(); resolve({ vendor: v.vendor_name, code: v.vendor_code, status: 'timeout' }); });
+ proxyReq.write(postData);
+ proxyReq.end();
+ });
+ }));
+ res.json({
+ success: true,
+ results: results.map(r => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message })
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Get agent observations (proxy) ---
+app.get('/api/vendors/:code/observations', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ 'SELECT agent_port, vendor_name FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!rows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ const port = rows[0].agent_port;
+ if (!port) return res.json({ success: true, data: [], message: 'No agent port configured' });
+
+ const proxyReq = http.get('http://127.0.0.1:' + port + '/api/observations', {
+ timeout: 5000,
+ headers: { 'Authorization': 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64') }
+ }, (proxyRes) => {
+ let data = '';
+ proxyRes.on('data', (chunk) => { data += chunk; });
+ proxyRes.on('end', () => {
+ try { res.json({ success: true, data: JSON.parse(data) }); }
+ catch (_e) { res.json({ success: true, data: [], raw: data }); }
+ });
+ });
+ proxyReq.on('error', () => res.json({ success: true, data: [], message: 'Agent unreachable' }));
+ proxyReq.on('timeout', () => { proxyReq.destroy(); res.json({ success: true, data: [], message: 'Timeout' }); });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- SKU Prefix Generator (compliant, obfuscated) ---
+// Rules: DW + 2 random letters from allowed set. Never reference vendor name.
+// Forbidden: B, D, S, F, M, N, V (hard to hear on phone)
+// Allowed: A, C, E, G, H, I, J, K, L, O, P, Q, R, T, U, W, X, Y, Z
+async function generateCompliantPrefix(pool) {
+ const ALLOWED = 'ACEGHIJKLOPQRTUWXYZ';
+ // Get all existing prefixes from vendor_registry
+ const { rows: existing } = await pool.query(
+ "SELECT DISTINCT sku_prefix FROM vendor_registry WHERE sku_prefix IS NOT NULL AND sku_prefix <> ''"
+ );
+ const usedPrefixes = new Set(existing.map(r => r.sku_prefix.replace(/-$/, '').toUpperCase()));
+
+ // Also check shopify_products for any DW prefixes in the wild
+ try {
+ const { rows: shopPrefixes } = await pool.query(
+ "SELECT DISTINCT SUBSTRING(dw_sku FROM 1 FOR 4) AS pfx FROM shopify_products WHERE dw_sku IS NOT NULL AND dw_sku <> '' AND dw_sku LIKE 'DW%'"
+ );
+ shopPrefixes.forEach(r => usedPrefixes.add(r.pfx.toUpperCase()));
+ } catch (_e) { /* shopify_products may not exist */ }
+
+ // Generate random 2-letter combos until we find one not in use
+ const maxAttempts = 500;
+ for (let i = 0; i < maxAttempts; i++) {
+ const c1 = ALLOWED[Math.floor(Math.random() * ALLOWED.length)];
+ const c2 = ALLOWED[Math.floor(Math.random() * ALLOWED.length)];
+ const candidate = 'DW' + c1 + c2;
+ if (!usedPrefixes.has(candidate)) {
+ return candidate + '-';
+ }
+ }
+ throw new Error('Could not generate unique SKU prefix after ' + maxAttempts + ' attempts');
+}
+
+// --- API: Find next available sku_range_start (5000 blocks) ---
+async function getNextSkuRangeStart(pool) {
+ const { rows } = await pool.query(
+ "SELECT COALESCE(MAX(sku_range_start), 195000) AS max_start FROM vendor_registry WHERE sku_range_start IS NOT NULL"
+ );
+ const maxStart = parseInt(rows[0].max_start) || 195000;
+ // Round up to next 5000 block
+ return Math.ceil((maxStart + 1) / 5000) * 5000;
+}
+
+// --- API: Assign DW SKUs to vendor catalog products ---
+app.post('/api/vendors/:code/assign-skus', async (req, res) => {
+ try {
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, catalog_table, sku_prefix, sku_range_start, private_label, skip_catalog_scrape FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+
+ const v = vendorRows[0];
+ if (!v.catalog_table) {
+ return res.json({ success: false, error: 'Vendor missing catalog_table' });
+ }
+
+ // SAFETY: Block SKU assignment for skip_catalog_scrape vendors (e.g., Cowtan & Tout)
+ // Private labels WITH a documented sku_prefix (rule 20/21: DWJS Jeffrey Stevens,
+ // DWPR Phillipe Romano, DWWC Architectural) ARE allowed — they must get their own DW SKUs.
+ if (v.skip_catalog_scrape) {
+ return res.status(400).json({
+ success: false,
+ error: `${v.vendor_name} is flagged skip_catalog_scrape. SKU assignment blocked.`
+ });
+ }
+ if (v.private_label && !(v.sku_prefix && v.sku_prefix.trim())) {
+ return res.status(400).json({
+ success: false,
+ error: `${v.vendor_name} is a private label brand without a documented sku_prefix. Set sku_prefix in vendor_registry first (see CLAUDE.md rule 21 for the Private Label Vendor Map).`
+ });
+ }
+
+ const catTable = v.catalog_table.replace(/[^a-z0-9_]/gi, '');
+ let prefix = (v.sku_prefix || '').trim();
+ let rangeStart = parseInt(v.sku_range_start) || 0;
+
+ // Auto-generate compliant prefix if vendor has none
+ if (!prefix) {
+ prefix = await generateCompliantPrefix(pool);
+ rangeStart = await getNextSkuRangeStart(pool);
+ await pool.query(
+ 'UPDATE vendor_registry SET sku_prefix = $1, sku_range_start = $2 WHERE vendor_code = $3',
+ [prefix, rangeStart, req.params.code]
+ );
+ console.log('[Victor] Auto-generated prefix ' + prefix + ' (range ' + rangeStart + ') for ' + v.vendor_name);
+ }
+
+ // Check if dw_sku column exists
+ const colCheck = await pool.query(
+ "SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'dw_sku'",
+ [catTable]
+ );
+ if (!colCheck.rows.length) {
+ await pool.query(`ALTER TABLE ${catTable} ADD COLUMN IF NOT EXISTS dw_sku text DEFAULT ''`);
+ }
+
+ // Find current max DW SKU number for this prefix across ALL tables
+ // Check the catalog table itself
+ const maxInCatalog = await pool.query(
+ `SELECT MAX(CAST(NULLIF(regexp_replace(dw_sku, $1, ''), '') AS INTEGER)) as max_num FROM ${catTable} WHERE dw_sku LIKE $2`,
+ ['^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), prefix + '%']
+ );
+ // Also check shopify_products for any existing SKUs with this prefix
+ let maxInShopify = { rows: [{ max_num: null }] };
+ try {
+ maxInShopify = await pool.query(
+ "SELECT MAX(CAST(NULLIF(regexp_replace(dw_sku, $1, ''), '') AS INTEGER)) as max_num FROM shopify_products WHERE dw_sku LIKE $2",
+ ['^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), prefix + '%']
+ );
+ } catch (e) { /* shopify_products may not exist */ }
+
+ const maxCat = parseInt(maxInCatalog.rows[0]?.max_num) || 0;
+ const maxShop = parseInt(maxInShopify.rows[0]?.max_num) || 0;
+ let nextNum = Math.max(maxCat, maxShop, rangeStart);
+ if (nextNum < rangeStart) nextNum = rangeStart;
+
+ // Get products missing dw_sku, ordered by mfr_sku for consistency
+ const missing = await pool.query(
+ `SELECT id, mfr_sku FROM ${catTable} WHERE dw_sku IS NULL OR dw_sku = '' ORDER BY mfr_sku ASC`
+ );
+
+ if (missing.rows.length === 0) {
+ return res.json({ success: true, assigned: 0, message: 'All products already have DW SKUs' });
+ }
+
+ // Assign sequential SKUs — but if mfr_sku is already a DW SKU, reuse it
+ let assigned = 0;
+ let reused = 0;
+ for (const row of missing.rows) {
+ let dwSku;
+ if (row.mfr_sku && /^DW[A-Z]{1,3}-\d+/.test(row.mfr_sku)) {
+ // mfr_sku is already a DW SKU — reuse it, don't mint a new one
+ dwSku = row.mfr_sku;
+ reused++;
+ } else {
+ nextNum++;
+ dwSku = prefix + nextNum;
+ }
+ await pool.query(`UPDATE ${catTable} SET dw_sku = $1 WHERE id = $2`, [dwSku, row.id]);
+ assigned++;
+ }
+
+ console.log(`[Victor] Assigned ${assigned} DW SKUs to ${v.vendor_name} (${prefix}${rangeStart + 1} - ${prefix}${nextNum}, ${reused} reused existing DW SKUs)`);
+
+ res.json({
+ success: true,
+ vendor: v.vendor_name,
+ reused,
+ prefix,
+ assigned,
+ range: prefix + (nextNum - assigned + 1) + ' to ' + prefix + nextNum,
+ message: assigned + ' DW SKUs assigned to ' + v.vendor_name
+ });
+ } catch (err) {
+ console.error('[Victor] SKU assignment error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Create/Find Shopify inquiry page for vendor with 0 products ---
+const SHOPIFY_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ORDERS_TOKEN;
+
+// ─── Launch Test: Full enrichment pipeline — 1 newest SKU per vendor to Shopify ──
+app.post('/api/launch-test', async (req, res) => {
+ try {
+ const { vendor_codes } = req.body;
+ if (!vendor_codes || !vendor_codes.length) return res.status(400).json({ error: 'No vendor_codes provided' });
+
+ const results = [];
+ const PRODUCT_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+ const GEMINI_KEY = process.env.GEMINI_API_KEY || '';
+ const GRAPHQL_URL = `https://${SHOPIFY_DOMAIN}/admin/api/2024-10/graphql.json`;
+
+ // Helper: POST JSON to Silas via http module
+ function silasPost(path, body) {
+ return new Promise((resolve, reject) => {
+ const postData = JSON.stringify(body);
+ const r = http.request({
+ hostname: '127.0.0.1', port: 9674, path, method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }
+ }, (resp) => {
+ let d = '';
+ resp.on('data', c => d += c);
+ resp.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
+ });
+ r.on('error', reject);
+ r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); });
+ r.write(postData);
+ r.end();
+ });
+ }
+
+ // Helper: Gemini AI description
+ async function generateDescription(imageUrl, title) {
+ try {
+ const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${GEMINI_KEY}`;
+ const geminiBody = {
+ contents: [{
+ parts: [
+ { text: `You are a luxury wallcovering copywriter. Write a 2-3 sentence commercial product description for this wallcovering titled "${title}". The product image is at: ${imageUrl}\nFocus on texture, design, and the atmosphere it creates in a room. Never use the word "wallpaper" — always say "wallcovering". Be elegant and concise.` }
+ ]
+ }]
+ };
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15000);
+ const resp = await fetch(geminiUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(geminiBody),
+ signal: controller.signal
+ });
+ clearTimeout(timeout);
+ const data = await resp.json();
+ if (data.candidates && data.candidates[0] && data.candidates[0].content) {
+ return data.candidates[0].content.parts[0].text.trim();
+ }
+ return null;
+ } catch (e) {
+ console.error('[VCC] Gemini description error:', e.message);
+ return null;
+ }
+ }
+
+ // Helper: GraphQL metafieldsSet
+ async function setMetafields(shopifyProductId, metafields) {
+ const mutation = `mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
+ metafieldsSet(metafields: $metafields) {
+ metafields { namespace key }
+ userErrors { field message }
+ }
+ }`;
+ const ownerId = `gid://shopify/Product/${shopifyProductId}`;
+ const variables = {
+ metafields: metafields.map(m => ({
+ ownerId,
+ namespace: m.namespace,
+ key: m.key,
+ value: m.value,
+ type: 'single_line_text_field'
+ }))
+ };
+ const resp = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: mutation, variables })
+ });
+ return await resp.json();
+ }
+
+ for (const code of vendor_codes) {
+ const steps = {};
+
+ // ── Lookup vendor ──
+ const { rows: vRows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, catalog_table, sku_prefix FROM vendor_registry WHERE vendor_code = $1', [code]
+ );
+ if (!vRows.length) { results.push({ vendor: code, status: 'error', reason: 'Vendor not found' }); continue; }
+ const v = vRows[0];
+ if (!v.catalog_table) { results.push({ vendor: v.vendor_name, status: 'error', reason: 'No catalog table' }); continue; }
+
+ const catTable = v.catalog_table.replace(/[^a-z0-9_]/gi, '');
+
+ // ── Check available columns ──
+ const { rows: cols } = await pool.query(
+ "SELECT column_name FROM information_schema.columns WHERE table_name=$1 AND column_name IN ('dw_sku','mfr_sku','pattern_name','color_name','width','image_url','material','collection','pattern_repeat','repeat_v','repeat_h','fire_rating','finish','application','design_style','design','match_type','roll_length','coverage','features','brand','rooms','color_primary','about_vendor','product_url')", [catTable]
+ );
+ const colSet = new Set(cols.map(c => c.column_name));
+ if (!colSet.has('dw_sku') || !colSet.has('mfr_sku')) {
+ results.push({ vendor: v.vendor_name, status: 'error', reason: 'Missing dw_sku or mfr_sku column' }); continue;
+ }
+
+ // ── STEP 1: Get newest eligible product ──
+ const widthFilter = colSet.has('width') ? "AND width IS NOT NULL AND width <> ''" : '';
+ const imageFilter = colSet.has('image_url') ? "AND image_url IS NOT NULL AND image_url <> ''" : '';
+ const colorFilter = colSet.has('color_name') ? "AND color_name IS NOT NULL AND color_name <> ''" : '';
+ const nameCol = colSet.has('pattern_name') ? 'pattern_name' : "'Product'";
+ const colorCol = colSet.has('color_name') ? 'color_name' : "''";
+
+ const selectCols = [
+ 'mfr_sku', `${nameCol} as pattern_name`, `${colorCol} as color_name`, 'dw_sku',
+ colSet.has('width') ? 'width' : "'' as width",
+ colSet.has('image_url') ? 'image_url' : "'' as image_url",
+ colSet.has('material') ? 'material' : "'Non-woven' as material",
+ colSet.has('collection') ? 'collection' : `'${v.vendor_name.replace(/'/g, "''")}' as collection`,
+ colSet.has('pattern_repeat') ? 'pattern_repeat' : (colSet.has('repeat_v') ? 'repeat_v as pattern_repeat' : "NULL as pattern_repeat"),
+ colSet.has('repeat_h') ? 'repeat_h' : "NULL as repeat_h",
+ colSet.has('fire_rating') ? 'fire_rating' : "NULL as fire_rating",
+ colSet.has('finish') ? 'finish' : "NULL as finish",
+ colSet.has('application') ? 'application' : "NULL as application",
+ colSet.has('design_style') ? 'design_style' : (colSet.has('design') ? 'design as design_style' : "NULL as design_style"),
+ colSet.has('match_type') ? 'match_type' : "NULL as match_type",
+ colSet.has('roll_length') ? 'roll_length' : "NULL as roll_length",
+ colSet.has('coverage') ? 'coverage' : "NULL as coverage",
+ colSet.has('features') ? 'features' : "NULL as features",
+ colSet.has('brand') ? 'brand' : "NULL as brand",
+ colSet.has('rooms') ? 'rooms' : "NULL as rooms",
+ colSet.has('about_vendor') ? 'about_vendor' : "NULL as about_vendor"
+ ];
+
+ const { rows: products } = await pool.query(
+ `SELECT ${selectCols.join(', ')}
+ FROM "${catTable}"
+ WHERE dw_sku IS NOT NULL AND shopify_product_id IS NULL
+ ${widthFilter} ${imageFilter} ${colorFilter}
+ ORDER BY id DESC LIMIT 1`
+ );
+
+ if (!products.length) {
+ results.push({ vendor: v.vendor_name, status: 'skipped', reason: 'No eligible products (all on Shopify or missing data)' }); continue;
+ }
+
+ const p = products[0];
+ // Private label: use private_label_name, hide real vendor
+ const displayVendor = v.is_private_label && v.private_label_name ? v.private_label_name : v.vendor_name;
+ const title = [p.pattern_name, p.color_name].filter(Boolean).join(' - ') + ' Wallcovering | ' + displayVendor;
+
+ // ── STEP 2: Silas validate-push ──
+ try {
+ const silasResult = await silasPost('/api/validate-push', {
+ dw_sku: p.dw_sku, vendor_code: code, vendor_name: v.vendor_name,
+ mfr_sku: p.mfr_sku, title, image_url: p.image_url, width: p.width
+ });
+ if (!silasResult.approved) {
+ steps.silas_push = 'rejected: ' + (silasResult.reason || 'unknown');
+ results.push({ vendor: v.vendor_name, sku: p.dw_sku, mfr: p.mfr_sku, status: 'blocked_silas', reason: silasResult.reason || 'Silas rejected', steps }); continue;
+ }
+ steps.silas_push = 'approved';
+ } catch (e) {
+ steps.silas_push = 'error: ' + e.message;
+ results.push({ vendor: v.vendor_name, sku: p.dw_sku, status: 'error', reason: 'Silas unavailable: ' + e.message, steps }); continue;
+ }
+
+ // ── STEP 3: Generate AI description via Gemini ──
+ let aiDescription = null;
+ try {
+ aiDescription = await generateDescription(p.image_url, title);
+ steps.description = aiDescription ? 'generated' : 'failed (proceeding without)';
+ } catch (e) {
+ steps.description = 'error: ' + e.message + ' (proceeding without)';
+ }
+
+ // Build body_html — AI description only, no specs table
+ const bodyHtml = aiDescription
+ ? `<p>${aiDescription.replace(/</g, '<').replace(/>/g, '>')}</p>`
+ : `<p>${title}</p>`;
+
+ // ── STEP 4: Create product on Shopify as DRAFT ──
+ // Private label: hide real vendor name, collection, and MFR SKU from public fields
+ const isPL = !!(v.is_private_label && v.private_label_name);
+ const tags = [displayVendor, p.collection, p.pattern_name, 'Luxury', 'Wallcovering'].filter(Boolean);
+ if (p.material) tags.push(p.material);
+ if (p.fire_rating) tags.push('Fire Rated');
+ if (p.design_style) tags.push(p.design_style);
+ // For private label, also add MFR SKU as tag (searchable but not customer-visible in title)
+ if (!isPL && p.mfr_sku) tags.push(p.mfr_sku);
+
+ const shopifyPayload = {
+ product: {
+ title,
+ vendor: displayVendor,
+ product_type: 'Wallcovering',
+ status: 'draft',
+ body_html: bodyHtml,
+ tags: [...new Set(tags)].join(', '),
+ images: (p.image_url && !p._croppedImage) ? [{ src: p.image_url, alt: title }] : [],
+ variants: [{
+ title: 'Sample', option1: 'Sample', price: '4.25',
+ sku: p.dw_sku + '-Sample', barcode: isPL ? '' : p.mfr_sku,
+ inventory_management: null
+ }]
+ }
+ };
+
+ let shopifyProductId = null;
+ try {
+ const shopResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify(shopifyPayload)
+ });
+ const shopData = await shopResp.json();
+ if (shopData.product) {
+ shopifyProductId = shopData.product.id;
+ steps.shopify_create = 'draft created';
+ } else {
+ steps.shopify_create = 'failed: ' + JSON.stringify(shopData.errors || shopData).substring(0, 200);
+ results.push({ vendor: v.vendor_name, sku: p.dw_sku, mfr: p.mfr_sku, status: 'error', reason: steps.shopify_create, steps }); continue;
+ }
+ } catch (e) {
+ steps.shopify_create = 'error: ' + e.message;
+ results.push({ vendor: v.vendor_name, sku: p.dw_sku, mfr: p.mfr_sku, status: 'error', reason: e.message, steps }); continue;
+ }
+
+ // Rate limit after Shopify REST call
+ await new Promise(r => setTimeout(r, 600));
+
+ // ── STEP 4b: Logo crop — detect and remove vendor logos from product image ──
+ if (p.image_url && shopifyProductId) {
+ try {
+ const cropResult = await processImage(p.image_url);
+ if (cropResult.cropped && cropResult.buffer) {
+ // Delete the original image, upload cropped version
+ const existingImages = shopData?.product?.images || [];
+ if (existingImages.length > 0) {
+ await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products/${shopifyProductId}/images/${existingImages[0].id}.json`, {
+ method: 'DELETE',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN }
+ });
+ await new Promise(r => setTimeout(r, 500));
+ }
+ const imgResp = await uploadCroppedToShopify(shopifyProductId, cropResult.buffer, title, SHOPIFY_DOMAIN, PRODUCT_TOKEN);
+ steps.logo_crop = cropResult.details;
+ if (imgResp.image) steps.logo_crop += ` → uploaded (${imgResp.image.id})`;
+ await new Promise(r => setTimeout(r, 500));
+ } else {
+ steps.logo_crop = cropResult.details;
+ }
+ } catch (cropErr) {
+ steps.logo_crop = 'error: ' + cropErr.message;
+ console.error('[Victor] Logo crop error:', cropErr.message);
+ }
+ }
+
+ // ── STEP 5: Push ALL metafields via GraphQL ──
+ const isTexture = (p.design_style || '').toLowerCase().includes('texture') && !(p.design_style || '').toLowerCase().includes('floral');
+ const metafields = [
+ // Core specs
+ { namespace: 'global', key: 'width', value: p.width },
+ // Private label: hide MFR SKU from customer-visible metafield, store real vendor in private_label namespace
+ { namespace: 'global', key: 'manufacturer_sku', value: isPL ? '' : p.mfr_sku },
+ { namespace: 'global', key: 'Brand', value: displayVendor },
+ { namespace: 'global', key: 'Collection', value: p.collection },
+ // Private label: store real vendor info in hidden namespace
+ isPL ? { namespace: 'private_label', key: 'real_vendor_name', value: v.vendor_name } : null,
+ isPL ? { namespace: 'private_label', key: 'real_mfr_sku', value: p.mfr_sku } : null,
+ { namespace: 'global', key: 'Contents', value: p.material },
+ { namespace: 'global', key: 'application', value: p.application || 'Paste the wall' },
+ // Repeat (patterns only, not textures)
+ !isTexture ? { namespace: 'global', key: 'repeat', value: p.pattern_repeat } : null,
+ !isTexture ? { namespace: 'global', key: 'Vert-Rpt', value: p.pattern_repeat } : null,
+ p.repeat_h ? { namespace: 'global', key: 'Horz-Rpt', value: p.repeat_h } : null,
+ // Match type
+ { namespace: 'global', key: 'MATCH', value: p.match_type },
+ // Fire rating
+ { namespace: 'global', key: 'fire_rating', value: p.fire_rating },
+ // Finish
+ { namespace: 'global', key: 'Finish', value: p.finish },
+ // Roll length
+ { namespace: 'global', key: 'length', value: p.roll_length },
+ // Design style
+ { namespace: 'global', key: 'design', value: p.design_style },
+ { namespace: 'global', key: 'Style', value: p.design_style ? 'Luxury' : null },
+ // Coverage
+ { namespace: 'global', key: 'coverage', value: p.coverage },
+ // Features
+ { namespace: 'global', key: 'features', value: p.features },
+ // Color
+ { namespace: 'global', key: 'Color-Way', value: p.color_name },
+ // Unit of measure
+ { namespace: 'global', key: 'unit_of_measure', value: 'Priced Per Single Roll' },
+ // Country
+ { namespace: 'global', key: 'Country', value: p.about_vendor ? null : null },
+ ].filter(m => m && m.value && String(m.value).trim() && String(m.value).toLowerCase() !== 'null');
+
+ try {
+ const gqlResult = await setMetafields(shopifyProductId, metafields);
+ const userErrors = gqlResult?.data?.metafieldsSet?.userErrors || [];
+ if (userErrors.length > 0) {
+ steps.metafields = 'partial: ' + userErrors.map(e => e.message).join('; ');
+ } else {
+ steps.metafields = metafields.length + ' set';
+ }
+ } catch (e) {
+ steps.metafields = 'error: ' + e.message;
+ }
+
+ // Rate limit after GraphQL call
+ await new Promise(r => setTimeout(r, 600));
+
+ // ── STEP 5.5: Interior Design Tagger (Gemini AI) ──
+ // Analyze image for colors, styles, patterns → tags + hex → metafields + Shopify tags
+ steps.id_tagger = 'skipped';
+ if (p.image_url) {
+ try {
+ const idPrompt = `Analyze this wallcovering image. Return ONLY valid JSON:\n{"dominantColor":"single color","hexCode":"#XXXXXX","allColors":["every visible color"],"styles":["1-2 from: Traditional, Contemporary, Art Deco, Mid-Century, Victorian, Baroque, Luxury, Bohemian, Coastal, Tropical, Minimalist"],"patterns":["1-2 from: Damask, Floral, Geometric, Botanical, Textured, Abstract, Stripe, Medallion, Trellis, Toile, Chinoiserie, Scenic"]}`;
+ const geminiIdResp = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${GEMINI_KEY}`,
+ { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [
+ { text: `Image URL: ${p.image_url}\n\nProduct: ${title}\n\n${idPrompt}` }
+ ]}],
+ generationConfig: { temperature: 0.1, maxOutputTokens: 400, thinkingConfig: { thinkingBudget: 0 } }
+ }),
+ signal: AbortSignal.timeout(15000)
+ }
+ );
+ const geminiIdData = await geminiIdResp.json();
+ const idText = geminiIdData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ const jsonMatch = idText.match(/\{[\s\S]*\}/);
+
+ if (jsonMatch) {
+ const aiAnalysis = JSON.parse(jsonMatch[0]);
+
+ // Build tag set
+ const tagSet = new Set();
+ tagSet.add(v.vendor_name);
+ tagSet.add('Wallcovering');
+ tagSet.add('Commercial');
+ tagSet.add('Architectural');
+ if (p.pattern_name) tagSet.add(p.pattern_name);
+ if (p.color_name) tagSet.add(p.color_name);
+ if (p.collection) tagSet.add(p.collection);
+ if (p.material) tagSet.add(p.material);
+
+ // ALL colors (no "Background Color" prefix)
+ (aiAnalysis.allColors || []).forEach(c => { if (c) tagSet.add(c.trim()); });
+ if (aiAnalysis.dominantColor) tagSet.add(aiAnalysis.dominantColor.trim());
+ (aiAnalysis.styles || []).forEach(s => { if (s) tagSet.add(s.trim()); });
+ (aiAnalysis.patterns || []).forEach(p2 => { if (p2) tagSet.add(p2.trim()); });
+ tagSet.delete('');
+
+ const tagsStr = Array.from(tagSet).join(', ');
+
+ // Update product tags on Shopify
+ await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products/${shopifyProductId}.json`, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: shopifyProductId, tags: tagsStr } })
+ });
+ await new Promise(r => setTimeout(r, 600));
+
+ // Push hex + AI data to metafields
+ const idMetafields = [];
+ const gid = `gid://shopify/Product/${shopifyProductId}`;
+ if (aiAnalysis.hexCode) {
+ idMetafields.push({ ownerId: gid, namespace: 'global', key: 'color_hex', value: aiAnalysis.hexCode, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'color_hex', value: aiAnalysis.hexCode, type: 'single_line_text_field' });
+ }
+ if (aiAnalysis.allColors) {
+ idMetafields.push({ ownerId: gid, namespace: 'global', key: 'ai_color_tags', value: aiAnalysis.allColors.join(', '), type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'ai_color_tags', value: aiAnalysis.allColors.join(', '), type: 'single_line_text_field' });
+ }
+ if (aiAnalysis.dominantColor) {
+ idMetafields.push({ ownerId: gid, namespace: 'global', key: 'background_color', value: aiAnalysis.dominantColor, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'background_color', value: aiAnalysis.dominantColor, type: 'single_line_text_field' });
+ }
+ // Push custom.* text fields too
+ if (aiAnalysis.dominantColor) {
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'background_color', value: aiAnalysis.dominantColor, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'color', value: p.color_name || aiAnalysis.dominantColor, type: 'single_line_text_field' });
+ }
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'brand', value: v.vendor_name, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'manufacturer_sku', value: p.mfr_sku, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'collection_name', value: p.collection || v.vendor_name, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'custom', key: 'material', value: p.material || 'Non-woven', type: 'multi_line_text_field' });
+ // dwc.* fields
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'pattern_name', value: p.pattern_name || '', type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'color', value: p.color_name || aiAnalysis.dominantColor, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'width', value: p.width || '', type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'manufacturer_sku', value: p.mfr_sku, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'real_vendor', value: v.vendor_name, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'brand', value: v.vendor_name, type: 'single_line_text_field' });
+ idMetafields.push({ ownerId: gid, namespace: 'dwc', key: 'dw_sku', value: p.dw_sku, type: 'single_line_text_field' });
+
+ const validMf = idMetafields.filter(m => m.value && m.value.trim());
+ if (validMf.length > 0) {
+ await setMetafields(shopifyProductId, validMf);
+ }
+ await new Promise(r => setTimeout(r, 600));
+
+ // Publish to all 14 channels
+ const PUBS = [
+ '22208643184','22497296496','29646651457','29739483201','29776969793',
+ '37538791489','37904089153','43657658419','44234276915','44317474867',
+ '44317507635','71898464307','115856375859','140027723827'
+ ].map(id => ({ publicationId: `gid://shopify/Publication/${id}` }));
+
+ await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/graphql.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: 'mutation publishablePublish($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { message } } }',
+ variables: { id: `gid://shopify/Product/${shopifyProductId}`, input: PUBS }
+ })
+ });
+
+ steps.id_tagger = `${tagSet.size} tags, hex=${aiAnalysis.hexCode || '?'}, ${validMf.length} mf, 14ch`;
+ }
+ } catch (e) {
+ steps.id_tagger = 'error: ' + e.message;
+ }
+ }
+
+ // ── STEP 6: Silas validate-activation ──
+ let silasActivationOk = false;
+ try {
+ const activationResult = await silasPost('/api/validate-activation', {
+ shopify_product_id: shopifyProductId
+ });
+ silasActivationOk = !!activationResult.approved;
+ steps.silas_activation = silasActivationOk ? 'approved' : ('rejected: ' + (activationResult.reason || 'unknown'));
+ } catch (e) {
+ steps.silas_activation = 'error: ' + e.message;
+ }
+
+ // ── STEP 7: Activate product (only if Silas approves) ──
+ if (silasActivationOk) {
+ try {
+ const activateResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products/${shopifyProductId}.json`, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: shopifyProductId, status: 'active' } })
+ });
+ const activateData = await activateResp.json();
+ steps.activated = !!(activateData.product && activateData.product.status === 'active');
+ } catch (e) {
+ steps.activated = false;
+ steps.activate_error = e.message;
+ }
+ // Rate limit after activation PUT
+ await new Promise(r => setTimeout(r, 600));
+ } else {
+ steps.activated = false;
+ }
+
+ // Room setting — skipped for Phase 2
+ steps.room_setting = 'skipped (Phase 2)';
+
+ // ── STEP 8: Update catalog with shopify_product_id ──
+ try {
+ await pool.query(`UPDATE "${catTable}" SET shopify_product_id = $1 WHERE dw_sku = $2`, [String(shopifyProductId), p.dw_sku]);
+ } catch (e) {
+ console.error('[VCC] Catalog update error for', p.dw_sku, e.message);
+ }
+
+ results.push({
+ vendor: v.vendor_name,
+ sku: p.dw_sku,
+ mfr: p.mfr_sku,
+ shopify_id: shopifyProductId,
+ title,
+ status: steps.activated ? 'launched' : 'draft',
+ steps
+ });
+ }
+
+ res.json({
+ success: true,
+ launched: results.filter(r => r.status === 'launched').length,
+ total: results.length,
+ results
+ });
+ } catch (err) {
+ console.error('[VCC] Launch test error:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/vendors/:code/inquiry-page', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, website_url, website_summary FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!rows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ const v = rows[0];
+ const handle = 'vendor-' + v.vendor_code.replace(/_/g, '-');
+
+ // Check if page already exists
+ const checkUrl = 'https://' + SHOPIFY_DOMAIN + '/admin/api/2024-01/pages.json?handle=' + handle;
+ const checkRes = await fetch(checkUrl, {
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' }
+ });
+ const checkData = await checkRes.json();
+ if (checkData.pages && checkData.pages.length > 0) {
+ return res.json({
+ success: true,
+ existing: true,
+ page_id: checkData.pages[0].id,
+ handle: checkData.pages[0].handle,
+ url: 'https://' + SHOPIFY_DOMAIN + '/pages/' + checkData.pages[0].handle,
+ message: 'Inquiry page already exists for ' + v.vendor_name
+ });
+ }
+
+ // Build inquiry page HTML — skip website_summary if it looks like an internal score
+ const rawSummary = v.website_summary || '';
+ const isInternalScore = /shopify|seo|lazy loading|mobile support|site with/i.test(rawSummary);
+ const vendorDescription = (!isInternalScore && rawSummary.length > 20) ? rawSummary : (v.vendor_name + ' offers premium wallcoverings and textiles. Contact us to request samples, pricing, and availability.');
+ const bodyHtml = '<div style="max-width:800px;margin:0 auto;padding:20px;">'
+ + '<h1 style="font-size:28px;margin-bottom:10px;">' + v.vendor_name + ' Wallcoverings</h1>'
+ + '<p style="font-size:16px;line-height:1.6;color:#555;margin-bottom:30px;">'
+ + vendorDescription + '</p>'
+ + '<div style="background:#f8f8f8;border:1px solid #e0e0e0;border-radius:8px;padding:30px;margin-bottom:30px;">'
+ + '<h2 style="font-size:22px;margin-bottom:8px;">Inquire About ' + v.vendor_name + ' Products</h2>'
+ + '<p style="color:#666;margin-bottom:20px;">Interested in ' + v.vendor_name + '? Fill out the form below and our team will get back to you with pricing, samples, and availability.</p>'
+ + '<form action="/contact#contact_form" method="post">'
+ + '<input type="hidden" name="contact[subject]" value="Inquiry: ' + v.vendor_name + '">'
+ + '<div style="margin-bottom:15px;">'
+ + '<label style="display:block;font-weight:600;margin-bottom:5px;">Name</label>'
+ + '<input type="text" name="contact[name]" required style="width:100%;padding:10px;border:1px solid #ccc;border-radius:4px;font-size:15px;">'
+ + '</div>'
+ + '<div style="margin-bottom:15px;">'
+ + '<label style="display:block;font-weight:600;margin-bottom:5px;">Email</label>'
+ + '<input type="email" name="contact[email]" required style="width:100%;padding:10px;border:1px solid #ccc;border-radius:4px;font-size:15px;">'
+ + '</div>'
+ + '<div style="margin-bottom:15px;">'
+ + '<label style="display:block;font-weight:600;margin-bottom:5px;">Phone (optional)</label>'
+ + '<input type="tel" name="contact[phone]" style="width:100%;padding:10px;border:1px solid #ccc;border-radius:4px;font-size:15px;">'
+ + '</div>'
+ + '<div style="margin-bottom:15px;">'
+ + '<label style="display:block;font-weight:600;margin-bottom:5px;">Message</label>'
+ + '<textarea name="contact[body]" rows="5" required style="width:100%;padding:10px;border:1px solid #ccc;border-radius:4px;font-size:15px;" placeholder="Tell us what you are looking for — specific patterns, colors, quantities, or samples."></textarea>'
+ + '</div>'
+ + '<button type="submit" style="background:#1a1a2e;color:#fff;padding:12px 30px;border:none;border-radius:4px;font-size:16px;cursor:pointer;">Send Inquiry</button>'
+ + '</form>'
+ + '</div>'
+ + (v.website_url ? '<p style="color:#888;font-size:14px;">Visit <a href="' + v.website_url + '" target="_blank" rel="noopener">' + v.vendor_name + ' official site</a> for more information.</p>' : '')
+ + '</div>';
+
+ // Create Shopify page
+ const createUrl = 'https://' + SHOPIFY_DOMAIN + '/admin/api/2024-01/pages.json';
+ const createRes = await fetch(createUrl, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ page: {
+ title: v.vendor_name + ' Wallcoverings — Inquiry',
+ handle: handle,
+ body_html: bodyHtml,
+ published: true,
+ template_suffix: ''
+ }
+ })
+ });
+ const createData = await createRes.json();
+
+ if (createData.page) {
+ console.log('[Victor] Created Shopify inquiry page for ' + v.vendor_name + ': /pages/' + handle);
+ res.json({
+ success: true,
+ existing: false,
+ page_id: createData.page.id,
+ handle: createData.page.handle,
+ url: 'https://' + SHOPIFY_DOMAIN + '/pages/' + createData.page.handle,
+ message: 'Inquiry page created for ' + v.vendor_name
+ });
+ } else {
+ const errMsg = typeof createData.errors === 'string' ? createData.errors : JSON.stringify(createData.errors || 'Failed to create page');
+ if (errMsg.includes('write_content')) {
+ res.json({ success: false, error: 'Shopify token needs write_content scope. Approve in Shopify Admin > Apps > Custom app settings.' });
+ } else {
+ res.json({ success: false, error: errMsg });
+ }
+ }
+ } catch (err) {
+ console.error('[Victor] Inquiry page error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Discontinuation suggestions (match catalog vs Shopify) ---
+app.get('/api/vendors/:code/disc-suggestions', async (req, res) => {
+ try {
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, catalog_table FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+
+ const code = req.params.code;
+ const patterns = VENDOR_NAME_MAP[code];
+ if (!patterns || patterns.length === 0) {
+ return res.json({ success: true, data: [], count: 0, message: 'No Shopify vendor mapping' });
+ }
+
+ const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
+ const conditions = patterns.map((_, i) => `vendor ILIKE $${i + 1}`).join(' OR ');
+
+ // Find draft + archived Shopify products for this vendor
+ const query = `
+ SELECT shopify_id, title, vendor, sku, dw_sku, mfr_sku, image_url, status, price, cost_price,
+ CASE
+ WHEN UPPER(status) = 'ARCHIVED' THEN 'ARCHIVED'
+ WHEN UPPER(status) = 'DRAFT' THEN 'DRAFT'
+ END as disc_status
+ FROM shopify_products
+ WHERE (${conditions}) AND UPPER(status) IN ('DRAFT', 'ARCHIVED')
+ ORDER BY UPPER(status) DESC, title ASC
+ LIMIT $${patterns.length + 1}
+ `;
+
+ const countQuery = `
+ SELECT
+ COUNT(*) FILTER (WHERE UPPER(status) = 'DRAFT') as draft_count,
+ COUNT(*) FILTER (WHERE UPPER(status) = 'ARCHIVED') as archived_count,
+ COUNT(*) as total
+ FROM shopify_products
+ WHERE (${conditions}) AND UPPER(status) IN ('DRAFT', 'ARCHIVED')
+ `;
+
+ const [dataResult, countResult] = await Promise.all([
+ pool.query(query, [...patterns, limit]),
+ pool.query(countQuery, patterns)
+ ]);
+
+ res.json({
+ success: true,
+ vendor: vendorRows[0].vendor_name,
+ data: dataResult.rows,
+ counts: countResult.rows[0],
+ suggestion: 'These products are DRAFT or ARCHIVED on Shopify and may be candidates for permanent removal.'
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Global discontinuation report ---
+app.get('/api/disc-report', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT vendor_code, vendor_name, shopify_draft, shopify_archived,
+ (COALESCE(shopify_draft, 0) + COALESCE(shopify_archived, 0)) as total_disc,
+ shopify_count, catalog_count
+ FROM vendor_registry
+ WHERE (COALESCE(shopify_draft, 0) + COALESCE(shopify_archived, 0)) > 0
+ ORDER BY (COALESCE(shopify_draft, 0) + COALESCE(shopify_archived, 0)) DESC
+ `);
+
+ const totalDraft = result.rows.reduce((s, r) => s + (parseInt(r.shopify_draft) || 0), 0);
+ const totalArchived = result.rows.reduce((s, r) => s + (parseInt(r.shopify_archived) || 0), 0);
+
+ res.json({
+ success: true,
+ data: result.rows,
+ totals: { draft: totalDraft, archived: totalArchived, total: totalDraft + totalArchived },
+ vendors_with_disc: result.rows.length
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: 3-Point Discontinuation Validation ---
+
+// Summary: real cleanup numbers (not the fake 95K)
+app.get('/api/discontinuation/summary', async (req, res) => {
+ try {
+ const summary = await getDiscontinuationSummary();
+ res.json({ success: true, ...summary });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// Validate a vendor's products for discontinuation
+app.get('/api/discontinuation/validate/:vendor_code', async (req, res) => {
+ try {
+ const { vendor_code } = req.params;
+ const statusFilter = req.query.status || 'DRAFT';
+ const limit = Math.min(parseInt(req.query.limit) || 500, 2000);
+ const results = await validateVendor(vendor_code, { statusFilter, limit });
+ res.json({ success: true, ...results });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// Delete draft products from Shopify (dry-run by default)
+app.post('/api/discontinuation/delete-drafts', async (req, res) => {
+ try {
+ const { vendor_code, dry_run = true, limit = 100 } = req.body || {};
+ const isDryRun = dry_run !== false && dry_run !== 'false';
+
+ // Build query for draft products
+ let query, params;
+ if (vendor_code) {
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT vendor_name FROM vendor_registry WHERE vendor_code = $1', [vendor_code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ query = `
+ SELECT shopify_id, title, vendor, dw_sku, mfr_sku, image_url
+ FROM shopify_products
+ WHERE vendor ILIKE $1 AND UPPER(status) = 'DRAFT'
+ ORDER BY title ASC
+ LIMIT $2
+ `;
+ params = [vendorRows[0].vendor_name, Math.min(parseInt(limit), 500)];
+ } else {
+ query = `
+ SELECT shopify_id, title, vendor, dw_sku, mfr_sku, image_url
+ FROM shopify_products
+ WHERE UPPER(status) = 'DRAFT'
+ ORDER BY vendor ASC, title ASC
+ LIMIT $1
+ `;
+ params = [Math.min(parseInt(limit), 500)];
+ }
+
+ const { rows: drafts } = await pool.query(query, params);
+
+ if (isDryRun) {
+ // Cross-reference with catalog to show what exists in PostgreSQL
+ const withCatalog = [];
+ const noCatalog = [];
+ for (const d of drafts) {
+ const { rows: catalogMatch } = await pool.query(`
+ SELECT id, sku, name, vendor_id FROM products
+ WHERE sku = $1 OR sku = $2
+ LIMIT 1
+ `, [d.mfr_sku || '', d.dw_sku || '']);
+ if (catalogMatch.length > 0) {
+ withCatalog.push({ ...d, catalog_id: catalogMatch[0].id, catalog_name: catalogMatch[0].name });
+ } else {
+ noCatalog.push(d);
+ }
+ }
+
+ return res.json({
+ success: true,
+ dry_run: true,
+ vendor: vendor_code || 'ALL',
+ total_drafts: drafts.length,
+ in_catalog: withCatalog.length,
+ not_in_catalog: noCatalog.length,
+ action_plan: {
+ delete_from_shopify: drafts.length,
+ data_safe_in_postgresql: withCatalog.length,
+ junk_no_catalog: noCatalog.length
+ },
+ products_with_catalog: withCatalog.slice(0, 20),
+ products_no_catalog: noCatalog.slice(0, 20),
+ message: 'DRY RUN — no changes made. Set dry_run: false to execute.'
+ });
+ }
+
+ // LIVE MODE — Queue deletions via Quinn (Shopify GraphQL)
+ const SHOPIFY_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+ const PRODUCT_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+
+ let deleted = 0, failed = 0;
+ const errors = [];
+
+ // Batch delete via GraphQL (25 per call)
+ for (let i = 0; i < drafts.length; i += 25) {
+ const batch = drafts.slice(i, i + 25);
+ const mutations = batch.map((d, idx) =>
+ `p${idx}: productDelete(input: {id: "${String(d.shopify_id).startsWith('gid://') ? d.shopify_id : 'gid://shopify/Product/' + d.shopify_id}"}) { deletedProductId userErrors { field message } }`
+ ).join('\n');
+
+ try {
+ const gqlResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-10/graphql.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: `mutation { ${mutations} }` })
+ });
+ const gqlData = await gqlResp.json();
+
+ for (let j = 0; j < batch.length; j++) {
+ const result = gqlData?.data?.[`p${j}`];
+ if (result?.deletedProductId) {
+ deleted++;
+ // Update shopify_products status
+ await pool.query(
+ "UPDATE shopify_products SET status = 'DELETED_FROM_SHOPIFY' WHERE shopify_id = $1",
+ [batch[j].shopify_id]
+ );
+ } else {
+ const userErrors = result?.userErrors || [];
+ const notExists = userErrors.some(e => e.message && e.message.includes('does not exist'));
+ if (notExists) {
+ // Product already gone from Shopify — clean up our DB record
+ deleted++;
+ await pool.query(
+ "UPDATE shopify_products SET status = 'DELETED_FROM_SHOPIFY' WHERE shopify_id = $1",
+ [batch[j].shopify_id]
+ );
+ } else {
+ failed++;
+ errors.push({ shopify_id: batch[j].shopify_id, error: userErrors.length ? userErrors : 'unknown' });
+ }
+ }
+ }
+
+ // Rate limit between batches
+ await new Promise(r => setTimeout(r, 1000));
+ } catch (err) {
+ failed += batch.length;
+ errors.push({ batch_start: i, error: err.message });
+ }
+ }
+
+ res.json({
+ success: true,
+ dry_run: false,
+ vendor: vendor_code || 'ALL',
+ deleted,
+ failed,
+ total_attempted: drafts.length,
+ errors: errors.slice(0, 10)
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Vendor Name Mapping (Shopify vendor names → vendor_registry codes) ---
+const VENDOR_NAME_MAP = {
+ // --- Original 33 vendors ---
+ 'schumacher': ['Schumacher%', 'F. Schumacher%'],
+ 'kravet': ['Kravet%'],
+ 'thibaut': ['Thibaut%', 'THIBAUT AT DW%', 'Anna French%', 'ANNA FRENCH%'],
+ 'cole_son': ['Cole & Son%', 'Cole and Son%'],
+ 'elitis': ['Elitis%'],
+ 'arte': ['Arte%', 'ARTE%'],
+ 'koroseal': ['Koroseal%'],
+ 'maya': ['Maya Romanoff%'],
+ 'phillip_jeffries': ['Phillip Jeffries%'],
+ 'innovations': ['Innovations%', 'Innovationsusa%'],
+ 'ralph_lauren': ['Ralph Lauren%'],
+ 'brewster_york': ['Brewster%', 'Ronald Redding%', 'DAISY BENNETT%', 'Daisy Bennett%', 'A-Street%'],
+ 'york_contract': ['York%'],
+ 'graham_brown': ['Graham & Brown%', 'Graham and Brown%', 'Grahambrown%'],
+ 'romo': ['Romo%'],
+ 'osborne': ['Osborne & Little%'],
+ 'mindthegap': ['Mind the Gap%'],
+ 'hygge': ['Hygge%'],
+ 'wallquest': ['Wallquest%', 'WallQuest%'],
+ 'glass_bead': ['Glass Bead%', 'Glambeads%'],
+ 'bespoke': ['DW Bespoke%'],
+ 'w1838': ['1838%'],
+ 'contrado': ['Contrado%'],
+ 'fabricut': ['Fabricut%'],
+ 'dedar': ['Dedar%'],
+ 'designtex': ['Designtex%'],
+ 'knoll': ['Knoll%'],
+ 'bnwalls': ['BN Walls%'],
+ 'stout': ['Stout%'],
+ 'timorous': ['Timorous%'],
+ 'arteriors': ['Arteriors%'],
+ 'folia': ['Folia%'],
+ 'muralsource': ['Mural Source%'],
+ 'spoonflower': ['Spoonflower%'],
+ // --- Newly added vendors ---
+ 'scalamandre': ['Scalamandre%'],
+ 'hollywood': ['Hollywood%', 'Momentumtextilesandwalls%'],
+ 'phillipe_romano': ['Phillipe Romano%', 'PR Faux Leather%', 'Velvet Walls by Phillipe Romano%'],
+ 'clarke_clarke': ['Clarke And Clarke%'],
+ 'brunschwig': ['Brunschwig%'],
+ 'jf_fabrics': ['JF FABRICS%', 'JF Fabrics%'],
+ 'jeffrey_stevens': ['Jeffrey Stevens%'],
+ 'lee_jofa': ['Lee Jofa%'],
+ 'rebel_walls': ['Rebel Walls%', 'Rebel Studio%'],
+ 'fentucci': ['Fentucci%'],
+ 'gaston_daniela': ['Gaston Y Daniela%'],
+ 'malibu': ['Malibu Wall%'],
+ 'holly_hunt': ['Holly Hunt%'],
+ 'sandberg': ['Sandberg%'],
+ 'china_seas': ['China Seas%'],
+ 'designers_guild': ['Designers Guild%', "Designer's Guild%", 'Designersguild%', 'Christian Lacroix%'],
+ 'wolf_gordon': ['Wolf Gordon%'],
+ 'paul_montgomery': ['Paul Montgomery%'],
+ // Anna French is a Thibaut sub-brand — map to thibaut
+ 'mulberry': ['Mulberry%'],
+ 'harlequin': ['Harlequin%'],
+ 'newmor': ['Newmor%'],
+ 'william_morris': ['William Morris%', 'Morris and Company%'],
+ 'british_walls': ['British Walls%'],
+ 'et_cie': ['Et Cie%'],
+ 'cowtan_tout': ['Cowtan & Tout%', 'Cowtan %'],
+ 'nina_campbell': ['Nina Campbell%'],
+ 'catchii': ['Catchii%'],
+ 'versace': ['Versace%'],
+ 'colefax_fowler': ['Colefax%'],
+ 'coordonne': ['Coordonn%'],
+ 'relativity_textiles': ['Retro Walls%', 'Retro DW%'],
+ 'surface_stick': ['Surface Stick%'],
+ 'la_walls': ['LA Walls%'],
+ 'gp_baker': ['G P & J Baker%', 'GP & J Baker%'],
+ 'roberto_cavalli': ['Roberto Cavalli%'],
+ 'armani_casa': ['Armani Casa%'],
+ 'nicolette_mayer': ['Nicolette Mayer%'],
+ 'nlxl': ['NLXL%'],
+ 'laura_ashley': ['Laura Ashley%'],
+ 'missoni': ['Missoni%'],
+ 'marburg': ['Marburg%'],
+ 'manuel_canovas': ['Manuel Canovas%'],
+ 'mark_alexander': ['Mark Alexander%'],
+ 'carlisle': ['Carlisle%'],
+ 'baker_lifestyle': ['Baker Lifestyle%'],
+ 'threads': ['Threads%'],
+ 'glitter_walls': ['Glitter Walls%'],
+ 'primo_leathers': ['Primo Leathers%'],
+ 'leed_walls': ['LEED Walls%'],
+ 'showroom_line': ['Showroom Line%'],
+
+ 'lincrusta': ['Lincrusta%'],
+ 'tres_tintas': ['Tres Tintas%'],
+ 'brand_mckenzie': ['Brand McKenzie%'],
+ 'borastapeter': ['Bor%stapeter%'],
+ 'iksel': ['IKSEL%', 'Iksel%'],
+ 'hermes': ['Hermes%'],
+ 'dolce_gabbana': ['Dolce & Gabbana%'],
+ 'ultrasuede': ['Ultrasuede%'],
+ 'novasuede': ['Novasuede%'],
+ // --- Sub-brands mapped to parent ---
+ // Christian Lacroix is a Designers Guild sub-brand
+ // Velvet Walls is a Phillipe Romano sub-brand (name doesn't start with PR)
+ // Momentumtextilesandwalls is Hollywood/Momentum
+
+ // --- New vendors (confirmed by Steve 2026-03-16) ---
+ 'andrew_martin': ['Andrew Martin%'],
+ 'as_creation': ['AS Creation%'],
+ 'backdrop': ['Backdrop%'],
+ 'clarence_house': ['Clarence House%'],
+ 'donghia': ['Donghia%'],
+ 'emiliana_parati': ['Emiliana Parati%'],
+ 'erika_wakerly': ['Erika Wakerly%'],
+ 'florence_broadhurst': ['Florence Broadhurst%'],
+ 'graduate_collection': ['Graduate Collection%', 'The Graduate Collection%'],
+ 'holland_sherry': ['Holland and Sherry%'],
+ 'martyn_lawrence_bullard': ['Martyn Lawrence Bullard%'],
+ 'mc_escher': ['MC Escher%'],
+ 'nobilis': ['Nobilis%'],
+ 'peg_norriss': ['Peg Norriss%'],
+ 'phyllis_morris': ['Phyllis Morris%'],
+ 'pierre_frey': ['Pierre Frey%'],
+ 'sacha_walckhoff': ['Sacha Walckhoff%'],
+ 'sugarboo': ['SUGARBOO%'],
+ 'telefina': ['Telefina%'],
+ 'witch_watchman': ['Witch & Watchman%', 'Witch And Watchman%'],
+ 'zeuxis': ['Zeuxis Parrhasius%'],
+ 'patty_madden': ['Patty Madden%'],
+ 'kelly_wearstler': ['Kelly Wearstler%'],
+ 'zoffany': ['Zoffany%'],
+ 'studio_ditte': ['Studio Ditte%'],
+ 'milton_king': ['Milton & King%', 'Milton and King%'],
+ 'celerie_kemble': ['Celerie Kemble%'],
+ 'hinson': ['Hinson%'],
+ 'farrow_ball': ['Farrow%ball%'],
+ 'winfield_thybony': ['Winfield Thybony%'],
+ 'sancar': ['Sancar%'],
+ 'edge_wallcovering': ['Edge Wallcovering%'],
+ 'ananbo': ['Ananbo%'],
+ 'rebecca_moses': ['Rebecca Moses%'],
+ 'happy_menocal': ['Happy Menocal%'],
+ 'sara_bergqvist': ['Sara Bergqvist%'],
+ 'sarah_bartholomew': ['Sarah Bartholomew%'],
+ 'olivia_popp': ['OLIVIA AND POPP%', 'Olivia and Popp%'],
+ 'linherr': ['Linherr Hollingsworth%'],
+ 'vahallan': ['Vahallan%'],
+ // --- Internal / House brands ---
+ 'dw_house': ['Designer Wallcoverings%', 'DW Home%', 'DW%Exclusive%', 'Hand Crafted%', 'Traditional Whimsy%', 'Mid Century Modern%', 'Apartment Wallpaper%', 'Hospitality Wallcoverings%', 'Wallpaper NYC%', 'Peel and Stick%', 'PS Removable%', 'Limited Stock%', 'Madagascar%', 'Florentina%', 'Steve Abrams%', 'Steven Abrams%', 'Architectural Fabrics%', 'Luxury Murals%', 'Trikes-DL Couch-Eykon-Tower%', 'Marimekko Exclusive%', 'Natural Paint%', 'Pixels%', 'French Market%', 'Grasscloth WallPaper%', 'Designer Laboratory%', 'Porsche%Paint%', 'Printy6%', 'TEST']
+};
+
+// Catalog table mapping for scraped vendor data
+const CATALOG_TABLE_MAP = {
+ 'arte': 'arte_catalog',
+ 'brewster_york': 'brewster_catalog',
+ 'cole_son': 'coleson_catalog',
+ 'elitis': 'elitis_catalog',
+ 'innovations': 'innovations_catalog',
+ 'koroseal': 'koroseal_catalog',
+ 'kravet': 'kravet_catalog',
+ 'maya': 'maya_catalog',
+ 'phillip_jeffries': 'pj_catalog',
+ 'ralph_lauren': 'rl_catalog',
+ 'schumacher': 'schumacher_catalog',
+ 'thibaut': 'thibaut_catalog',
+ 'york_contract': 'york_catalog',
+ 'graham_brown': 'graham_brown_catalog',
+ 'romo': 'romo_catalog',
+ 'bespoke': 'bespoke_catalog',
+ 'china_seas': 'china_seas_catalog',
+ 'cowtan_tout': 'cowtan_catalog',
+ // New scraped tables
+ 'w1838': 'w1838_catalog',
+ 'dedar': 'dedar_catalog',
+ 'designtex': 'designtex_catalog',
+ 'fabricut': 'fabricut_catalog',
+ 'knoll': 'knoll_catalog',
+ 'hygge': 'hygge_catalog',
+ 'bnwalls': 'bnwalls_catalog',
+ 'mindthegap': 'mindthegap_catalog',
+ 'osborne': 'osborne_catalog',
+ 'stout': 'stout_catalog',
+ 'wallquest': 'wallquest_catalog',
+ 'contrado': 'contrado_catalog',
+ 'muralsource': 'muralsource_catalog',
+ 'timorous': 'timorous_catalog',
+ 'arteriors': 'arteriors_catalog',
+ 'folia': 'folia_catalog',
+ 'spoonflower': 'spoonflower_catalog'
+};
+
+// --- Catalog column mapping (price/image column names per table) ---
+const CATALOG_COLUMNS = {
+ 'arte_catalog': { price: 'price', image: 'image_url' },
+ 'bespoke_catalog': { price: null, image: 'image_url' },
+ 'brewster_catalog': { price: null, image: 'image_url' },
+ 'china_seas_catalog': { price: null, image: 'image_url' },
+ 'coleson_catalog': { price: null, image: 'image_url' },
+ 'cowtan_catalog': { price: null, image: 'image_url' },
+ 'elitis_catalog': { price: null, image: 'image_url' },
+ 'graham_brown_catalog': { price: 'trade_price', image: 'image_url' },
+ 'innovations_catalog': { price: null, image: 'image_url' },
+ 'koroseal_catalog': { price: null, image: 'image_url' },
+ 'kravet_catalog': { price: null, image: 'image_url' },
+ 'maya_catalog': { price: null, image: 'image_url' },
+ 'pj_catalog': { price: null, image: 'image_url' },
+ 'rl_catalog': { price: 'price', image: 'image_url' },
+ 'romo_catalog': { price: 'price', image: 'image_url' },
+ 'schumacher_catalog': { price: 'trade_price', image: 'image_url' },
+ 'thibaut_catalog': { price: 'your_cost', image: 'image_url' },
+ 'york_catalog': { price: null, image: 'image_url' },
+ 'york_contract_catalog': { price: 'retail_price', image: 'image_url' },
+ // New scraped tables (all use standard column names)
+ 'w1838_catalog': { price: 'price_retail', image: 'image_url' },
+ 'dedar_catalog': { price: 'price_retail', image: 'image_url' },
+ 'designtex_catalog': { price: 'price_retail', image: 'image_url' },
+ 'fabricut_catalog': { price: 'price_retail', image: 'image_url' },
+ 'knoll_catalog': { price: 'price_retail', image: 'image_url' },
+ 'hygge_catalog': { price: 'price_retail', image: 'image_url' },
+ 'bnwalls_catalog': { price: 'price_retail', image: 'image_url' },
+ 'mindthegap_catalog': { price: 'price_retail', image: 'image_url' },
+ 'osborne_catalog': { price: 'price_retail', image: 'image_url' },
+ 'stout_catalog': { price: 'price_retail', image: 'image_url' },
+ 'wallquest_catalog': { price: 'price_retail', image: 'image_url' },
+ 'contrado_catalog': { price: 'price_retail', image: 'image_url' },
+ 'muralsource_catalog': { price: 'price_retail', image: 'image_url' },
+ 'timorous_catalog': { price: 'price_retail', image: 'image_url' },
+ 'arteriors_catalog': { price: 'price_retail', image: 'image_url' },
+ 'folia_catalog': { price: 'price_retail', image: 'image_url' },
+ 'spoonflower_catalog': { price: 'price_retail', image: 'image_url' },
+ // Romo Group sub-brands
+ 'black_edition_catalog': { price: null, image: 'image_url' },
+ 'kirkby_catalog': { price: null, image: 'image_url' },
+ 'zinc_catalog': { price: null, image: 'image_url' },
+ 'villa_nova_catalog': { price: null, image: 'image_url' },
+ 'mark_alexander_catalog':{ price: null, image: 'image_url' },
+ // Non-standard column names
+ 'franquemont_catalog': { price: null, image: "image_urls[1]" },
+ 'versace_catalog': { price: null, image: 'image_url' },
+ // More vendors
+ 'coordonne_catalog': { price: null, image: 'image_url' },
+ 'harlequin_catalog': { price: null, image: 'image_url' },
+ 'scalamandre_catalog': { price: null, image: 'image_url' },
+ 'as_creation_catalog': { price: null, image: 'image_url' },
+ 'marburg_catalog': { price: null, image: 'image_url' },
+ 'newwall_catalog': { price: null, image: 'image_url' },
+ 'nicolette_mayer_catalog': { price: null, image: 'image_url' },
+ 'malibu_catalog': { price: null, image: 'image_url' },
+ 'hollywood_catalog': { price: null, image: 'image_url' },
+ 'jf_fabrics_catalog': { price: null, image: 'image_url' },
+ 'jeffrey_stevens_catalog': { price: null, image: 'image_url' },
+ 'nobilis_catalog': { price: null, image: 'image_url' }
+};
+
+// --- Spec column mapping (width & repeat column names per catalog table) ---
+const SPEC_COLUMNS = {
+ // Old-style tables: width + pattern_repeat
+ 'arte_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'bespoke_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'brewster_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'china_seas_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'coleson_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'cowtan_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'elitis_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'graham_brown_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'innovations_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'koroseal_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'kravet_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'maya_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'pj_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'rl_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'romo_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'schumacher_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'thibaut_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'york_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'york_contract_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ // New-style tables: width + repeat_v
+ 'w1838_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'dedar_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'designtex_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'fabricut_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'knoll_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'hygge_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'bnwalls_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'mindthegap_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'osborne_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'stout_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'wallquest_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'contrado_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'muralsource_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'timorous_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'arteriors_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'folia_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'spoonflower_catalog': { width: 'width', repeat: 'repeat_v' },
+ // Romo Group sub-brands
+ 'black_edition_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'kirkby_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'zinc_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'villa_nova_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'mark_alexander_catalog':{ width: 'width', repeat: 'pattern_repeat' },
+ // Non-standard column names
+ 'franquemont_catalog': { width: 'roll_width', repeat: 'vertical_repeat' },
+ 'versace_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ // More vendors
+ 'coordonne_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'harlequin_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'scalamandre_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'as_creation_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'marburg_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'newwall_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'nicolette_mayer_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'malibu_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'hollywood_catalog': { width: 'width', repeat: 'repeat_v' },
+ 'jf_fabrics_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'jeffrey_stevens_catalog': { width: 'width', repeat: 'pattern_repeat' },
+ 'nobilis_catalog': { width: 'width', repeat: 'pattern_repeat' }
+};
+
+// --- Required Catalog Fields (ALL vendors must capture these) ---
+const REQUIRED_CATALOG_FIELDS = [
+ 'mfr_sku', 'dw_sku', 'pattern_name', 'color_name', 'collection',
+ 'product_type', 'width', 'length', 'repeat_v', 'repeat_h',
+ 'match_type', 'material', 'finish', 'application', 'coverage',
+ 'features', 'fire_rating', 'design', 'rooms', 'color_primary',
+ 'color_secondary', 'price_retail', 'price_currency', 'image_url',
+ 'all_images', 'product_url', 'in_stock', 'vendor_name', 'brand',
+ 'tags', 'body_html', 'short_description', 'about_vendor',
+ 'us_distributor', 'showroom_locations'
+];
+
+// Map required field names to possible column aliases per catalog table
+// Old-style tables use different column names for the same data
+const FIELD_ALIASES = {
+ 'mfr_sku': ['mfr_sku', 'arte_sku', 'sku', 'item_number', 'product_sku', 'vendor_sku'],
+ 'repeat_v': ['repeat_v', 'pattern_repeat', 'vertical_repeat'],
+ 'repeat_h': ['repeat_h', 'horizontal_repeat'],
+ 'price_retail': ['price_retail', 'price', 'retail_price', 'trade_price', 'your_cost', 'msrp'],
+ 'length': ['length', 'roll_length'],
+ 'fire_rating': ['fire_rating', 'fire_rating_us', 'fire_rating_eu'],
+ 'body_html': ['body_html', 'description', 'ai_description'],
+ 'tags': ['tags', 'ai_tags'],
+ 'design': ['design', 'ai_patterns', 'ai_styles'],
+ 'color_primary': ['color_primary', 'ai_background_color'],
+ 'color_secondary': ['color_secondary', 'ai_colors'],
+ 'rooms': ['rooms'],
+ 'application': ['application', 'installation', 'adhesive', 'how_to_paste']
+};
+
+// --- Compute spec completeness for a catalog table ---
+async function computeSpecCompleteness(catTable) {
+ if (!catTable) return { pct: 0, present: 0, total: REQUIRED_CATALOG_FIELDS.length };
+ try {
+ // Get actual columns in this table
+ const colRes = await pool.query(
+ `SELECT column_name FROM information_schema.columns WHERE table_name = $1`,
+ [catTable]
+ );
+ const actualCols = new Set(colRes.rows.map(r => r.column_name));
+ if (actualCols.size === 0) return { pct: 0, present: 0, total: REQUIRED_CATALOG_FIELDS.length };
+
+ let fieldsPresent = 0;
+
+ for (const field of REQUIRED_CATALOG_FIELDS) {
+ // Check if any alias of this field exists as a column
+ const aliases = FIELD_ALIASES[field] || [field];
+ const matchedCol = aliases.find(a => actualCols.has(a));
+ if (matchedCol) {
+ fieldsPresent++;
+ }
+ }
+
+ const pct = REQUIRED_CATALOG_FIELDS.length > 0
+ ? parseFloat(((fieldsPresent / REQUIRED_CATALOG_FIELDS.length) * 100).toFixed(1))
+ : 0;
+
+ return { pct, present: fieldsPresent, total: REQUIRED_CATALOG_FIELDS.length };
+ } catch (_) {
+ return { pct: 0, present: 0, total: REQUIRED_CATALOG_FIELDS.length };
+ }
+}
+
+// --- Sync: Populate vendor_registry — CATALOG tables are source of truth ---
+async function syncVendorCounts() {
+ console.log('[Victor] Syncing vendor counts — PostgreSQL catalogs = source of truth, Shopify = published...');
+ logActivity('sync', 'Sync started — scanning all vendor catalogs');
+ const startTime = Date.now();
+ let updated = 0;
+
+ try {
+ // Get all vendors with current counts for change detection
+ const { rows: allVendors } = await pool.query('SELECT vendor_code, vendor_name, catalog_table, catalog_count as old_cat, shopify_count as old_shop, total_products as old_total FROM vendor_registry');
+
+ for (const v of allVendors) {
+ const code = v.vendor_code;
+ const catTable = v.catalog_table;
+ let catalogCount = 0, catWithCost = 0, catWithImages = 0, catOnShopify = 0;
+ let catWithWidth = 0, catWithRepeat = 0;
+ let catWithAboutVendor = 0, catWithAllImages = 0, catWithSpecSheet = 0, catWithBodyHtml = 0;
+ let shopifyCount = 0, shopifyWithImages = 0;
+ let shopifyDraft = 0, shopifyArchived = 0;
+ let parkerCost = 0;
+
+ // --- PRIMARY: Catalog table (source of truth) ---
+ if (catTable) {
+ try {
+ const cols = CATALOG_COLUMNS[catTable] || { price: null, image: 'image_url' };
+
+ // When table is vendor_catalog (shared), filter by vendor_code
+ const isShared = catTable === 'vendor_catalog';
+ const vendorFilter = isShared ? ` WHERE vendor_code = '${code.replace(/'/g, "''")}'` : '';
+ const vendorAnd = isShared ? ` AND vendor_code = '${code.replace(/'/g, "''")}'` : '';
+
+ // Total catalog products
+ const totalRes = await pool.query(`SELECT COUNT(*) as cnt FROM ${catTable}${vendorFilter}`);
+ catalogCount = parseInt(totalRes.rows[0].cnt) || 0;
+
+ // Catalog products with cost/price
+ if (cols.price) {
+ const costRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${cols.price} IS NOT NULL AND ${cols.price}::text != '' AND ${cols.price}::text != '0'${vendorAnd}`
+ );
+ catWithCost = parseInt(costRes.rows[0].cnt) || 0;
+ }
+
+ // Catalog products with images (handle array columns like image_urls)
+ if (cols.image) {
+ let imgQuery;
+ if (cols.image.includes('[')) {
+ // Array column like image_urls[1]
+ const arrCol = cols.image.split('[')[0];
+ imgQuery = `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${arrCol} IS NOT NULL AND array_length(${arrCol},1) > 0${vendorAnd}`;
+ } else {
+ imgQuery = `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${cols.image} IS NOT NULL AND ${cols.image} != ''${vendorAnd}`;
+ }
+ const imgRes = await pool.query(imgQuery);
+ catWithImages = parseInt(imgRes.rows[0].cnt) || 0;
+ }
+
+ // Catalog products with width spec
+ const specCols = SPEC_COLUMNS[catTable];
+ if (specCols) {
+ if (specCols.width) {
+ try {
+ const widthRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${specCols.width} IS NOT NULL AND ${specCols.width}::text != '' AND ${specCols.width}::text != '0'${vendorAnd}`
+ );
+ catWithWidth = parseInt(widthRes.rows[0].cnt) || 0;
+ } catch (_) {}
+ }
+ if (specCols.repeat) {
+ try {
+ const repeatRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${specCols.repeat} IS NOT NULL AND ${specCols.repeat}::text != '' AND ${specCols.repeat}::text != '0'${vendorAnd}`
+ );
+ catWithRepeat = parseInt(repeatRes.rows[0].cnt) || 0;
+ } catch (_) {}
+ }
+ }
+
+ // Catalog products with about_vendor, all_images, spec_sheet, body_html
+ const assetFields = [
+ { col: 'about_vendor', setter: (v) => catWithAboutVendor = v },
+ { col: 'all_images', setter: (v) => catWithAllImages = v },
+ { col: 'spec_sheet', setter: (v) => catWithSpecSheet = v },
+ { col: 'body_html', setter: (v) => catWithBodyHtml = v }
+ ];
+ for (const af of assetFields) {
+ try {
+ const afRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM ${catTable} WHERE ${af.col} IS NOT NULL AND ${af.col}::text != '' AND ${af.col}::text != '[]'${vendorAnd}`
+ );
+ af.setter(parseInt(afRes.rows[0].cnt) || 0);
+ } catch (_) { /* column may not exist */ }
+ }
+
+ // Catalog products already on Shopify
+ try {
+ const shopRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM ${catTable} WHERE shopify_product_id IS NOT NULL AND shopify_product_id != ''${vendorAnd}`
+ );
+ catOnShopify = parseInt(shopRes.rows[0].cnt) || 0;
+ } catch (_) { /* no shopify_product_id column */ }
+ } catch (_) { /* table may not exist yet */ }
+ }
+
+ // --- Spec Completeness ---
+ const specResult = await computeSpecCompleteness(catTable);
+ const specPct = specResult.pct;
+ const specPresent = specResult.present;
+ const specTotal = specResult.total;
+
+ // --- SECONDARY: Shopify products (what's published) ---
+ const patterns = VENDOR_NAME_MAP[code];
+ if (patterns && patterns.length > 0) {
+ try {
+ const conditions = patterns.map((_, i) => `vendor ILIKE $${i + 1}`).join(' OR ');
+ const shopRes = await pool.query(
+ `SELECT COUNT(DISTINCT shopify_id) as cnt,
+ COUNT(CASE WHEN image_url IS NOT NULL AND image_url != '' THEN 1 END) as with_img,
+ COUNT(CASE WHEN UPPER(status) = 'DRAFT' THEN 1 END) as draft_cnt,
+ COUNT(CASE WHEN UPPER(status) = 'ARCHIVED' THEN 1 END) as arch_cnt
+ FROM shopify_products WHERE ${conditions}`,
+ patterns
+ );
+ shopifyCount = parseInt(shopRes.rows[0].cnt) || 0;
+ shopifyWithImages = parseInt(shopRes.rows[0].with_img) || 0;
+ shopifyDraft = parseInt(shopRes.rows[0].draft_cnt) || 0;
+ shopifyArchived = parseInt(shopRes.rows[0].arch_cnt) || 0;
+ } catch (_) {}
+ }
+
+ // --- Parker cost data ---
+ if (patterns && patterns.length > 0) {
+ try {
+ const parkerPatterns = patterns.map(p => '%' + p.replace(/%/g, '') + '%');
+ const parkerConditions = parkerPatterns.map((_, i) => `vendor_name ILIKE $${i + 1}`).join(' OR ');
+ const parkerRes = await pool.query(
+ `SELECT COUNT(*) as cnt FROM parker_products WHERE vendor_cost > 0 AND (${parkerConditions})`,
+ parkerPatterns
+ );
+ parkerCost = parseInt(parkerRes.rows[0].cnt) || 0;
+ } catch (_) {}
+ }
+
+ // Determine best product count: catalog (primary) > shopify (secondary)
+ const totalProducts = catalogCount > 0 ? catalogCount : shopifyCount;
+ const totalWithCost = Math.max(catWithCost, parkerCost);
+ const totalWithImages = catalogCount > 0 ? catWithImages : shopifyWithImages;
+ const notOnShopify = catalogCount > 0 ? Math.max(0, catalogCount - catOnShopify) : 0;
+
+ const discCount = shopifyDraft + shopifyArchived;
+
+ await pool.query(
+ `UPDATE vendor_registry SET
+ total_products = $1, products_with_cost = $2, products_with_images = $3,
+ catalog_count = $4, shopify_count = $5, catalog_with_cost = $6,
+ catalog_with_images = $7, catalog_on_shopify = $8, catalog_not_on_shopify = $9,
+ catalog_with_width = $10, catalog_with_repeat = $11,
+ shopify_draft = $12, shopify_archived = $13, discontinued_count = $14,
+ spec_completeness = $15, spec_fields_present = $16, spec_fields_total = $17,
+ catalog_with_about_vendor = $19, catalog_with_all_images = $20,
+ catalog_with_spec_sheet = $21, catalog_with_body_html = $22,
+ updated_at = NOW()
+ WHERE vendor_code = $18`,
+ [totalProducts, totalWithCost, totalWithImages,
+ catalogCount, shopifyCount, catWithCost,
+ catWithImages, catOnShopify, notOnShopify,
+ catWithWidth, catWithRepeat,
+ shopifyDraft, shopifyArchived, discCount,
+ specPct, specPresent, specTotal, code,
+ catWithAboutVendor, catWithAllImages, catWithSpecSheet, catWithBodyHtml]
+ );
+ updated++;
+
+ // Log significant changes
+ const oldCat = parseInt(v.old_cat) || 0;
+ const oldShop = parseInt(v.old_shop) || 0;
+ if (catalogCount > 0 && catalogCount !== oldCat) {
+ const diff = catalogCount - oldCat;
+ const arrow = diff > 0 ? '+' : '';
+ logActivity('sync', `${v.vendor_name}: catalog ${oldCat} -> ${catalogCount} (${arrow}${diff})`, { code, diff });
+ }
+ if (shopifyCount > 0 && shopifyCount !== oldShop) {
+ const diff = shopifyCount - oldShop;
+ const arrow = diff > 0 ? '+' : '';
+ logActivity('sync', `${v.vendor_name}: Shopify ${oldShop} -> ${shopifyCount} (${arrow}${diff})`, { code, diff });
+ }
+ }
+
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`[Victor] Sync complete: ${updated} vendors updated in ${elapsed}s`);
+ logActivity('sync', `Sync complete: ${updated} vendors updated in ${elapsed}s`);
+ return { success: true, updated, elapsed };
+ } catch (err) {
+ console.error('[Victor] Sync error:', err.message);
+ logActivity('error', 'Sync failed: ' + err.message);
+ return { success: false, error: err.message };
+ }
+}
+
+// --- API: Sample product from vendor catalog (best-populated row) ---
+app.get('/api/vendors/:code/sample-product', async (req, res) => {
+ try {
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT catalog_table, vendor_name FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ const table = vendorRows[0].catalog_table;
+ if (!table) return res.json({ success: true, data: null, message: 'No catalog table configured' });
+ // Sanitize table name
+ if (!/^[a-z0-9_]+$/.test(table)) return res.status(400).json({ success: false, error: 'Invalid table name' });
+ // Get columns
+ const colResult = await pool.query(
+ `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position`, [table]
+ );
+ const columns = colResult.rows.map(r => r.column_name);
+ // Get best-populated product (most non-null/non-empty fields, with image)
+ const sample = await pool.query(
+ `SELECT * FROM ${table} WHERE image_url IS NOT NULL AND image_url != '' ORDER BY updated_at DESC NULLS LAST LIMIT 1`
+ );
+ if (!sample.rows.length) {
+ // Fallback: any product
+ const fallback = await pool.query(`SELECT * FROM ${table} ORDER BY id LIMIT 1`);
+ return res.json({ success: true, data: fallback.rows[0] || null, columns, schema: colResult.rows, vendor: vendorRows[0].vendor_name });
+ }
+ res.json({ success: true, data: sample.rows[0], columns, schema: colResult.rows, vendor: vendorRows[0].vendor_name });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Chat with vendor agent (data-driven answers) ---
+app.post('/api/vendors/:code/chat', async (req, res) => {
+ try {
+ const { message } = req.body;
+ if (!message) return res.status(400).json({ success: false, error: 'Message required' });
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT * FROM vendor_registry WHERE vendor_code = $1', [req.params.code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ const v = vendorRows[0];
+ const table = v.catalog_table;
+ let catalogStats = null;
+ if (table && /^[a-z0-9_]+$/.test(table)) {
+ const stats = await pool.query(`
+ SELECT
+ COUNT(*) as total,
+ COUNT(*) FILTER (WHERE image_url IS NOT NULL AND image_url != '') as with_images,
+ COUNT(*) FILTER (WHERE width IS NOT NULL AND width != '') as with_width,
+ COUNT(*) FILTER (WHERE repeat_v IS NOT NULL AND repeat_v != '') as with_repeat,
+ COUNT(*) FILTER (WHERE discontinued = true) as discontinued,
+ COUNT(*) FILTER (WHERE shopify_product_id IS NOT NULL AND shopify_product_id != '') as on_shopify,
+ COUNT(*) FILTER (WHERE price_retail IS NOT NULL) as with_price,
+ COUNT(*) FILTER (WHERE material IS NOT NULL AND material != '') as with_material,
+ MIN(last_scraped) as oldest_scrape,
+ MAX(last_scraped) as newest_scrape,
+ COUNT(*) FILTER (WHERE last_scraped IS NULL) as never_scraped
+ FROM ${table}
+ `);
+ catalogStats = stats.rows[0];
+ }
+ // Build context for response
+ const context = {
+ vendor: v.vendor_name,
+ code: v.vendor_code,
+ agent: v.agent_name,
+ port: v.agent_port,
+ catalog_table: table,
+ catalog_count: v.catalog_count,
+ shopify_count: v.shopify_count,
+ not_on_shopify: v.catalog_not_on_shopify,
+ website_score: v.website_score,
+ website_url: v.website_url,
+ has_credentials: v.has_credentials,
+ crawl_priority: v.crawl_priority,
+ last_product_update: v.last_product_update,
+ learnings: v.learnings,
+ catalogStats
+ };
+ // Try to proxy to agent first
+ if (v.agent_port) {
+ try {
+ const agentResp = await fetch(`http://127.0.0.1:${v.agent_port}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64') },
+ body: JSON.stringify({ message, context }),
+ signal: AbortSignal.timeout(5000)
+ });
+ if (agentResp.ok) {
+ const agentData = await agentResp.json();
+ return res.json({ success: true, source: 'agent', response: agentData.response || agentData.message, context });
+ }
+ } catch (e) { /* Agent doesn't have chat — fall through to data-driven response */ }
+ }
+ // Data-driven response
+ let answer = `${v.vendor_name} (${v.vendor_code}) — Agent: ${v.agent_name || '?'} on port ${v.agent_port || '?'}\n`;
+ if (catalogStats) {
+ answer += `\nCatalog: ${catalogStats.total} products in ${table}\n`;
+ answer += `• With images: ${catalogStats.with_images}\n`;
+ answer += `• With width: ${catalogStats.with_width}\n`;
+ answer += `• With repeat: ${catalogStats.with_repeat}\n`;
+ answer += `• With price: ${catalogStats.with_price}\n`;
+ answer += `• With material: ${catalogStats.with_material}\n`;
+ answer += `• On Shopify: ${catalogStats.on_shopify}\n`;
+ answer += `• Discontinued: ${catalogStats.discontinued}\n`;
+ answer += `• Never scraped: ${catalogStats.never_scraped}\n`;
+ answer += `\nLast scrape: ${catalogStats.newest_scrape ? new Date(catalogStats.newest_scrape).toLocaleDateString() : 'never'}\n`;
+ }
+ answer += `\nShopify: ${v.shopify_count || 0} listed | Not listed: ${v.catalog_not_on_shopify || 0}`;
+ if (v.learnings) answer += `\n\nNotes: ${v.learnings}`;
+ res.json({ success: true, source: 'data', response: answer, context });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Trigger sync ---
+app.post('/api/sync', async (req, res) => {
+ logActivity('api', 'Manual sync triggered via API');
+ const result = await syncVendorCounts();
+ res.json(result);
+});
+
+// --- API: Live Activity Feed ---
+app.get('/api/activity', (req, res) => {
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
+ const since = req.query.since || null;
+ let entries = activityLog;
+ if (since) {
+ entries = entries.filter(e => e.ts > since);
+ }
+ res.json({ entries: entries.slice(0, limit), total: activityLog.length });
+});
+
+// --- API: Products list for a vendor (from shopify_products) ---
+app.get('/api/vendors/:code/products', async (req, res) => {
+ try {
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT vendor_name, vendor_code FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!vendorRows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+
+ const patterns = VENDOR_NAME_MAP[req.params.code];
+ if (!patterns || patterns.length === 0) {
+ return res.json({ success: true, data: [], count: 0, message: 'No Shopify vendor mapping configured' });
+ }
+
+ const limit = Math.min(parseInt(req.query.limit) || 100, 500);
+ const offset = parseInt(req.query.offset) || 0;
+ const search = req.query.search || '';
+
+ const conditions = patterns.map((_, i) => `vendor ILIKE $${i + 1}`).join(' OR ');
+ const values = [...patterns];
+ let whereExtra = '';
+ if (search) {
+ values.push('%' + search + '%');
+ whereExtra = ` AND (title ILIKE $${values.length} OR sku ILIKE $${values.length} OR dw_sku ILIKE $${values.length})`;
+ }
+ values.push(limit, offset);
+
+ const query = `
+ SELECT shopify_id, title, vendor, sku, dw_sku, mfr_sku, image_url, cost_price, retail_price, price, status, tags
+ FROM shopify_products
+ WHERE (${conditions})${whereExtra}
+ ORDER BY title ASC
+ LIMIT $${values.length - 1} OFFSET $${values.length}
+ `;
+
+ const countQuery = `SELECT COUNT(DISTINCT shopify_id) as cnt FROM shopify_products WHERE (${conditions})${whereExtra}`;
+ const countValues = values.slice(0, values.length - 2);
+
+ const [dataResult, countResult] = await Promise.all([
+ pool.query(query, values),
+ pool.query(countQuery, countValues)
+ ]);
+
+ res.json({
+ success: true,
+ vendor: vendorRows[0].vendor_name,
+ data: dataResult.rows,
+ count: parseInt(countResult.rows[0].cnt),
+ limit,
+ offset
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Catalog Browse API (infinite scroll + phase progress) ---
+app.get('/api/catalog-browse', async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
+ const offset = parseInt(req.query.offset) || 0;
+ const vendor = req.query.vendor || '';
+ const search = req.query.search || '';
+
+ const conditions = [];
+ const values = [];
+ let idx = 1;
+
+ if (vendor) {
+ conditions.push(`vc.vendor_code = $${idx++}`);
+ values.push(vendor);
+ }
+ if (search) {
+ conditions.push(`(vc.dw_sku ILIKE $${idx} OR vc.mfr_sku ILIKE $${idx} OR vc.pattern_name ILIKE $${idx})`);
+ values.push('%' + search + '%');
+ idx++;
+ }
+
+ // Optional brand-flag filters (apply via JOIN on vendor_registry)
+ if (req.query.pl === '1') conditions.push(`vr.is_private_label = true`);
+ if (req.query.brand === '1') conditions.push(`vr.show_on_brands_page = true`);
+
+ const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
+
+ const dataQuery = `
+ SELECT vc.dw_sku, vc.mfr_sku, vc.pattern_name, vc.color_name, vc.vendor_code,
+ vc.image_url, vc.on_shopify,
+ vr.is_private_label, vr.show_on_brands_page, vr.private_label_name, vr.vendor_name,
+ et.phase1_scraped_at IS NOT NULL AS p1,
+ et.phase2_specs_at IS NOT NULL AS p2,
+ et.phase3_ai_at IS NOT NULL AS p3,
+ et.phase4_silas_at IS NOT NULL AS p4,
+ et.phase5_vcc_at IS NOT NULL AS p5,
+ et.phase6_rooms_at IS NOT NULL AS p6,
+ et.phase7_spin_at IS NOT NULL AS p7,
+ et.phase8_shopify_at IS NOT NULL AS p8,
+ (CASE WHEN et.phase1_scraped_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase2_specs_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase3_ai_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase4_silas_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase5_vcc_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase6_rooms_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase7_spin_at IS NOT NULL THEN 1 ELSE 0 END +
+ CASE WHEN et.phase8_shopify_at IS NOT NULL THEN 1 ELSE 0 END) AS phases_done
+ FROM vendor_catalog vc
+ LEFT JOIN enrichment_tracking et ON et.vendor_code = vc.vendor_code AND et.mfr_sku = vc.mfr_sku
+ LEFT JOIN vendor_registry vr ON vr.vendor_code = vc.vendor_code
+ ${where}
+ ORDER BY vc.vendor_code, vc.dw_sku
+ LIMIT $${idx++} OFFSET $${idx++}
+ `;
+ values.push(limit, offset);
+
+ const countQuery = `
+ SELECT COUNT(*) AS cnt
+ FROM vendor_catalog vc
+ LEFT JOIN vendor_registry vr ON vr.vendor_code = vc.vendor_code
+ ${where}
+ `;
+ const countValues = values.slice(0, values.length - 2);
+
+ const [dataResult, countResult] = await Promise.all([
+ pool.query(dataQuery, values),
+ pool.query(countQuery, countValues)
+ ]);
+
+ res.json({
+ success: true,
+ data: dataResult.rows,
+ total: parseInt(countResult.rows[0].cnt),
+ limit,
+ offset
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+app.get('/api/catalog-browse/vendors', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT vendor_code, COUNT(*) AS product_count
+ FROM vendor_catalog
+ GROUP BY vendor_code
+ ORDER BY product_count DESC
+ `);
+ res.json({ success: true, data: rows });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// === [ADMIN MODAL] Catalog product detail / cover / collections (added 2026-05-05) ===
+const VCC_SHOP_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const VCC_SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const VCC_SHOP_API = 'https://' + VCC_SHOP_DOMAIN + '/admin/api/2024-10';
+
+// In-memory 5-min cache for collection list
+let _vccCollectionsCache = { ts: 0, data: null };
+
+function _vccNumericId(gidOrNum) {
+ if (!gidOrNum) return null;
+ const s = String(gidOrNum);
+ const m = s.match(/(\d+)$/);
+ return m ? m[1] : null;
+}
+
+async function _vccShopifyFetch(path, opts) {
+ opts = opts || {};
+ opts.headers = Object.assign({
+ 'X-Shopify-Access-Token': VCC_SHOP_TOKEN,
+ 'Content-Type': 'application/json'
+ }, opts.headers || {});
+ const r = await fetch(VCC_SHOP_API + path, opts);
+ const text = await r.text();
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = { raw: text }; }
+ return { ok: r.ok, status: r.status, body };
+}
+
+// Resolve a vendor_catalog row (vendor_code + mfr_sku) to a numeric Shopify product id.
+// We accept either the numeric id directly (preferred) OR a "vendor:mfr_sku" pair as fallback.
+async function _vccResolveProductId(idParam, vendor, mfrSku) {
+ const direct = _vccNumericId(idParam);
+ if (direct && /^\d{6,}$/.test(direct)) return direct;
+ if (vendor && mfrSku) {
+ const { rows } = await pool.query(
+ `SELECT shopify_id FROM shopify_products
+ WHERE LOWER(mfr_sku) = LOWER($1) AND vendor ILIKE $2 AND status='ACTIVE'
+ ORDER BY id DESC LIMIT 1`,
+ [mfrSku, vendor]
+ );
+ if (rows.length) return _vccNumericId(rows[0].shopify_id);
+ }
+ return null;
+}
+
+// GET /api/catalog-browse/product/:id
+// :id may be a numeric Shopify product id, or query ?vendor=&mfr_sku= as fallback lookup.
+app.get('/api/catalog-browse/product/:id', async (req, res) => {
+ try {
+ const pid = await _vccResolveProductId(req.params.id, req.query.vendor, req.query.mfr_sku);
+ if (!pid) return res.status(404).json({ success: false, error: 'Product not found in shopify_products' });
+
+ const [prod, imgs, collects] = await Promise.all([
+ _vccShopifyFetch('/products/' + pid + '.json?fields=id,title,handle,vendor,status,image'),
+ _vccShopifyFetch('/products/' + pid + '/images.json'),
+ _vccShopifyFetch('/collects.json?product_id=' + pid + '&limit=250&fields=id,collection_id')
+ ]);
+ if (!prod.ok) return res.status(prod.status).json({ success: false, error: 'Shopify product fetch failed', detail: prod.body });
+
+ const product = prod.body && prod.body.product;
+ const images = (imgs.body && imgs.body.images) || [];
+ const collectsArr = (collects.body && collects.body.collects) || [];
+
+ res.json({
+ success: true,
+ product: {
+ id: product.id,
+ title: product.title,
+ handle: product.handle,
+ vendor: product.vendor,
+ status: product.status,
+ cover_image_id: product.image ? product.image.id : null
+ },
+ images: images.map(i => ({ id: i.id, src: i.src, position: i.position, alt: i.alt, width: i.width, height: i.height })),
+ collection_ids: collectsArr.map(c => c.collection_id),
+ collects: collectsArr
+ });
+ } catch (err) {
+ console.error('[vcc product detail]', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// POST /api/catalog-browse/product/:id/cover-photo
+// body: { image_id } -> set that image to position 1 (REST PUT /images/:id)
+// OR { upload: <base64>, filename, alt } -> upload as new image at position 1
+app.post('/api/catalog-browse/product/:id/cover-photo', async (req, res) => {
+ try {
+ const pid = await _vccResolveProductId(req.params.id, req.body.vendor, req.body.mfr_sku);
+ if (!pid) return res.status(404).json({ success: false, error: 'Product not found' });
+ const { image_id, upload, filename, alt } = req.body || {};
+
+ if (upload) {
+ // Strip data: URL prefix if present
+ const b64 = String(upload).replace(/^data:image\/[a-zA-Z+]+;base64,/, '');
+ const r = await _vccShopifyFetch('/products/' + pid + '/images.json', {
+ method: 'POST',
+ body: JSON.stringify({ image: { attachment: b64, filename: filename || 'cover.jpg', position: 1, alt: alt || '' } })
+ });
+ if (!r.ok) return res.status(r.status).json({ success: false, error: 'Image upload failed', detail: r.body });
+ return res.json({ success: true, action: 'uploaded', image: r.body.image });
+ }
+
+ if (!image_id) return res.status(400).json({ success: false, error: 'image_id or upload required' });
+ const r = await _vccShopifyFetch('/products/' + pid + '/images/' + image_id + '.json', {
+ method: 'PUT',
+ body: JSON.stringify({ image: { id: Number(image_id), position: 1 } })
+ });
+ if (!r.ok) return res.status(r.status).json({ success: false, error: 'Reorder failed', detail: r.body });
+ res.json({ success: true, action: 'promoted', image: r.body.image });
+ } catch (err) {
+ console.error('[vcc cover-photo]', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// GET /api/shopify/collections (5-min cache)
+// Returns merged custom + smart collections: [{id, title, handle, type}]
+app.get('/api/shopify/collections', async (req, res) => {
+ try {
+ const now = Date.now();
+ if (_vccCollectionsCache.data && (now - _vccCollectionsCache.ts) < 5 * 60 * 1000) {
+ return res.json({ success: true, cached: true, count: _vccCollectionsCache.data.length, data: _vccCollectionsCache.data });
+ }
+ async function pageAll(kind) {
+ const out = [];
+ let url = '/' + kind + '.json?limit=250&fields=id,title,handle';
+ // Shopify REST cursor pagination via Link header — fetch full URL when present
+ let nextFull = null;
+ while (true) {
+ const r = nextFull
+ ? await fetch(nextFull, { headers: { 'X-Shopify-Access-Token': VCC_SHOP_TOKEN } })
+ : await fetch(VCC_SHOP_API + url, { headers: { 'X-Shopify-Access-Token': VCC_SHOP_TOKEN } });
+ if (!r.ok) break;
+ const j = await r.json();
+ const arr = j[kind] || [];
+ for (const c of arr) out.push({ id: c.id, title: c.title, handle: c.handle, type: kind === 'custom_collections' ? 'custom' : 'smart' });
+ const link = r.headers.get('link') || r.headers.get('Link');
+ if (!link) break;
+ const m = link.match(/<([^>]+)>;\s*rel="next"/);
+ if (!m) break;
+ nextFull = m[1];
+ }
+ return out;
+ }
+ const [custom, smart] = await Promise.all([pageAll('custom_collections'), pageAll('smart_collections')]);
+ const all = custom.concat(smart).sort((a, b) => (a.title || '').localeCompare(b.title || ''));
+ _vccCollectionsCache = { ts: now, data: all };
+ res.json({ success: true, cached: false, count: all.length, data: all });
+ } catch (err) {
+ console.error('[vcc collections list]', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// POST /api/catalog-browse/product/:id/collections
+// body: { add: [collection_id...], remove: [collection_id...] }
+// Smart collections cannot be modified via Collect API — those are silently skipped & reported.
+app.post('/api/catalog-browse/product/:id/collections', async (req, res) => {
+ try {
+ const pid = await _vccResolveProductId(req.params.id, req.body.vendor, req.body.mfr_sku);
+ if (!pid) return res.status(404).json({ success: false, error: 'Product not found' });
+ const add = Array.isArray(req.body.add) ? req.body.add : [];
+ const remove = Array.isArray(req.body.remove) ? req.body.remove : [];
+
+ const results = { added: [], removed: [], skipped_smart: [], errors: [] };
+
+ // Build smart-collection set so we can skip them (Collect API only works on custom_collections)
+ const colList = (_vccCollectionsCache.data) || [];
+ const smartIds = new Set(colList.filter(c => c.type === 'smart').map(c => String(c.id)));
+
+ for (const cid of add) {
+ if (smartIds.has(String(cid))) { results.skipped_smart.push(cid); continue; }
+ const r = await _vccShopifyFetch('/collects.json', {
+ method: 'POST',
+ body: JSON.stringify({ collect: { product_id: Number(pid), collection_id: Number(cid) } })
+ });
+ if (r.ok) results.added.push(cid);
+ else results.errors.push({ op: 'add', collection_id: cid, status: r.status, detail: r.body });
+ }
+
+ if (remove.length) {
+ // Need collect_id per (product, collection) to DELETE
+ const lookup = await _vccShopifyFetch('/collects.json?product_id=' + pid + '&limit=250&fields=id,collection_id');
+ const map = {};
+ ((lookup.body && lookup.body.collects) || []).forEach(c => { map[String(c.collection_id)] = c.id; });
+ for (const cid of remove) {
+ if (smartIds.has(String(cid))) { results.skipped_smart.push(cid); continue; }
+ const collectId = map[String(cid)];
+ if (!collectId) { results.errors.push({ op: 'remove', collection_id: cid, error: 'not currently a member' }); continue; }
+ const r = await _vccShopifyFetch('/collects/' + collectId + '.json', { method: 'DELETE' });
+ if (r.ok || r.status === 404) results.removed.push(cid);
+ else results.errors.push({ op: 'remove', collection_id: cid, status: r.status, detail: r.body });
+ }
+ }
+
+ res.json({ success: true, product_id: pid, ...results });
+ } catch (err) {
+ console.error('[vcc collections update]', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+// === [/ADMIN MODAL] ===
+
+// Serve catalog browse page
+app.get('/catalog-browse', (req, res) => {
+ res.sendFile(__dirname + '/public/catalog-browse.html');
+});
+
+// Run sync on startup (after 5s delay to let DB connect) and every 5 minutes
+setTimeout(() => syncVendorCounts(), 5000);
+setInterval(() => syncVendorCounts(), 300000);
+
+// --- Utility: Escape HTML to prevent XSS ---
+function escapeHtml(str) {
+ if (!str) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+// --- API: Score a single vendor website ---
+app.post('/api/vendors/:code/score', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, website_url FROM vendor_registry WHERE vendor_code = $1',
+ [req.params.code]
+ );
+ if (!rows.length) return res.status(404).json({ success: false, error: 'Vendor not found' });
+ const v = rows[0];
+ if (!v.website_url) return res.json({ success: false, error: 'No website URL configured' });
+
+ console.log(`[Victor] Scoring website for ${v.vendor_name}: ${v.website_url}`);
+ const result = await scoreWebsite(v.website_url);
+
+ await pool.query(
+ `UPDATE vendor_registry SET website_score = $1, website_summary = $2, website_tech_stack = $3, website_scored_at = NOW() WHERE vendor_code = $4`,
+ [result.score, result.summary, result.tech_stack || '', v.vendor_code]
+ );
+
+ res.json({ success: true, data: { vendor_code: v.vendor_code, ...result } });
+ } catch (err) {
+ console.error(`[Victor] Score error:`, err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Score all vendor websites (batch, throttled) ---
+app.post('/api/score-all', async (req, res) => {
+ try {
+ const staleOnly = req.body.stale_only !== false; // default: only score stale/unscored
+ let query = 'SELECT vendor_code, vendor_name, website_url FROM vendor_registry WHERE website_url IS NOT NULL';
+ if (staleOnly) {
+ query += ` AND (website_scored_at IS NULL OR website_scored_at < NOW() - INTERVAL '7 days')`;
+ }
+ query += ' ORDER BY website_score ASC NULLS FIRST';
+ const { rows } = await pool.query(query);
+
+ res.json({ success: true, message: `Scoring ${rows.length} vendors in background`, count: rows.length });
+
+ // Background scoring with 2s delay between requests
+ (async () => {
+ let scored = 0;
+ for (const v of rows) {
+ try {
+ const result = await scoreWebsite(v.website_url);
+ await pool.query(
+ `UPDATE vendor_registry SET website_score = $1, website_summary = $2, website_tech_stack = $3, website_scored_at = NOW() WHERE vendor_code = $4`,
+ [result.score, result.summary, result.tech_stack || '', v.vendor_code]
+ );
+ scored++;
+ console.log(`[Victor] Scored ${v.vendor_name}: ${result.score}/10 (${scored}/${rows.length})`);
+ } catch (err) {
+ console.log(`[Victor] Score failed for ${v.vendor_name}: ${err.message}`);
+ }
+ // Throttle: 2s between requests to avoid rate limits
+ await new Promise(r => setTimeout(r, 2000));
+ }
+ console.log(`[Victor] Batch scoring complete: ${scored}/${rows.length} vendors scored`);
+ })();
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// ============================================================================
+// DATA QUALITY (Strategy 3) - products table + catalog audit
+// ============================================================================
+
+// --- API: Data quality summary for products table ---
+app.get('/api/data-quality', async (req, res) => {
+ try {
+ // Products table quality
+ const pq = await pool.query(`
+ SELECT
+ COUNT(*) as total,
+ COUNT(CASE WHEN image_urls IS NOT NULL AND array_length(image_urls, 1) > 0 THEN 1 END) as has_images,
+ COUNT(CASE WHEN width_in IS NOT NULL AND width_in > 0 THEN 1 END) as has_width,
+ COUNT(CASE WHEN material IS NOT NULL AND material != '' THEN 1 END) as has_material,
+ COUNT(CASE WHEN repeat_vertical_in IS NOT NULL THEN 1 END) as has_repeat_v,
+ COUNT(CASE WHEN repeat_horizontal_in IS NOT NULL THEN 1 END) as has_repeat_h,
+ COUNT(CASE WHEN match_type IS NOT NULL AND match_type != '' THEN 1 END) as has_match,
+ COUNT(CASE WHEN content IS NOT NULL AND content != '' THEN 1 END) as has_content,
+ COUNT(CASE WHEN price IS NOT NULL AND price > 0 THEN 1 END) as has_price,
+ COUNT(CASE WHEN backing IS NOT NULL AND backing != '' THEN 1 END) as has_backing,
+ COUNT(CASE WHEN fire_rating IS NOT NULL AND fire_rating != '' THEN 1 END) as has_fire_rating,
+ COUNT(CASE WHEN thumbnail_url IS NOT NULL AND thumbnail_url != '' THEN 1 END) as has_thumbnail
+ FROM products
+ `);
+
+ // Per-vendor breakdown
+ const vq = await pool.query(`
+ SELECT
+ vendor_id,
+ COUNT(*) as total,
+ COUNT(CASE WHEN image_urls IS NOT NULL AND array_length(image_urls, 1) > 0 THEN 1 END) as has_images,
+ COUNT(CASE WHEN width_in IS NOT NULL AND width_in > 0 THEN 1 END) as has_width,
+ COUNT(CASE WHEN material IS NOT NULL AND material != '' THEN 1 END) as has_material,
+ COUNT(CASE WHEN content IS NOT NULL AND content != '' THEN 1 END) as has_content,
+ COUNT(CASE WHEN price IS NOT NULL AND price > 0 THEN 1 END) as has_price,
+ COUNT(CASE WHEN repeat_vertical_in IS NOT NULL THEN 1 END) as has_repeat_v
+ FROM products
+ GROUP BY vendor_id
+ ORDER BY total DESC
+ `);
+
+ // Open issues count
+ const iq = await pool.query(`
+ SELECT COUNT(*) as open_issues FROM data_quality_issues WHERE resolved = false
+ `);
+
+ res.json({
+ success: true,
+ summary: pq.rows[0],
+ vendors: vq.rows,
+ open_issues: parseInt(iq.rows[0].open_issues),
+ ts: new Date().toISOString()
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Run data quality audit (populate data_quality_issues) ---
+app.post('/api/data-quality/audit', async (req, res) => {
+ try {
+ const client = await pool.connect();
+ let created = 0;
+
+ // Find all products missing critical fields
+ const { rows } = await client.query(`
+ SELECT id, vendor_id, sku, name,
+ (image_urls IS NULL OR array_length(image_urls, 1) IS NULL) as missing_image,
+ (width_in IS NULL OR width_in = 0) as missing_width,
+ (material IS NULL OR material = '') as missing_material,
+ (content IS NULL OR content = '') as missing_content,
+ (price IS NULL OR price = 0) as missing_price,
+ (repeat_vertical_in IS NULL) as missing_repeat
+ FROM products
+ `);
+
+ // Clear old unresolved issues
+ await client.query(`DELETE FROM data_quality_issues WHERE resolved = false`);
+
+ for (const p of rows) {
+ const issues = [];
+ if (p.missing_image) issues.push({ type: 'missing_image', severity: 'critical' });
+ if (p.missing_width) issues.push({ type: 'missing_width', severity: 'high' });
+ if (p.missing_material) issues.push({ type: 'missing_material', severity: 'medium' });
+ if (p.missing_content) issues.push({ type: 'missing_content', severity: 'medium' });
+ if (p.missing_price) issues.push({ type: 'missing_price', severity: 'high' });
+ if (p.missing_repeat) issues.push({ type: 'missing_repeat', severity: 'low' });
+
+ for (const issue of issues) {
+ await client.query(
+ `INSERT INTO data_quality_issues (issue_type, severity, vendor_id, product_id, details)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [issue.type, issue.severity, p.vendor_id, p.id, JSON.stringify({ sku: p.sku, name: p.name })]
+ );
+ created++;
+ }
+ }
+
+ client.release();
+ logActivity('data-quality', `Audit complete: ${created} issues found across ${rows.length} products`);
+ res.json({ success: true, issues_created: created, products_audited: rows.length });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Data quality issues list ---
+app.get('/api/data-quality/issues', async (req, res) => {
+ try {
+ const vendor = req.query.vendor || null;
+ const severity = req.query.severity || null;
+ let query = `SELECT issue_type, severity, vendor_id, COUNT(*) as cnt
+ FROM data_quality_issues WHERE resolved = false`;
+ const params = [];
+ if (vendor) { params.push(vendor); query += ` AND vendor_id = $${params.length}`; }
+ if (severity) { params.push(severity); query += ` AND severity = $${params.length}`; }
+ query += ` GROUP BY issue_type, severity, vendor_id ORDER BY cnt DESC`;
+ const { rows } = await pool.query(query, params);
+ res.json({ success: true, issues: rows });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Catalog-level data quality (for VCC main dashboard) ---
+app.get('/api/data-quality/catalog', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ vendor_code,
+ vendor_name,
+ catalog_count,
+ products_with_images,
+ catalog_with_width,
+ catalog_with_repeat,
+ products_with_cost,
+ spec_completeness,
+ CASE WHEN catalog_count > 0 THEN ROUND(products_with_images::numeric / catalog_count * 100, 1) ELSE 0 END as image_pct,
+ CASE WHEN catalog_count > 0 THEN ROUND(catalog_with_width::numeric / catalog_count * 100, 1) ELSE 0 END as width_pct,
+ CASE WHEN catalog_count > 0 THEN ROUND(catalog_with_repeat::numeric / catalog_count * 100, 1) ELSE 0 END as repeat_pct,
+ CASE WHEN catalog_count > 0 THEN ROUND(products_with_cost::numeric / catalog_count * 100, 1) ELSE 0 END as cost_pct
+ FROM vendor_registry
+ WHERE catalog_count > 0
+ ORDER BY catalog_count DESC
+ `);
+ res.json({ success: true, vendors: result.rows });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: Slack alert for data quality drops ---
+const SLACK_WEBHOOK = 'https://hooks.slack.com/services/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';
+
+app.post('/api/data-quality/slack-alert', async (req, res) => {
+ try {
+ const threshold = parseInt(req.body.threshold) || 80;
+ const result = await pool.query(`
+ SELECT vendor_code, vendor_name, catalog_count,
+ CASE WHEN catalog_count > 0 THEN ROUND(products_with_images::numeric / catalog_count * 100, 1) ELSE 0 END as image_pct,
+ CASE WHEN catalog_count > 0 THEN ROUND(catalog_with_width::numeric / catalog_count * 100, 1) ELSE 0 END as width_pct,
+ CASE WHEN catalog_count > 0 THEN ROUND(catalog_with_repeat::numeric / catalog_count * 100, 1) ELSE 0 END as repeat_pct,
+ spec_completeness
+ FROM vendor_registry
+ WHERE catalog_count > 50
+ ORDER BY spec_completeness ASC NULLS FIRST
+ `);
+
+ const flagged = result.rows.filter(v => {
+ const img = parseFloat(v.image_pct) || 0;
+ const width = parseFloat(v.width_pct) || 0;
+ const spec = parseFloat(v.spec_completeness) || 0;
+ return img < threshold || width < threshold || spec < threshold;
+ });
+
+ if (flagged.length === 0) {
+ return res.json({ success: true, message: 'All vendors above threshold', flagged: 0 });
+ }
+
+ const lines = flagged.slice(0, 15).map(v =>
+ `• *${v.vendor_name}* (${Number(v.catalog_count).toLocaleString()}) — Img: ${v.image_pct}% | Width: ${v.width_pct}% | Spec: ${v.spec_completeness}%`
+ );
+
+ const slackMsg = {
+ text: `:warning: *Data Quality Alert* — ${flagged.length} vendors below ${threshold}% threshold\n\n${lines.join('\n')}${flagged.length > 15 ? `\n...and ${flagged.length - 15} more` : ''}\n\n<http://45.61.58.125:9660/|View in VCC>`
+ };
+
+ await fetch(SLACK_WEBHOOK, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(slackMsg)
+ });
+
+ logActivity('data-quality', `Slack alert sent: ${flagged.length} vendors below ${threshold}%`);
+ res.json({ success: true, flagged: flagged.length, threshold });
+ } catch (err) {
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Discontinued Products API ---
+app.get('/api/discontinued', async (req, res) => {
+ try {
+ const { vendor, page = 1, limit = 50, status: filterStatus } = req.query;
+ const offset = (parseInt(page) - 1) * parseInt(limit);
+
+ // Summary stats
+ const summaryQ = await pool.query(`
+ SELECT
+ COUNT(*) as total_discontinued,
+ COUNT(DISTINCT d.vendor_name) as vendor_count,
+ COUNT(sp.id) as matched_shopify,
+ COUNT(CASE WHEN sp.status = 'active' THEN 1 END) as still_active,
+ COUNT(CASE WHEN sp.status = 'archived' THEN 1 END) as already_archived,
+ COUNT(CASE WHEN sp.status = 'draft' THEN 1 END) as in_draft,
+ COUNT(CASE WHEN sp.id IS NULL THEN 1 END) as not_on_shopify
+ FROM discontinued_skus d
+ LEFT JOIN shopify_products sp ON d.dw_sku = sp.dw_sku
+ `);
+
+ // Vendor breakdown
+ const vendorQ = await pool.query(`
+ SELECT
+ COALESCE(NULLIF(d.vendor_name,''), 'Unknown') as vendor_name,
+ COUNT(*) as total,
+ COUNT(sp.id) as on_shopify,
+ COUNT(CASE WHEN sp.status = 'active' THEN 1 END) as active,
+ COUNT(CASE WHEN sp.status = 'archived' THEN 1 END) as archived,
+ COUNT(CASE WHEN sp.status = 'draft' THEN 1 END) as draft
+ FROM discontinued_skus d
+ LEFT JOIN shopify_products sp ON d.dw_sku = sp.dw_sku
+ GROUP BY COALESCE(NULLIF(d.vendor_name,''), 'Unknown')
+ ORDER BY COUNT(*) DESC
+ `);
+
+ // Product list (paginated, filterable)
+ let whereClause = '';
+ const params = [];
+ if (vendor && vendor !== 'all') {
+ params.push(vendor);
+ whereClause += ` AND COALESCE(NULLIF(d.vendor_name,''), 'Unknown') = $${params.length}`;
+ }
+ if (filterStatus === 'active') {
+ whereClause += ` AND sp.status = 'active'`;
+ } else if (filterStatus === 'archived') {
+ whereClause += ` AND sp.status = 'archived'`;
+ } else if (filterStatus === 'not_on_shopify') {
+ whereClause += ` AND sp.id IS NULL`;
+ }
+
+ params.push(parseInt(limit), offset);
+ const productsQ = await pool.query(`
+ SELECT
+ d.id, d.dw_handle, d.dw_sku, d.mfr_sku, d.vendor_code, d.vendor_name,
+ d.archive_tag, d.archived_date, d.notes,
+ sp.shopify_id, sp.title, sp.status as shopify_status, sp.image_url, sp.vendor as shopify_vendor
+ FROM discontinued_skus d
+ LEFT JOIN shopify_products sp ON d.dw_sku = sp.dw_sku
+ WHERE 1=1 ${whereClause}
+ ORDER BY d.vendor_name ASC, d.dw_sku ASC
+ LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params);
+
+ const countParams = params.slice(0, -2);
+ const countQ = await pool.query(`
+ SELECT COUNT(*) as total
+ FROM discontinued_skus d
+ LEFT JOIN shopify_products sp ON d.dw_sku = sp.dw_sku
+ WHERE 1=1 ${whereClause}
+ `, countParams);
+
+ res.json({
+ summary: summaryQ.rows[0],
+ vendors: vendorQ.rows,
+ products: productsQ.rows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total: parseInt(countQ.rows[0].total),
+ pages: Math.ceil(parseInt(countQ.rows[0].total) / parseInt(limit))
+ }
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Line Manager: Proxy to Brewster/York catalog (9600) ---
+function proxyTo9600(method, apiPath, body) {
+ return new Promise((resolve, reject) => {
+ const bodyStr = body ? JSON.stringify(body) : '';
+ const authHeader = 'Basic ' + Buffer.from(process.env.GEORGE_BASIC_AUTH || '').toString('base64');
+ const options = {
+ hostname: '127.0.0.1', port: 9600, path: apiPath, method,
+ headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
+ timeout: 30000
+ };
+ if (bodyStr) options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
+ const req = http.request(options, (res) => {
+ let data = '';
+ res.on('data', c => data += c);
+ res.on('end', () => {
+ try { resolve(JSON.parse(data)); }
+ catch (e) { resolve({ raw: data }); }
+ });
+ });
+ req.on('error', (e) => reject(e));
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
+ if (bodyStr) req.write(bodyStr);
+ req.end();
+ });
+}
+
+app.get('/api/lm/lines', async (req, res) => {
+ try {
+ const data = await proxyTo9600('GET', '/api/lines');
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: 'Brewster catalog unavailable: ' + err.message }); }
+});
+
+app.post('/api/lm/push/start', async (req, res) => {
+ try {
+ const data = await proxyTo9600('POST', '/api/push/start', req.body);
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.get('/api/lm/push/status', async (req, res) => {
+ try {
+ const data = await proxyTo9600('GET', '/api/push/status');
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.post('/api/lm/push/stop', async (req, res) => {
+ try {
+ const data = await proxyTo9600('POST', '/api/push/stop');
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.get('/api/lm/push/cron', async (req, res) => {
+ try {
+ const data = await proxyTo9600('GET', '/api/push/cron');
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.post('/api/lm/push/cron', async (req, res) => {
+ try {
+ const data = await proxyTo9600('POST', '/api/push/cron', req.body);
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.delete('/api/lm/push/cron', async (req, res) => {
+ try {
+ const data = await proxyTo9600('DELETE', '/api/push/cron');
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+app.post('/api/lm/crawl/:brand', async (req, res) => {
+ try {
+ const data = await proxyTo9600('POST', '/api/crawl/' + encodeURIComponent(req.params.brand));
+ res.json(data);
+ } catch (err) { res.status(502).json({ success: false, error: err.message }); }
+});
+
+// ============================================================
+// TAG MANAGER ENDPOINTS
+// ============================================================
+
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
+
+// Comprehensive color word set from interior-design-tagger skill
+const TAG_COLOR_WORDS = new Set([
+ 'Basil','Beige','Black','Blue','Blush','Bronze','Brown','Burgundy','Buff',
+ 'Camel','Canary','Cerulean','Champagne','Charcoal','Cherry','Chestnut','Chocolate',
+ 'Cobalt','Copper','Coral','Cream','Crimson',
+ 'Ebony','Ecru','Emerald','Espresso',
+ 'Fawn','Forest','Fuchsia',
+ 'Gold','Golden','Graphite','Gray','Green','Grey','Gunmetal',
+ 'Honey','Hunter',
+ 'Indigo','Ivory',
+ 'Jet',
+ 'Kelly','Khaki',
+ 'Lavender','Lilac','Lime',
+ 'Magenta','Maroon','Mauve','Metallic','Mint','Mocha','Moss','Mustard',
+ 'Navy','Nude',
+ 'Ochre','Olive','Onyx','Orange','Orchid',
+ 'Peach','Pearl','Pewter','Pink','Platinum','Plum','Purple',
+ 'Red','Rose','Ruby','Rust',
+ 'Saffron','Sage','Salmon','Sand','Sapphire','Scarlet','Seafoam','Sienna','Silver','Sky','Slate',
+ 'Stone','Tan','Tangerine','Taupe','Teal','Terracotta','Thyme','Turquoise',
+ 'Umber','Vermillion','Violet',
+ 'White','Wine',
+ 'Yellow'
+]);
+
+const TAG_JUNK_PATTERNS = [
+ /^display_variant$/i,
+ /^LIGHT\s+\w+\/\w+$/,
+ /^Color-/i,
+ /^Color\s/i,
+ /^Background Color/i,
+ /Trending.*Collection.*20[12]\d/i,
+ /^[A-Z\s]{3,}\/[A-Z\s]{3,}$/
+];
+
+const PARENT_COLOR_MAP = {
+ 'Indigo':'Blue','Navy':'Blue','Cobalt':'Blue','Sky':'Blue','Teal':'Blue','Cerulean':'Blue','Aqua':'Blue','Azure':'Blue','Sapphire':'Blue',
+ 'Olive':'Green','Sage':'Green','Mint':'Green','Forest':'Green','Emerald':'Green','Seafoam':'Green','Lime':'Green','Kelly':'Green','Hunter':'Green',
+ 'Coral':'Pink','Salmon':'Pink','Blush':'Pink','Rose':'Pink','Fuchsia':'Pink',
+ 'Rust':'Orange','Terracotta':'Orange','Sienna':'Orange','Copper':'Orange','Amber':'Orange','Tangerine':'Orange','Apricot':'Orange',
+ 'Burgundy':'Red','Maroon':'Red','Wine':'Red','Crimson':'Red','Scarlet':'Red','Cherry':'Red','Ruby':'Red','Vermillion':'Red',
+ 'Lavender':'Purple','Plum':'Purple','Violet':'Purple','Mauve':'Purple','Amethyst':'Purple','Lilac':'Purple','Orchid':'Purple','Magenta':'Purple',
+ 'Gold':'Yellow','Mustard':'Yellow','Canary':'Yellow','Honey':'Yellow','Saffron':'Yellow',
+ 'Tan':'Beige','Khaki':'Beige','Sand':'Beige','Camel':'Beige','Taupe':'Beige','Buff':'Beige','Fawn':'Beige','Ecru':'Beige',
+ 'Charcoal':'Gray','Slate':'Gray','Pewter':'Gray','Ash':'Gray','Stone':'Gray','Graphite':'Gray','Gunmetal':'Gray',
+ 'Chocolate':'Brown','Espresso':'Brown','Mocha':'Brown','Chestnut':'Brown','Umber':'Brown',
+ 'Ivory':'White','Cream':'White','Pearl':'White','Bone':'White','Eggshell':'White',
+ 'Onyx':'Black','Ebony':'Black','Jet':'Black'
+};
+
+// In-memory state for background jobs
+let tagScanState = { running: false, scanned: 0, flagged: 0, total: 0, byVendor: {} };
+let tagApplyState = { running: false, applied: 0, errors: 0, total: 0, currentVendor: '' };
+let tagBatchState = { status: 'idle', batchId: null, analyzed: 0, retrying: 0, total: 0 };
+
+// GET /api/tags/stats
+app.get('/api/tags/stats', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT vendor,
+ COUNT(*) as total,
+ COUNT(*) FILTER (WHERE status='pending') as pending,
+ COUNT(*) FILTER (WHERE status='retry') as retry,
+ COUNT(*) FILTER (WHERE status='analyzed') as analyzed,
+ COUNT(*) FILTER (WHERE status='applied') as applied
+ FROM tag_analysis GROUP BY vendor ORDER BY vendor
+ `);
+ res.json({ vendors: rows });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// GET /api/tags/scan — start background scan
+app.get('/api/tags/scan', async (req, res) => {
+ if (tagScanState.running) return res.json({ message: 'Scan already running', ...tagScanState });
+ tagScanState = { running: true, scanned: 0, flagged: 0, total: 0, byVendor: {} };
+ res.json({ message: 'Scan started' });
+ logActivity('info', 'Tag Manager: Shopify scan started');
+
+ // Background scan
+ (async () => {
+ try {
+ let sinceId = 0;
+ while (true) {
+ await new Promise(r => setTimeout(r, 520));
+ const url = `https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products.json?limit=250&since_id=${sinceId}&fields=id,title,vendor,tags,status,images`;
+ const resp = await fetch(url, { headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN } });
+ const text = await resp.text();
+ let d;
+ try { d = JSON.parse(text); } catch(e) { break; }
+ if (!d.products || d.products.length === 0) break;
+
+ const values = [];
+ for (const p of d.products) {
+ if (p.status === 'archived') continue;
+ tagScanState.scanned++;
+ const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
+ const colorTags = tags.filter(t => TAG_COLOR_WORDS.has(t));
+ const junkTags = tags.filter(t => TAG_JUNK_PATTERNS.some(pat => pat.test(t)));
+ const isFlagged = colorTags.length > 5 || tags.length <= 1 || junkTags.length > 0;
+
+ if (isFlagged) {
+ tagScanState.flagged++;
+ if (!tagScanState.byVendor[p.vendor]) tagScanState.byVendor[p.vendor] = 0;
+ tagScanState.byVendor[p.vendor]++;
+ const imageUrl = (p.images && p.images[0]) ? p.images[0].src : '';
+ values.push([String(p.id), p.vendor, p.title, imageUrl, p.tags || '']);
+ }
+ }
+
+ // Batch upsert flagged products
+ for (const v of values) {
+ await pool.query(`
+ INSERT INTO tag_analysis (shopify_product_id, vendor, title, image_url, current_tags, status)
+ VALUES ($1, $2, $3, $4, $5, 'pending')
+ ON CONFLICT (shopify_product_id) DO UPDATE SET
+ vendor=EXCLUDED.vendor, title=EXCLUDED.title, image_url=EXCLUDED.image_url,
+ current_tags=EXCLUDED.current_tags
+ WHERE tag_analysis.status NOT IN ('analyzed','applied')
+ `, v);
+ }
+
+ sinceId = d.products[d.products.length - 1].id;
+ tagScanState.total = tagScanState.scanned;
+ if (d.products.length < 250) break;
+ }
+ logActivity('info', `Tag Manager: Scan complete. ${tagScanState.flagged} flagged of ${tagScanState.scanned} scanned`);
+ } catch (err) {
+ logActivity('error', 'Tag Manager scan error: ' + err.message);
+ }
+ tagScanState.running = false;
+ })();
+});
+
+// GET /api/tags/scan-status
+app.get('/api/tags/scan-status', (req, res) => {
+ res.json(tagScanState);
+});
+
+// POST /api/tags/batch-submit — Submit Gemini batch analysis
+app.post('/api/tags/batch-submit', async (req, res) => {
+ const { vendors, limit } = req.body || {};
+ if (!vendors || !vendors.length) return res.status(400).json({ error: 'vendors required' });
+ const maxLimit = limit || 5000;
+
+ try {
+ const { rows } = await pool.query(`
+ SELECT shopify_product_id, vendor, title, image_url, current_tags
+ FROM tag_analysis
+ WHERE status IN ('pending','retry') AND vendor = ANY($1)
+ ORDER BY created_at ASC LIMIT $2
+ `, [vendors, maxLimit]);
+
+ if (rows.length === 0) return res.json({ message: 'No products to analyze', productCount: 0 });
+
+ tagBatchState = { status: 'processing', batchId: null, analyzed: 0, retrying: 0, total: rows.length };
+ res.json({ message: 'Batch processing started', productCount: rows.length });
+ logActivity('info', `Tag Manager: Gemini batch started for ${rows.length} products`);
+
+ // Process products with real-time Gemini calls (batch API alternative)
+ // Using sync calls with rate limiting since Batch API requires file upload setup
+ (async () => {
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ try {
+ // Download image
+ let imageBase64 = null;
+ if (row.image_url) {
+ try {
+ const imgResp = await fetch(row.image_url);
+ if (imgResp.ok) {
+ const buf = Buffer.from(await imgResp.arrayBuffer());
+ imageBase64 = buf.toString('base64');
+ }
+ } catch(e) { /* skip image */ }
+ }
+
+ if (!imageBase64) {
+ await pool.query(`UPDATE tag_analysis SET status='retry', retry_count=retry_count+1 WHERE shopify_product_id=$1`, [row.shopify_product_id]);
+ tagBatchState.retrying++;
+ continue;
+ }
+
+ // Call Gemini
+ const geminiResp = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${GEMINI_API_KEY}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [
+ { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } },
+ { text: `Analyze this wallcovering/fabric image. Return ONLY valid JSON (no markdown, no backticks):
+{
+ "backgroundColor": "single dominant color word",
+ "relevantColors": ["max 3 colors ACTUALLY visible in image"],
+ "styles": ["1-2 from: Traditional, Contemporary, Art Deco, Mid-Century Modern, Victorian, Chinoiserie, Minimalist, Bohemian, Coastal, Tropical, Scandinavian, Industrial, Farmhouse, Transitional, Mediterranean, Arts & Crafts, Art Nouveau, Maximalist"],
+ "patterns": ["1-2 from: Damask, Floral, Geometric, Botanical, Toile, Trellis, Abstract, Stripe, Solid, Textured, Paisley, Medallion, Chevron, Herringbone, Ikat, Scenic, Tropical, Vine, Leaf, Ogee, Quatrefoil, Fretwork"],
+ "material": "one of: Vinyl, Paper, Non-woven, Grasscloth, Sisal, Silk, Fabric, Linen, Velvet, Metal Leaf, Foil"
+}` }
+ ]}],
+ generationConfig: { temperature: 0.1, maxOutputTokens: 500, thinkingConfig: { thinkingBudget: 0 } }
+ })
+ }
+ );
+
+ const geminiData = await geminiResp.json();
+ let aiResult = null;
+ try {
+ const textContent = geminiData.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ const cleaned = textContent.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
+ aiResult = JSON.parse(cleaned);
+ } catch(e) { /* parse failed */ }
+
+ if (!aiResult || !aiResult.backgroundColor) {
+ await pool.query(`UPDATE tag_analysis SET status='retry', retry_count=retry_count+1 WHERE shopify_product_id=$1`, [row.shopify_product_id]);
+ tagBatchState.retrying++;
+ continue;
+ }
+
+ // Build suggested tags
+ const existingTags = (row.current_tags || '').split(',').map(t => t.trim()).filter(Boolean);
+ const keepTags = existingTags.filter(t => {
+ if (TAG_COLOR_WORDS.has(t)) return false;
+ if (TAG_JUNK_PATTERNS.some(p => p.test(t))) return false;
+ if (/^Background Color/i.test(t)) return false;
+ if (/^Multi$/i.test(t)) return false;
+ return true;
+ });
+
+ // Preserve display_variant
+ const hadDisplayVariant = existingTags.some(t => t.toLowerCase() === 'display_variant');
+
+ const newTags = new Set(keepTags);
+ if (hadDisplayVariant) newTags.add('display_variant');
+
+ // Add background color
+ newTags.add('Background Color ' + aiResult.backgroundColor);
+
+ // Add AI colors with parent mapping
+ const parentFamilies = new Set();
+ const allColors = [aiResult.backgroundColor, ...(aiResult.relevantColors || [])];
+ for (const c of allColors) {
+ const cap = c.charAt(0).toUpperCase() + c.slice(1).toLowerCase();
+ newTags.add(cap);
+ const parent = PARENT_COLOR_MAP[cap];
+ if (parent) { newTags.add(parent); parentFamilies.add(parent); }
+ else { parentFamilies.add(cap); }
+ }
+ if (parentFamilies.size > 3) newTags.add('Multi');
+
+ // Add styles, patterns, material
+ for (const s of (aiResult.styles || [])) newTags.add(s);
+ for (const p of (aiResult.patterns || [])) newTags.add(p);
+ if (aiResult.material) {
+ newTags.add(aiResult.material);
+ const natMaterials = ['grasscloth','sisal','jute','raffia','hemp','seagrass','silk','linen','cotton','paper'];
+ if (!natMaterials.includes(aiResult.material.toLowerCase())) {
+ newTags.add('Class A Fire Rated');
+ }
+ }
+
+ const suggestedTags = [...newTags].join(', ');
+
+ await pool.query(`
+ UPDATE tag_analysis SET
+ ai_background_color=$2, ai_colors=$3, ai_styles=$4, ai_patterns=$5, ai_material=$6,
+ suggested_tags=$7, status='analyzed', analyzed_at=NOW()
+ WHERE shopify_product_id=$1
+ `, [
+ row.shopify_product_id,
+ aiResult.backgroundColor,
+ JSON.stringify(aiResult.relevantColors || []),
+ JSON.stringify(aiResult.styles || []),
+ JSON.stringify(aiResult.patterns || []),
+ aiResult.material || null,
+ suggestedTags
+ ]);
+ tagBatchState.analyzed++;
+
+ // Rate limit: ~1 req/s for Gemini
+ await new Promise(r => setTimeout(r, 1100));
+
+ } catch (err) {
+ await pool.query(`UPDATE tag_analysis SET status='retry', retry_count=retry_count+1 WHERE shopify_product_id=$1`, [row.shopify_product_id]).catch(() => {});
+ tagBatchState.retrying++;
+ }
+ }
+ tagBatchState.status = 'complete';
+ logActivity('info', `Tag Manager: Gemini batch complete. ${tagBatchState.analyzed} analyzed, ${tagBatchState.retrying} retrying`);
+ })();
+
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// GET /api/tags/batch-status
+app.get('/api/tags/batch-status', (req, res) => {
+ res.json(tagBatchState);
+});
+
+// GET /api/tags/preview
+app.get('/api/tags/preview', async (req, res) => {
+ const vendor = req.query.vendor;
+ const limit = parseInt(req.query.limit) || 50;
+ const offset = parseInt(req.query.offset) || 0;
+ if (!vendor) return res.status(400).json({ error: 'vendor required' });
+ try {
+ const { rows } = await pool.query(`
+ SELECT shopify_product_id as id, title, current_tags as "currentTags",
+ suggested_tags as "suggestedTags", ai_colors as "aiColors", ai_styles as "aiStyles", status
+ FROM tag_analysis WHERE vendor=$1 ORDER BY status DESC, created_at ASC LIMIT $2 OFFSET $3
+ `, [vendor, limit, offset]);
+ res.json({ products: rows });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// POST /api/tags/apply
+app.post('/api/tags/apply', async (req, res) => {
+ const { vendor, productIds } = req.body || {};
+ if (!vendor) return res.status(400).json({ error: 'vendor required' });
+ if (tagApplyState.running) return res.json({ message: 'Apply already running', ...tagApplyState });
+
+ let whereClause = `vendor=$1 AND status='analyzed'`;
+ const params = [vendor];
+ if (productIds && productIds.length > 0) {
+ whereClause += ` AND shopify_product_id = ANY($2)`;
+ params.push(productIds.map(String));
+ }
+
+ try {
+ const { rows } = await pool.query(`SELECT shopify_product_id, suggested_tags FROM tag_analysis WHERE ${whereClause}`, params);
+ tagApplyState = { running: true, applied: 0, errors: 0, total: rows.length, currentVendor: vendor };
+ res.json({ message: 'Applying tags', total: rows.length });
+ logActivity('info', `Tag Manager: Applying tags to ${rows.length} ${vendor} products`);
+
+ (async () => {
+ for (const row of rows) {
+ try {
+ await new Promise(r => setTimeout(r, 520));
+ const url = `https://${SHOPIFY_DOMAIN}/admin/api/2024-10/products/${row.shopify_product_id}.json`;
+ const resp = await fetch(url, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: parseInt(row.shopify_product_id), tags: row.suggested_tags } })
+ });
+ if (resp.status === 429) {
+ const ra = parseFloat(resp.headers.get('Retry-After') || '2');
+ await new Promise(r => setTimeout(r, ra * 1000));
+ // Retry once
+ await fetch(url, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: parseInt(row.shopify_product_id), tags: row.suggested_tags } })
+ });
+ }
+ await pool.query(`UPDATE tag_analysis SET status='applied', applied_at=NOW() WHERE shopify_product_id=$1`, [row.shopify_product_id]);
+ tagApplyState.applied++;
+ } catch (err) {
+ tagApplyState.errors++;
+ }
+ }
+ tagApplyState.running = false;
+ logActivity('info', `Tag Manager: Apply complete for ${vendor}. ${tagApplyState.applied} applied, ${tagApplyState.errors} errors`);
+ })();
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// GET /api/tags/apply-status
+app.get('/api/tags/apply-status', (req, res) => {
+ res.json(tagApplyState);
+});
+
+// =============================================================
+// PRODUCT STANDARDIZATION ENGINE API
+// =============================================================
+
+// GET /api/standardize/status — engine state
+app.get('/api/standardize/status', (req, res) => {
+ res.json(stdWorker.getEngineState());
+});
+
+// GET /api/standardize/progress — per-vendor progress
+app.get('/api/standardize/progress', async (req, res) => {
+ try {
+ const data = await stdWorker.getVendorProgress();
+ res.json(data);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// POST /api/standardize/start — start engine for a vendor
+app.post('/api/standardize/start', async (req, res) => {
+ const { vendor, analyzeOnly, limit } = req.body || {};
+ if (!vendor) return res.status(400).json({ error: 'vendor required' });
+ try {
+ const result = await stdWorker.runVendorStandardization(vendor, {
+ analyzeOnly: analyzeOnly || false,
+ limit: limit || 99999
+ });
+ res.json(result);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// POST /api/post-push-pipeline — Full post-push enrichment pipeline for a vendor
+// Runs: 1) AI enrichment (Gemini) → 2) Metafield push → 3) Body HTML rewrite → 4) Interior designer tags → 5) Architectural (if private label)
+app.post('/api/post-push-pipeline', async (req, res) => {
+ const { vendor_code, limit } = req.body || {};
+ if (!vendor_code) return res.status(400).json({ error: 'vendor_code required' });
+
+ const vc = vendor_code.toUpperCase();
+ const lim = limit || 99999;
+
+ // Check if vendor is private label (for architectural skill)
+ let isPrivateLabel = false;
+ try {
+ const { rows } = await pool.query('SELECT is_private_label FROM vendor_registry WHERE vendor_code = $1', [vc]);
+ isPrivateLabel = rows[0]?.is_private_label || false;
+ } catch (e) { /* ignore */ }
+
+ const { execFile } = require('child_process');
+ const scriptsDir = '/root/DW-Agents/scripts';
+
+ const runScript = (cmd, args) => new Promise((resolve) => {
+ execFile(cmd, args, { timeout: 600000, cwd: scriptsDir }, (err, stdout, stderr) => {
+ resolve({ success: !err, output: (stdout || '').slice(-500), error: err?.message || '' });
+ });
+ });
+
+ // Return immediately, run pipeline in background
+ res.json({ status: 'started', vendor_code: vc, is_private_label: isPrivateLabel, steps: [
+ 'ai-enrichment', 'metafield-push', 'body-html-rewrite', 'interior-design-tags',
+ ...(isPrivateLabel ? ['architectural-update'] : [])
+ ]});
+
+ console.log(`[VCC] Post-push pipeline started for ${vc} (private_label=${isPrivateLabel})`);
+
+ // Step 1: AI Enrichment (Gemini vision for colors, description)
+ console.log(`[VCC] [${vc}] Step 1/5: AI Enrichment...`);
+ await runScript('python3', [`${scriptsDir}/enrich-ai-fields.py`, '--vendor', vc, '--limit', String(lim)]);
+
+ // Step 2: Push all metafields to Shopify
+ console.log(`[VCC] [${vc}] Step 2/5: Metafield Push...`);
+ await runScript('python3', [`${scriptsDir}/push-metafields-vendor.py`, 'vendor_catalog', '--vendor', vc, '--force']);
+
+ // Step 3: Rewrite body_html (description only, mfr desc priority, no specs)
+ console.log(`[VCC] [${vc}] Step 3/5: Body HTML Rewrite...`);
+ await runScript('python3', [`${scriptsDir}/rewrite-body-html.py`, '--vendor', vc]);
+
+ // Step 4: Interior Designer Tags (ALWAYS runs — additive)
+ console.log(`[VCC] [${vc}] Step 4/5: Interior Designer Tags...`);
+ await runScript('python3', [`${scriptsDir}/tag-interior-design.py`, '--vendor', vc]);
+
+ // Step 5: Private Label cleanup (only if private label)
+ if (isPrivateLabel) {
+ console.log(`[VCC] [${vc}] Step 5/5: Private Label Fix (Caribbean names, our colors, metafields)...`);
+ await runScript('python3', [`${scriptsDir}/fix-pr-private-label.py`]);
+ }
+
+ console.log(`[VCC] Post-push pipeline COMPLETE for ${vc}`);
+});
+
+// POST /api/sync-metafield-namespaces — Copy global.*/dwc.* → custom.*/specs.* for a vendor's products
+app.post('/api/sync-metafield-namespaces', async (req, res) => {
+ const { vendor, limit } = req.body || {};
+ if (!vendor) return res.status(400).json({ error: 'vendor required (Shopify vendor name)' });
+ const lim = limit || 250;
+
+ res.json({ status: 'started', vendor, limit: lim });
+
+ const { syncNamespaces } = require('/root/DW-Agents/shared/metafield-map.js');
+
+ (async () => {
+ let synced = 0, skipped = 0, errors = 0;
+ let sinceId = 0;
+
+ while (true) {
+ try {
+ const pResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-01/products.json?vendor=${encodeURIComponent(vendor)}&status=active&limit=250&fields=id&since_id=${sinceId}`, {
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN }
+ });
+ if (pResp.status === 429) { await new Promise(r => setTimeout(r, 4000)); continue; }
+ const pData = await pResp.json();
+ if (!pData.products?.length) break;
+
+ for (const p of pData.products) {
+ if (synced + skipped >= lim) break;
+ try {
+ const mfResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-01/products/${p.id}/metafields.json?limit=250`, {
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN }
+ });
+ if (mfResp.status === 429) { await new Promise(r => setTimeout(r, 4000)); continue; }
+ const { metafields = [] } = await mfResp.json();
+ const newMfs = syncNamespaces(metafields);
+
+ if (newMfs.length > 0) {
+ const putResp = await fetch(`https://${SHOPIFY_DOMAIN}/admin/api/2024-01/products/${p.id}.json`, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: p.id, metafields: newMfs } })
+ });
+ if (putResp.status === 429) { await new Promise(r => setTimeout(r, 4000)); }
+ synced++;
+ } else {
+ skipped++;
+ }
+ } catch { errors++; }
+ await new Promise(r => setTimeout(r, 600));
+ }
+
+ sinceId = pData.products[pData.products.length - 1].id;
+ if (synced + skipped >= lim) break;
+ await new Promise(r => setTimeout(r, 500));
+ } catch { break; }
+ }
+
+ console.log(`[VCC] Namespace sync DONE for ${vendor}: ${synced} synced, ${skipped} already set, ${errors} errors`);
+ })();
+});
+
+// POST /api/update-metafields — Push specs from DB to Shopify metafields for a vendor
+// Uses shared metafield mapping: custom.*, specs.*, global.*, dwc.*
+app.post('/api/update-metafields', async (req, res) => {
+ const { vendor_code, catalog_table, limit, force } = req.body || {};
+ if (!vendor_code && !catalog_table) return res.status(400).json({ error: 'vendor_code or catalog_table required' });
+
+ const table = catalog_table || (vendor_code ? vendor_code.toLowerCase() + '_catalog' : null);
+ const vc = (vendor_code || table.replace('_catalog', '')).toUpperCase();
+ const lim = limit || 99999;
+
+ res.json({ status: 'started', vendor: vc, table, limit: lim, force: !!force });
+
+ const { execFile } = require('child_process');
+ console.log(`[VCC] Metafield push started for ${vc} (table=${table}, limit=${lim}, force=${!!force})`);
+
+ const args = ['push-metafields-vendor.py', table];
+ if (vendor_code) args.push('--vendor', vc);
+ if (force) args.push('--force');
+ if (limit) args.push('--limit', String(lim));
+
+ execFile('python3', args, { timeout: 3600000, cwd: '/root/DW-Agents/scripts' }, (err, stdout, stderr) => {
+ if (err) console.log(`[VCC] Metafield push ERROR for ${vc}:`, err.message);
+ else console.log(`[VCC] Metafield push COMPLETE for ${vc}:`, stdout.slice(-300));
+ });
+});
+
+// POST /api/standardize/stop — stop engine
+app.post('/api/standardize/stop', (req, res) => {
+ res.json(stdWorker.stopEngine());
+});
+
+// POST /api/standardize/bulk — run bulk analysis on all pre-loaded pending products (skip Shopify scan)
+app.post('/api/standardize/bulk', async (req, res) => {
+ const { batchSize, apply } = req.body || {};
+ try {
+ const result = await stdWorker.runBulkAnalysis({
+ batchSize: batchSize || 500,
+ apply: apply !== false
+ });
+ res.json(result);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// GET /api/standardize/vendors — get Shopify vendor list with product counts
+app.get('/api/standardize/vendors', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT vr.vendor_name, vr.vendor_code, vr.shopify_count, vr.is_private_label,
+ vr.vendor_discount_pct, vr.discount_confirmed,
+ COALESCE(ps.total, 0) as std_total,
+ COALESCE(ps.analyzed, 0) as std_analyzed,
+ COALESCE(ps.applied, 0) as std_applied,
+ COALESCE(ps.errors, 0) as std_errors,
+ ps.last_analyzed, ps.last_applied
+ FROM vendor_registry vr
+ LEFT JOIN (
+ SELECT vendor,
+ COUNT(*) as total,
+ COUNT(*) FILTER (WHERE status='analyzed') as analyzed,
+ COUNT(*) FILTER (WHERE status='applied') as applied,
+ COUNT(*) FILTER (WHERE status='error') as errors,
+ MAX(analyzed_at) as last_analyzed,
+ MAX(applied_at) as last_applied
+ FROM product_standardization
+ GROUP BY vendor
+ ) ps ON vr.vendor_name = ps.vendor
+ WHERE vr.shopify_count > 0
+ ORDER BY vr.shopify_count DESC
+ `);
+ res.json({ vendors: rows });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// POST /api/vendor/discount — save confirmed vendor discount and cascade to
+// every product_pricing row for that vendor so the generated cost flips.
+app.post('/api/vendor/discount', express.json(), async (req, res) => {
+ try {
+ const { vendor_code, discount_pct, confirmed } = req.body || {};
+ if (!vendor_code) return res.status(400).json({ error: 'vendor_code required' });
+ const pct = (discount_pct === null || discount_pct === '' || discount_pct === undefined) ? null : Number(discount_pct);
+ await pool.query(
+ `UPDATE vendor_registry
+ SET vendor_discount_pct = $2,
+ discount_confirmed = $3,
+ discount_confirmed_at = CASE WHEN $3 THEN NOW() ELSE discount_confirmed_at END
+ WHERE vendor_code = $1`,
+ [vendor_code, pct, !!confirmed]);
+ await pool.query(
+ `UPDATE product_pricing SET discount_pct = $2, updated_at = NOW() WHERE vendor_code = $1`,
+ [vendor_code, pct]);
+ const r = await pool.query(
+ `SELECT vendor_code, vendor_discount_pct, discount_confirmed FROM vendor_registry WHERE vendor_code = $1`,
+ [vendor_code]);
+ res.json({ ok: true, vendor: r.rows[0] });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// GET /api/standardize/preview/:vendor — preview analyzed products
+app.get('/api/standardize/preview/:vendor', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT shopify_product_id as id, title, title_standardized, tags_standardized,
+ ai_colors, ai_hex_codes, ai_styles, ai_patterns, ai_material, ai_is_mural,
+ ai_audience, ai_description, ai_room_type, status, analyzed_at, applied_at
+ FROM product_standardization
+ WHERE vendor=$1 ORDER BY analyzed_at DESC NULLS LAST LIMIT 50
+ `, [req.params.vendor]);
+ res.json({ products: rows });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+// --- Product Actions API ---
+const PA_PRODUCT_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const PA_CONTENT_TOKEN = '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';
+const PA_SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const PA_GQL = `https://${PA_SHOP}/admin/api/2024-07/graphql.json`;
+
+app.get('/api/product-actions/search', async (req, res) => {
+ try {
+ const q = req.query.q || '';
+ const cursor = req.query.cursor || null;
+ const after = cursor ? `, after: "${cursor}"` : '';
+
+ const gqlQuery = `{ products(first: 50, query: "${q.replace(/"/g, '\\"')}"${after}) { edges { cursor node { id title handle status vendor productType featuredImage { url } variants(first:1) { edges { node { sku } } } updatedAt } } pageInfo { hasNextPage } } }`;
+
+ const resp = await fetch(PA_GQL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PA_PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: gqlQuery })
+ });
+ const data = await resp.json();
+ const edges = data?.data?.products?.edges || [];
+ const hasMore = data?.data?.products?.pageInfo?.hasNextPage || false;
+ const lastCursor = edges.length ? edges[edges.length - 1].cursor : null;
+
+ const products = edges.map(e => {
+ const n = e.node;
+ return {
+ id: n.id.split('/').pop(),
+ title: n.title,
+ handle: n.handle,
+ status: n.status,
+ vendor: n.vendor || '',
+ type: n.productType || '',
+ sku: n.variants?.edges?.[0]?.node?.sku || '',
+ image: n.featuredImage?.url ? n.featuredImage.url.replace(/\?.*/, '?width=80') : '',
+ updated: n.updatedAt ? new Date(n.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
+ };
+ });
+
+ res.json({ products, cursor: lastCursor, hasMore });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/product-actions/bulk-status', async (req, res) => {
+ try {
+ const { ids, status } = req.body;
+ if (!ids || !status) return res.status(400).json({ error: 'ids and status required' });
+
+ let success = 0, errors = 0;
+ for (const id of ids) {
+ try {
+ const resp = await fetch(`https://${PA_SHOP}/admin/api/2024-07/products/${id}.json`, {
+ method: 'PUT',
+ headers: { 'X-Shopify-Access-Token': PA_PRODUCT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: { id: parseInt(id), status } })
+ });
+ if (resp.ok) success++;
+ else errors++;
+ await new Promise(r => setTimeout(r, 300));
+ } catch (e) {
+ errors++;
+ }
+ }
+ res.json({ success, errors, total: ids.length });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/product-actions/bulk-redirect', async (req, res) => {
+ try {
+ const { handles, target } = req.body;
+ if (!handles || !target) return res.status(400).json({ error: 'handles and target required' });
+
+ let created = 0, skipped = 0, errors = 0;
+ for (const handle of handles) {
+ try {
+ const resp = await fetch(`https://${PA_SHOP}/admin/api/2024-07/redirects.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': PA_CONTENT_TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ redirect: { path: `/products/${handle}`, target } })
+ });
+ if (resp.ok) created++;
+ else if (resp.status === 422) skipped++;
+ else errors++;
+ await new Promise(r => setTimeout(r, 250));
+ } catch (e) {
+ errors++;
+ }
+ }
+ res.json({ created, skipped, errors, total: handles.length });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- New Launches API ---
+app.get('/api/new-launches', async (req, res) => {
+ try {
+ const { days = '7' } = req.query;
+ const daysNum = parseInt(days) || 7;
+ const since = new Date(Date.now() - daysNum * 86400000).toISOString();
+
+ const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+ const TOKEN = process.env.SHOPIFY_ORDERS_TOKEN;
+
+ const products = [];
+ let cursor = null;
+
+ for (let page = 0; page < 20; page++) {
+ await new Promise(r => setTimeout(r, 350));
+ const after = cursor ? `, after: "${cursor}"` : '';
+ const q = `{
+ products(first: 50, query: "created_at:>'${since}' status:active"${after}, sortKey: CREATED_AT, reverse: true) {
+ pageInfo { hasNextPage endCursor }
+ edges { node {
+ id title vendor productType status createdAt
+ variants(first: 1) { edges { node { sku price } } }
+ images(first: 1) { edges { node { src } } }
+ }}
+ }
+ }`;
+
+ const r = await fetch(`https://${SHOP}/admin/api/2024-10/graphql.json`, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: q })
+ });
+ const d = await r.json();
+ if (!d.data) break;
+
+ for (const e of d.data.products.edges) {
+ const n = e.node;
+ products.push({
+ id: n.id.split('/').pop(),
+ title: n.title,
+ vendor: n.vendor,
+ type: n.productType,
+ sku: n.variants?.edges?.[0]?.node?.sku || '',
+ price: n.variants?.edges?.[0]?.node?.price || '',
+ image: n.images?.edges?.[0]?.node?.src || '',
+ created: n.createdAt
+ });
+ }
+ if (!d.data.products.pageInfo.hasNextPage) break;
+ cursor = d.data.products.pageInfo.endCursor;
+ }
+
+ // Group by date
+ const grouped = {};
+ for (const p of products) {
+ const dateKey = new Date(p.created).toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles', year: 'numeric', month: 'short', day: 'numeric' });
+ if (!grouped[dateKey]) grouped[dateKey] = [];
+ grouped[dateKey].push(p);
+ }
+
+ res.json({ success: true, total: products.length, days: daysNum, grouped, products });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- Catalog Column Cache (refreshed every 5 minutes) ---
+const catalogColumnCache = { data: null, ts: 0, TTL: 300000 };
+async function getCatalogColumnMap() {
+ const now = Date.now();
+ if (catalogColumnCache.data && (now - catalogColumnCache.ts) < catalogColumnCache.TTL) {
+ return catalogColumnCache.data;
+ }
+ // Get all vendor catalog tables
+ const vendorRes = await pool.query(
+ "SELECT vendor_code, vendor_name, catalog_table FROM vendor_registry WHERE catalog_table IS NOT NULL AND catalog_table != '' AND catalog_table != 'vendor_catalog' ORDER BY vendor_name"
+ );
+ // Batch query all columns for all catalog tables at once
+ const tableNames = vendorRes.rows.map(v => v.catalog_table).filter(t => /^[a-z0-9_]+$/i.test(t));
+ const colRes = await pool.query(
+ "SELECT table_name, column_name FROM information_schema.columns WHERE table_name = ANY($1)",
+ [tableNames]
+ );
+ // Build map: tableName -> Set of columns
+ const colMap = {};
+ for (const row of colRes.rows) {
+ if (!colMap[row.table_name]) colMap[row.table_name] = new Set();
+ colMap[row.table_name].add(row.column_name);
+ }
+ const result = { vendors: vendorRes.rows, colMap };
+ catalogColumnCache.data = result;
+ catalogColumnCache.ts = now;
+ return result;
+}
+
+// --- API: Global Catalog Search (searches across ALL vendor catalog tables) ---
+app.get('/api/catalog-search', async (req, res) => {
+ try {
+ const q = (req.query.q || '').trim();
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(10, parseInt(req.query.limit) || 50));
+ const offset = (page - 1) * limit;
+
+ if (!q) {
+ return res.json({ success: true, data: [], total: 0, page, limit, pages: 0 });
+ }
+
+ const { vendors: vendorRows, colMap } = await getCatalogColumnMap();
+
+ const searchCols = ['pattern_name', 'color_name', 'mfr_sku', 'collection', 'material', 'product_type', 'dw_sku'];
+ const selectCols = ['pattern_name', 'color_name', 'mfr_sku', 'collection', 'image_url', 'width', 'shopify_product_id', 'product_type', 'dw_sku', 'product_url', 'repeat_v', 'repeat_h', 'material', 'match_type', 'all_images', 'spec_sheet', 'about_vendor'];
+
+ const searchPattern = '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
+ const unionParts = [];
+
+ for (const v of vendorRows) {
+ const tbl = v.catalog_table;
+ if (!/^[a-z0-9_]+$/i.test(tbl)) continue;
+ const existingCols = colMap[tbl];
+ if (!existingCols) continue;
+
+ const selParts = selectCols.map(col => existingCols.has(col) ? `CAST(${col} AS TEXT) as ${col}` : `NULL::text as ${col}`);
+ const whereParts = searchCols
+ .filter(col => existingCols.has(col))
+ .map(col => `CAST(${col} AS TEXT) ILIKE $1`);
+
+ if (whereParts.length === 0) continue;
+
+ unionParts.push(
+ `SELECT ${selParts.join(', ')}, '${v.vendor_code.replace(/'/g, "''")}' as vendor_code, '${v.vendor_name.replace(/'/g, "''")}' as vendor_name FROM ${tbl} WHERE (${whereParts.join(' OR ')})`
+ );
+ }
+
+ if (unionParts.length === 0) {
+ return res.json({ success: true, data: [], total: 0, page, limit, pages: 0 });
+ }
+
+ // Count total matches first
+ const countSQL = `SELECT COUNT(*) as total FROM (${unionParts.join(' UNION ALL ')}) AS search_results`;
+ const countRes = await pool.query(countSQL, [searchPattern]);
+ const total = parseInt(countRes.rows[0].total);
+
+ // Fetch paginated results
+ const dataSQL = `SELECT * FROM (${unionParts.join(' UNION ALL ')}) AS search_results ORDER BY vendor_name, pattern_name LIMIT ${limit} OFFSET ${offset}`;
+ const dataRes = await pool.query(dataSQL, [searchPattern]);
+
+ res.json({
+ success: true,
+ data: dataRes.rows,
+ total,
+ page,
+ limit,
+ pages: Math.ceil(total / limit),
+ query: q
+ });
+ } catch (err) {
+ console.error('[catalog-search] Error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- API: View All Catalog Items (paginated across ALL vendor catalog tables) ---
+app.get('/api/catalog-all', async (req, res) => {
+ try {
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(10, parseInt(req.query.limit) || 50));
+ const offset = (page - 1) * limit;
+ const vendorFilter = (req.query.vendor || '').trim();
+
+ const { vendors: vendorRows, colMap } = await getCatalogColumnMap();
+
+ const selectCols = ['pattern_name', 'color_name', 'mfr_sku', 'collection', 'image_url', 'width', 'shopify_product_id', 'product_type', 'dw_sku', 'product_url', 'repeat_v', 'repeat_h', 'material', 'match_type', 'all_images', 'spec_sheet', 'about_vendor'];
+ const unionParts = [];
+
+ for (const v of vendorRows) {
+ if (vendorFilter && v.vendor_code !== vendorFilter) continue;
+ const tbl = v.catalog_table;
+ if (!/^[a-z0-9_]+$/i.test(tbl)) continue;
+ const existingCols = colMap[tbl];
+ if (!existingCols) continue;
+
+ const selParts = selectCols.map(col => existingCols.has(col) ? `CAST(${col} AS TEXT) as ${col}` : `NULL::text as ${col}`);
+
+ unionParts.push(
+ `SELECT ${selParts.join(', ')}, '${v.vendor_code.replace(/'/g, "''")}' as vendor_code, '${v.vendor_name.replace(/'/g, "''")}' as vendor_name FROM ${tbl}`
+ );
+ }
+
+ if (unionParts.length === 0) {
+ return res.json({ success: true, data: [], total: 0, page, limit, pages: 0 });
+ }
+
+ // Use pg_stat for fast approximate total count when no vendor filter
+ let total;
+ if (!vendorFilter) {
+ const approxRes = await pool.query(`
+ SELECT SUM(n_live_tup) as total FROM pg_stat_user_tables
+ WHERE relname IN (${vendorRows.map((_, i) => `$${i+1}`).join(',')})
+ `, vendorRows.map(v => v.catalog_table));
+ total = parseInt(approxRes.rows[0].total) || 0;
+ } else {
+ const countSQL = `SELECT COUNT(*) as total FROM (${unionParts.join(' UNION ALL ')}) AS all_items`;
+ const countRes = await pool.query(countSQL);
+ total = parseInt(countRes.rows[0].total);
+ }
+
+ // Fetch paginated results
+ const dataSQL = `SELECT * FROM (${unionParts.join(' UNION ALL ')}) AS all_items ORDER BY vendor_name, pattern_name LIMIT ${limit} OFFSET ${offset}`;
+ const dataRes = await pool.query(dataSQL);
+
+ res.json({
+ success: true,
+ data: dataRes.rows,
+ total,
+ page,
+ limit,
+ pages: Math.ceil(total / limit),
+ vendor: vendorFilter || 'all'
+ });
+ } catch (err) {
+ console.error('[catalog-all] Error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Sample Variant Health Check ---
+// Shopify Product API token (read/write products scope)
+const SHOPIFY_PRODUCT_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const SHOPIFY_GRAPHQL_URL = 'https://' + SHOPIFY_DOMAIN + '/admin/api/2024-01/graphql.json';
+
+// In-memory cache for sample variant audit results (1 hour TTL)
+let sampleVariantCache = null;
+let sampleVariantCacheTime = 0;
+const SAMPLE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
+
+// Excluded vendors for push recommendations
+const EXCLUDED_VENDOR_NOTES = [
+ { name: 'Cowtan & Tout', code: 'cowtan_tout', reason: 'Images restricted - specs only' },
+ { name: 'Scalamandre (Fabrics)', code: 'scalamandre', reason: 'Fabric products excluded from push' },
+ { name: 'Bespoke', code: 'bespoke', reason: 'Custom/bespoke products - excluded from standard push' }
+];
+
+/**
+ * Fetch all active products with their variants from Shopify via GraphQL (paginated).
+ * Returns array of { id, title, vendor, variants: [{ id, title, price }] }
+ */
+async function fetchAllShopifyProductsWithVariants(progressCb) {
+ const products = [];
+ let hasNextPage = true;
+ let cursor = null;
+ let pageNum = 0;
+
+ while (hasNextPage) {
+ pageNum++;
+ const afterClause = cursor ? `, after: "${cursor}"` : '';
+ const query = `{
+ products(first: 250, query: "status:active"${afterClause}) {
+ edges {
+ cursor
+ node {
+ id
+ title
+ vendor
+ variants(first: 100) {
+ edges {
+ node {
+ id
+ title
+ price
+ }
+ }
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ }
+ }
+ }`;
+
+ const response = await fetch(SHOPIFY_GRAPHQL_URL, {
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_PRODUCT_TOKEN,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ query })
+ });
+
+ if (!response.ok) {
+ const errText = await response.text();
+ throw new Error(`Shopify GraphQL error (${response.status}): ${errText}`);
+ }
+
+ const data = await response.json();
+
+ if (data.errors) {
+ throw new Error('Shopify GraphQL errors: ' + JSON.stringify(data.errors));
+ }
+
+ const edges = data.data.products.edges;
+ for (const edge of edges) {
+ const node = edge.node;
+ products.push({
+ id: node.id.replace('gid://shopify/Product/', ''),
+ gid: node.id,
+ title: node.title,
+ vendor: node.vendor,
+ variants: node.variants.edges.map(ve => ({
+ id: ve.node.id.replace('gid://shopify/ProductVariant/', ''),
+ gid: ve.node.id,
+ title: ve.node.title,
+ price: parseFloat(ve.node.price)
+ }))
+ });
+ cursor = edge.cursor;
+ }
+
+ hasNextPage = data.data.products.pageInfo.hasNextPage;
+
+ if (progressCb) progressCb(pageNum, products.length, hasNextPage);
+
+ // Respect rate limits: Shopify GraphQL has 1000 point bucket, this query costs ~10-15 points
+ // Small delay to avoid throttling
+ if (hasNextPage) {
+ await new Promise(r => setTimeout(r, 200));
+ }
+ }
+
+ return products;
+}
+
+/**
+ * Build a vendor lookup map: vendor_name (lowercase) -> { vendor_code, sample_price, skip_shopify }
+ */
+async function getVendorSamplePriceMap() {
+ const { rows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, sample_price, skip_shopify FROM vendor_registry'
+ );
+ const map = {};
+ for (const r of rows) {
+ map[r.vendor_name.toLowerCase()] = {
+ vendor_code: r.vendor_code,
+ sample_price: parseFloat(r.sample_price) || 4.25,
+ skip_shopify: r.skip_shopify || false
+ };
+ }
+ return map;
+}
+
+// GET /api/audit/sample-variants — Check all active products for Sample variant
+app.get('/api/audit/sample-variants', async (req, res) => {
+ try {
+ const forceRefresh = req.query.refresh === 'true';
+ const now = Date.now();
+
+ // Return cached results if fresh
+ if (!forceRefresh && sampleVariantCache && (now - sampleVariantCacheTime) < SAMPLE_CACHE_TTL) {
+ return res.json({
+ success: true,
+ cached: true,
+ cached_at: new Date(sampleVariantCacheTime).toISOString(),
+ ...sampleVariantCache
+ });
+ }
+
+ logActivity('api', 'Sample variant audit started');
+ const startTime = Date.now();
+
+ // Get vendor price map
+ const vendorMap = await getVendorSamplePriceMap();
+
+ // Fetch all products with variants from Shopify
+ let lastLog = Date.now();
+ const products = await fetchAllShopifyProductsWithVariants((page, count, more) => {
+ if (Date.now() - lastLog > 5000) {
+ console.log(`[Victor] Sample audit: page ${page}, ${count} products fetched...`);
+ lastLog = Date.now();
+ }
+ });
+
+ // Analyze each product
+ let total = 0;
+ let hasSample = 0;
+ let missingSample = 0;
+ let wrongPrice = 0;
+ const byVendor = {};
+ const missingList = [];
+ const wrongPriceList = [];
+
+ for (const product of products) {
+ total++;
+ const vendorLower = (product.vendor || '').toLowerCase();
+ const vendorInfo = vendorMap[vendorLower] || { vendor_code: 'unknown', sample_price: 4.25, skip_shopify: false };
+ const expectedPrice = vendorInfo.sample_price;
+ const vendorCode = vendorInfo.vendor_code;
+
+ // Initialize vendor bucket
+ if (!byVendor[vendorCode]) {
+ byVendor[vendorCode] = {
+ vendor_name: product.vendor,
+ total: 0,
+ has_sample: 0,
+ missing_sample: 0,
+ wrong_price: 0,
+ skip_shopify: vendorInfo.skip_shopify,
+ expected_sample_price: expectedPrice
+ };
+ }
+ byVendor[vendorCode].total++;
+
+ // Check for Sample variant
+ const sampleVariant = product.variants.find(v =>
+ v.title && v.title.toLowerCase().includes('sample')
+ );
+
+ if (sampleVariant) {
+ if (Math.abs(sampleVariant.price - expectedPrice) > 0.01) {
+ wrongPrice++;
+ byVendor[vendorCode].wrong_price++;
+ wrongPriceList.push({
+ product_id: product.id,
+ title: product.title,
+ vendor: product.vendor,
+ vendor_code: vendorCode,
+ variant_id: sampleVariant.id,
+ current_price: sampleVariant.price,
+ expected_price: expectedPrice
+ });
+ } else {
+ hasSample++;
+ byVendor[vendorCode].has_sample++;
+ }
+ } else {
+ missingSample++;
+ byVendor[vendorCode].missing_sample++;
+ if (missingList.length < 500) { // Cap the list to avoid huge responses
+ missingList.push({
+ product_id: product.id,
+ title: product.title,
+ vendor: product.vendor,
+ vendor_code: vendorCode
+ });
+ }
+ }
+ }
+
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+
+ const result = {
+ total,
+ has_sample: hasSample,
+ missing_sample: missingSample,
+ wrong_price: wrongPrice,
+ pct_compliant: total > 0 ? ((hasSample / total) * 100).toFixed(1) + '%' : '0%',
+ by_vendor: byVendor,
+ excluded_vendors: EXCLUDED_VENDOR_NOTES,
+ sample_missing_list: missingList,
+ sample_wrong_price_list: wrongPriceList.slice(0, 200),
+ scan_duration_sec: parseFloat(elapsed),
+ scanned_at: new Date().toISOString()
+ };
+
+ // Cache results
+ sampleVariantCache = result;
+ sampleVariantCacheTime = Date.now();
+
+ logActivity('api', `Sample variant audit complete: ${total} products, ${missingSample} missing, ${wrongPrice} wrong price (${elapsed}s)`);
+
+ res.json({
+ success: true,
+ cached: false,
+ ...result
+ });
+ } catch (err) {
+ console.error('[Victor] Sample variant audit error:', err.message);
+ logActivity('error', 'Sample variant audit failed: ' + err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// POST /api/fix/sample-variants — Fix missing or wrong-priced Sample variants
+app.post('/api/fix/sample-variants', async (req, res) => {
+ try {
+ const { vendor_code, dry_run = true } = req.body;
+
+ if (!vendor_code) {
+ return res.status(400).json({ success: false, error: 'vendor_code is required' });
+ }
+
+ // Get vendor info
+ const { rows: vendorRows } = await pool.query(
+ 'SELECT vendor_code, vendor_name, sample_price, skip_shopify FROM vendor_registry WHERE vendor_code = $1',
+ [vendor_code]
+ );
+
+ if (!vendorRows.length) {
+ return res.status(404).json({ success: false, error: 'Vendor not found: ' + vendor_code });
+ }
+
+ const vendor = vendorRows[0];
+ if (vendor.skip_shopify) {
+ return res.status(400).json({
+ success: false,
+ error: `Vendor ${vendor.vendor_name} has skip_shopify=true. Cannot fix sample variants for excluded vendors.`
+ });
+ }
+
+ const expectedPrice = parseFloat(vendor.sample_price) || 4.25;
+
+ logActivity('api', `Sample variant fix started for ${vendor.vendor_name} (dry_run=${dry_run})`);
+
+ // If we have cached audit data AND the cached lists are complete for this vendor, use cache;
+ // otherwise run a targeted live scan via GraphQL.
+ let productsToFix = [];
+ let useLiveScan = true;
+
+ if (sampleVariantCache && sampleVariantCache.by_vendor[vendor_code]) {
+ const vendorStats = sampleVariantCache.by_vendor[vendor_code];
+ const cachedMissing = sampleVariantCache.sample_missing_list
+ .filter(p => p.vendor_code === vendor_code);
+ const cachedWrongPrice = sampleVariantCache.sample_wrong_price_list
+ .filter(p => p.vendor_code === vendor_code);
+
+ // Only use cache if the cached lists contain ALL items for this vendor
+ // (i.e., counts match what the by_vendor stats say)
+ const expectedMissing = vendorStats.missing_sample || 0;
+ const expectedWrong = vendorStats.wrong_price || 0;
+ if (cachedMissing.length >= expectedMissing && cachedWrongPrice.length >= expectedWrong) {
+ useLiveScan = false;
+ for (const p of cachedMissing) {
+ productsToFix.push({ product_id: p.product_id, title: p.title, action: 'add_sample', variant_id: null });
+ }
+ for (const p of cachedWrongPrice) {
+ productsToFix.push({ product_id: p.product_id, title: p.title, action: 'fix_price', variant_id: p.variant_id, current_price: p.current_price });
+ }
+ }
+ }
+
+ if (useLiveScan) {
+ // Live scan — fetch products for this vendor from Shopify via GraphQL
+ const vendorProducts = [];
+ let hasNextPage = true;
+ let cursor = null;
+
+ while (hasNextPage) {
+ const afterClause = cursor ? `, after: "${cursor}"` : '';
+ const vendorEscaped = vendor.vendor_name.replace(/"/g, '\\"');
+ const query = `{
+ products(first: 250, query: "status:active vendor:'${vendorEscaped}'"${afterClause}) {
+ edges {
+ cursor
+ node {
+ id
+ title
+ variants(first: 100) {
+ edges {
+ node {
+ id
+ title
+ price
+ }
+ }
+ }
+ }
+ }
+ pageInfo { hasNextPage }
+ }
+ }`;
+
+ const response = await fetch(SHOPIFY_GRAPHQL_URL, {
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_PRODUCT_TOKEN,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ query })
+ });
+
+ if (!response.ok) throw new Error(`Shopify GraphQL error: ${response.status}`);
+ const data = await response.json();
+ if (data.errors) throw new Error('GraphQL: ' + JSON.stringify(data.errors));
+
+ for (const edge of data.data.products.edges) {
+ const node = edge.node;
+ const pid = node.id.replace('gid://shopify/Product/', '');
+ const variants = node.variants.edges.map(ve => ({
+ id: ve.node.id.replace('gid://shopify/ProductVariant/', ''),
+ title: ve.node.title,
+ price: parseFloat(ve.node.price)
+ }));
+
+ const sampleV = variants.find(v => v.title && v.title.toLowerCase().includes('sample'));
+ if (!sampleV) {
+ productsToFix.push({ product_id: pid, title: node.title, action: 'add_sample', variant_id: null });
+ } else if (Math.abs(sampleV.price - expectedPrice) > 0.01) {
+ productsToFix.push({ product_id: pid, title: node.title, action: 'fix_price', variant_id: sampleV.id, current_price: sampleV.price });
+ }
+ cursor = edge.cursor;
+ }
+
+ hasNextPage = data.data.products.pageInfo.hasNextPage;
+ if (hasNextPage) await new Promise(r => setTimeout(r, 200));
+ }
+ }
+
+ if (productsToFix.length === 0) {
+ return res.json({
+ success: true,
+ message: `All products for ${vendor.vendor_name} already have correct Sample variants.`,
+ fixed: 0,
+ errors: 0
+ });
+ }
+
+ // Dry run — just report what would be fixed
+ if (dry_run) {
+ return res.json({
+ success: true,
+ dry_run: true,
+ vendor: vendor.vendor_name,
+ vendor_code: vendor.vendor_code,
+ expected_price: expectedPrice,
+ would_fix: productsToFix.length,
+ add_sample: productsToFix.filter(p => p.action === 'add_sample').length,
+ fix_price: productsToFix.filter(p => p.action === 'fix_price').length,
+ products: productsToFix.slice(0, 100) // Show first 100
+ });
+ }
+
+ // Live fix — apply changes via Shopify REST API (rate-limited at 2 req/s)
+ let fixed = 0;
+ let errors = 0;
+ const errorDetails = [];
+ const SHOPIFY_REST_BASE = 'https://' + SHOPIFY_DOMAIN + '/admin/api/2024-01';
+
+ for (const item of productsToFix) {
+ try {
+ if (item.action === 'add_sample') {
+ // Add a new Sample variant to the product
+ const variantPayload = {
+ variant: {
+ product_id: parseInt(item.product_id),
+ title: 'Sample',
+ option1: 'Sample',
+ price: expectedPrice.toFixed(2),
+ requires_shipping: true,
+ taxable: true,
+ inventory_management: null,
+ inventory_policy: 'deny'
+ }
+ };
+
+ const addRes = await fetch(
+ `${SHOPIFY_REST_BASE}/products/${item.product_id}/variants.json`,
+ {
+ method: 'POST',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_PRODUCT_TOKEN,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(variantPayload)
+ }
+ );
+
+ if (!addRes.ok) {
+ const errBody = await addRes.text();
+ throw new Error(`Add variant failed (${addRes.status}): ${errBody}`);
+ }
+ fixed++;
+ } else if (item.action === 'fix_price') {
+ // Update existing variant price
+ const updatePayload = {
+ variant: {
+ id: parseInt(item.variant_id),
+ price: expectedPrice.toFixed(2)
+ }
+ };
+
+ const updateRes = await fetch(
+ `${SHOPIFY_REST_BASE}/variants/${item.variant_id}.json`,
+ {
+ method: 'PUT',
+ headers: {
+ 'X-Shopify-Access-Token': SHOPIFY_PRODUCT_TOKEN,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(updatePayload)
+ }
+ );
+
+ if (!updateRes.ok) {
+ const errBody = await updateRes.text();
+ throw new Error(`Update variant failed (${updateRes.status}): ${errBody}`);
+ }
+ fixed++;
+ }
+ } catch (err) {
+ errors++;
+ errorDetails.push({ product_id: item.product_id, title: item.title, error: err.message });
+ console.error(`[Victor] Sample fix error for product ${item.product_id}: ${err.message}`);
+ }
+
+ // Rate limit: 2 requests per second
+ await new Promise(r => setTimeout(r, 500));
+ }
+
+ // Invalidate cache after fixes
+ sampleVariantCache = null;
+ sampleVariantCacheTime = 0;
+
+ logActivity('api', `Sample variant fix for ${vendor.vendor_name}: ${fixed} fixed, ${errors} errors`);
+
+ res.json({
+ success: true,
+ dry_run: false,
+ vendor: vendor.vendor_name,
+ vendor_code: vendor.vendor_code,
+ expected_price: expectedPrice,
+ fixed,
+ errors,
+ error_details: errorDetails.slice(0, 50)
+ });
+ } catch (err) {
+ console.error('[Victor] Sample variant fix error:', err.message);
+ logActivity('error', 'Sample variant fix failed: ' + err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// GET /api/vendor-exclusions — List vendors excluded from Shopify push
+app.get('/api/vendor-exclusions', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT vendor_code, vendor_name, skip_shopify, sample_price, notes
+ FROM vendor_registry
+ WHERE skip_shopify = true
+ ORDER BY vendor_name`
+ );
+
+ res.json({
+ success: true,
+ excluded_vendors: rows,
+ static_notes: EXCLUDED_VENDOR_NOTES,
+ count: rows.length
+ });
+ } catch (err) {
+ console.error('[Victor] Vendor exclusions error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Dashboard HTML ---
+app.get('/', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.send(getDashboardHTML());
+});
+
+function getDashboardHTML() {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Victor - Vendor Command Center</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+:root {
+ --bg-primary: #0a0a14;
+ --bg-card: #13152a;
+ --bg-card-hover: #1a1d35;
+ --bg-input: #1e2140;
+ --border: #2a2d45;
+ --text-primary: #e8e8f0;
+ --text-secondary: #8888a8;
+ --text-muted: #55557a;
+ --accent: #7c3aed;
+ --accent-hover: #6d28d9;
+ --accent-light: rgba(124, 58, 237, 0.15);
+ --green: #22c55e;
+ --green-bg: rgba(34, 197, 94, 0.12);
+ --red: #ef4444;
+ --red-bg: rgba(239, 68, 68, 0.12);
+ --orange: #f59e0b;
+ --orange-bg: rgba(245, 158, 11, 0.12);
+ --blue: #3b82f6;
+ --blue-bg: rgba(59, 130, 246, 0.12);
+ --yellow: #eab308;
+ --pink: #ec4899;
+ --pink-bg: rgba(236, 72, 153, 0.12);
+ --cyan: #06b6d4;
+ --cyan-bg: rgba(6, 182, 212, 0.12);
+}
+.asset-badge {
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 9px;
+ font-weight: 600;
+ margin: 2px 3px 2px 0;
+ white-space: nowrap;
+}
+.asset-badge.missing { background: var(--red-bg); color: var(--red); }
+.asset-badge.partial { background: var(--orange-bg); color: var(--orange); }
+.asset-badge.good { background: var(--green-bg); color: var(--green); }
+.asset-badge.na { background: var(--bg-input); color: var(--text-muted); }
+.score-cell { text-align: center; cursor: default; }
+.score-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; min-width: 28px; text-align: center; }
+.score-badge.score-high { background: var(--green-bg); color: var(--green); }
+.score-badge.score-mid { background: var(--orange-bg); color: var(--orange); }
+.score-badge.score-low { background: var(--red-bg); color: var(--red); }
+.score-badge.score-none { background: var(--bg-input); color: var(--text-muted); font-weight: 400; font-size: 10px; }
+.website-info { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; padding: 8px 12px; background: var(--bg-input); border-radius: 6px; border: 1px solid var(--border); }
+.website-info .wi-row { display: flex; gap: 8px; align-items: center; }
+.website-info .wi-label { font-size: 10px; color: var(--text-muted); min-width: 60px; }
+.website-info .wi-value { font-size: 11px; color: var(--text-primary); }
+.asset-badge.na { background: rgba(255,255,255,0.05); color: var(--text-muted); }
+.assets-cell { position: relative; white-space: nowrap; text-align: center; cursor: pointer; }
+.dq-summary { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; cursor: pointer; user-select: none; }
+.dq-summary:hover { filter: brightness(1.2); }
+.dq-summary.dq-good { background: var(--green-bg); color: var(--green); }
+.dq-summary.dq-partial { background: var(--orange-bg); color: var(--orange); }
+.dq-summary.dq-bad { background: var(--red-bg); color: var(--red); }
+.dq-summary.dq-na { background: var(--bg-input); color: var(--text-muted); font-weight: 400; }
+.dq-popover { display: none; position: absolute; top: 100%; right: 0; z-index: 1000; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px; min-width: 180px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
+.dq-popover.show { display: block; }
+.dq-popover .dq-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; font-size: 11px; }
+.dq-popover .dq-label { color: var(--text-secondary); }
+.dq-popover .dq-val { font-weight: 600; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ min-height: 100vh;
+}
+.header {
+ background: linear-gradient(135deg, #13152a 0%, #1a1040 100%);
+ border-bottom: 1px solid var(--border);
+ padding: 16px 24px;
+ position: sticky;
+ top: 0;
+ z-index: 200;
+}
+.header-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+.header-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.header-title h1 {
+ font-size: 26px;
+ font-weight: 700;
+ background: linear-gradient(135deg, #7c3aed, #a78bfa);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+.header-title .codename {
+ font-size: 14px;
+ color: var(--text-secondary);
+ background: var(--bg-input);
+ padding: 3px 10px;
+ border-radius: 4px;
+}
+.header-meta {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.refresh-ts { color: var(--text-secondary); }
+.stats-bar {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.stat-card {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 16px;
+ display: flex;
+ flex-direction: column;
+ min-width: 120px;
+ cursor: pointer;
+ transition: transform 0.15s, box-shadow 0.15s;
+}
+.stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+.stat-card:active { transform: translateY(0); }
+/* Stat Drilldown Modal */
+.stat-modal-overlay {
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.7); z-index: 1000;
+ display: flex; align-items: center; justify-content: center;
+ backdrop-filter: blur(4px);
+}
+.stat-modal {
+ background: var(--bg-secondary); border: 1px solid var(--border);
+ border-radius: 12px; width: 90vw; max-width: 900px; max-height: 80vh;
+ display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
+}
+.stat-modal-header {
+ padding: 16px 20px; border-bottom: 1px solid var(--border);
+ display: flex; justify-content: space-between; align-items: center;
+}
+.stat-modal-header h3 { margin: 0; font-size: 16px; color: var(--text-primary); }
+.stat-modal-close { background: none; border: none; color: var(--text-muted); font-size: 20px; cursor: pointer; padding: 4px 8px; }
+.stat-modal-close:hover { color: var(--text-primary); }
+.stat-modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
+.stat-modal-body table { width: 100%; border-collapse: collapse; }
+.stat-modal-body th { text-align: left; padding: 6px 10px; font-size: 11px; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); cursor: pointer; }
+.stat-modal-body th:hover { color: var(--accent); }
+.stat-modal-body td { padding: 6px 10px; font-size: 13px; border-bottom: 1px solid rgba(42,45,69,0.3); }
+.stat-modal-body tr:hover { background: var(--bg-card-hover); }
+.stat-card .stat-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.stat-card .stat-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 2px;
+}
+.stat-card.accent .stat-value { color: var(--accent); }
+.stat-card.green .stat-value { color: var(--green); }
+.stat-card.blue .stat-value { color: var(--blue); }
+.stat-card.orange .stat-value { color: var(--orange); }
+.controls {
+ padding: 12px 24px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-primary);
+}
+.filter-group {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.filter-btn {
+ padding: 5px 12px;
+ font-size: 12px;
+ border: 1px solid var(--border);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.filter-btn:hover { border-color: var(--accent); color: var(--text-primary); }
+.filter-btn.active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.controls-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.sort-select {
+ padding: 5px 10px;
+ font-size: 12px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ outline: none;
+}
+.sort-select option { background: var(--bg-card); }
+.bulk-btn {
+ padding: 5px 12px;
+ font-size: 12px;
+ border: 1px solid var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.bulk-btn:hover { background: var(--accent); color: #fff; }
+.bulk-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+ padding: 0 24px;
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border);
+}
+.tab-btn {
+ padding: 10px 20px;
+ font-size: 13px;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.tab-btn:hover { color: var(--text-secondary); }
+.tab-btn.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+.table-container {
+ padding: 0 24px 24px;
+ overflow-x: auto;
+}
+table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 12px;
+}
+thead th {
+ padding: 8px 10px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ white-space: nowrap;
+ background: var(--bg-primary);
+}
+thead th[title] {
+ cursor: help;
+ border-bottom: 1px dotted var(--text-muted);
+}
+tbody tr {
+ border-bottom: 1px solid rgba(42, 45, 69, 0.5);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+tbody tr:hover { background: var(--bg-card-hover); }
+tbody tr.expanded { background: var(--bg-card); }
+td {
+ padding: 8px 10px;
+ font-size: 13px;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+.cb-cell { width: 36px; text-align: center; }
+.cb-cell input[type="checkbox"] {
+ accent-color: var(--accent);
+ width: 15px;
+ height: 15px;
+ cursor: pointer;
+}
+.vendor-name {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.agent-code {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-left: 6px;
+}
+.port-badge {
+ font-size: 10px;
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ padding: 1px 6px;
+ border-radius: 4px;
+ margin-left: 4px;
+}
+.sku-badge {
+ font-size: 11px;
+ background: var(--accent-light);
+ color: var(--accent);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-family: monospace;
+}
+.priority-badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+.priority-HIGH { background: var(--red-bg); color: var(--red); }
+.priority-MEDIUM { background: var(--orange-bg); color: var(--orange); }
+.priority-LOW { background: var(--green-bg); color: var(--green); }
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 4px;
+}
+.dot-green { background: var(--green); box-shadow: 0 0 4px var(--green); }
+.dot-red { background: var(--red); }
+.dot-gray { background: #555; }
+.icon-status {
+ font-size: 14px;
+ margin-right: 4px;
+}
+.product-count {
+ font-family: monospace;
+ font-size: 12px;
+}
+.date-cell {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+.expand-btn {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ transition: all 0.15s;
+}
+.expand-btn:hover { background: var(--bg-input); color: var(--text-primary); }
+.pl-checkbox {
+ accent-color: var(--accent);
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+}
+.pl-name-input {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ color: var(--text-primary);
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ width: 100px;
+ outline: none;
+}
+.pl-name-input:focus { border-color: var(--accent); }
+.drill-down {
+ display: none;
+ background: var(--bg-card);
+ border-bottom: 2px solid var(--accent);
+}
+.drill-down.show { display: table-row; }
+.drill-content {
+ padding: 16px 20px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ min-width: 0;
+ overflow: hidden;
+ white-space: normal;
+}
+.drill-section {
+ background: var(--bg-input);
+ border-radius: 8px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ min-width: 0;
+ overflow: hidden;
+}
+.drill-section h4 {
+ font-size: 12px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+.drill-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.action-btn {
+ padding: 6px 12px;
+ font-size: 11px;
+ border: 1px solid var(--border);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.15s;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.action-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
+.action-btn.loading { opacity: 0.6; pointer-events: none; }
+.action-btn .spinner {
+ display: none;
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--text-muted);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+.action-btn.loading .spinner { display: inline-block; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.learnings-textarea {
+ width: 100%;
+ min-height: 80px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px;
+ font-size: 12px;
+ font-family: inherit;
+ resize: vertical;
+ outline: none;
+}
+.learnings-textarea:focus { border-color: var(--accent); }
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 10px 16px;
+}
+.detail-item {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+}
+.detail-item .label {
+ font-size: 10px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 2px;
+}
+.detail-item .value {
+ font-size: 13px;
+ color: var(--text-primary);
+ word-break: break-word;
+ overflow-wrap: break-word;
+ line-height: 1.4;
+}
+.agent-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--accent);
+ font-size: 12px;
+ text-decoration: none;
+}
+.agent-link:hover { text-decoration: underline; }
+.calendar-container { padding: 16px 24px; display: none; }
+.calendar-container.show { display: block; }
+.calendar-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 4px;
+}
+.cal-header {
+ padding: 6px;
+ font-size: 11px;
+ color: var(--text-muted);
+ text-align: center;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+.cal-day {
+ min-height: 70px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 4px;
+ font-size: 10px;
+}
+.cal-day .day-num {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+.cal-day .cal-vendor {
+ padding: 1px 4px;
+ border-radius: 3px;
+ margin-bottom: 2px;
+ font-size: 9px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.cal-day.empty { opacity: 0.3; }
+.toast-container {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 10000;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.toast {
+ padding: 10px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ animation: slideIn 0.2s ease-out;
+ max-width: 360px;
+}
+.toast.success { background: var(--green-bg); color: var(--green); border: 1px solid var(--green); }
+.toast.error { background: var(--red-bg); color: var(--red); border: 1px solid var(--red); }
+.toast.info { background: var(--blue-bg); color: var(--blue); border: 1px solid var(--blue); }
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+.search-input {
+ padding: 5px 10px;
+ font-size: 12px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ outline: none;
+ width: 180px;
+}
+.search-input:focus { border-color: var(--accent); }
+.search-input::placeholder { color: var(--text-muted); }
+@media (max-width: 1200px) {
+ .drill-content { grid-template-columns: 1fr; }
+ .stats-bar { gap: 8px; }
+ .stat-card { min-width: 90px; padding: 6px 10px; }
+}
+@media (max-width: 768px) {
+ .controls { flex-direction: column; align-items: flex-start; }
+ .header { padding: 12px 16px; }
+ .table-container { padding: 0 8px 16px; }
+ td, th { padding: 6px 6px; font-size: 11px; }
+}
+/* --- Live Activity Ticker --- */
+.live-ticker {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 32px;
+ background: linear-gradient(135deg, #0d0d1a 0%, #13152a 100%);
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ z-index: 300;
+ overflow: hidden;
+ font-size: 11px;
+ padding: 0 12px;
+ gap: 8px;
+}
+.ticker-label {
+ background: var(--accent);
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-weight: 700;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.ticker-pulse {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 6px var(--green);
+ animation: pulse 2s infinite;
+ flex-shrink: 0;
+}
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+.ticker-scroll {
+ flex: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
+ -webkit-mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
+}
+.ticker-entries {
+ display: inline-flex;
+ gap: 24px;
+ animation: ticker-scroll 40s linear infinite;
+ color: var(--text-secondary);
+}
+.ticker-entries:hover { animation-play-state: paused; }
+@keyframes ticker-scroll {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-50%); }
+}
+.ticker-entry { white-space: nowrap; }
+.ticker-entry .ticker-time { color: var(--text-muted); margin-right: 6px; font-family: monospace; font-size: 10px; }
+.ticker-entry .ticker-type { font-weight: 600; margin-right: 4px; }
+.ticker-type.sync { color: var(--blue); }
+.ticker-type.agent { color: var(--green); }
+.ticker-type.api { color: var(--accent); }
+.ticker-type.error { color: var(--red); }
+.ticker-type.info { color: var(--cyan); }
+.ticker-type.health { color: var(--orange); }
+.ticker-count {
+ color: var(--text-muted);
+ font-size: 10px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+body { padding-bottom: 36px; }
+/* Stat value flash animation */
+@keyframes flash-green {
+ 0% { color: var(--green); text-shadow: 0 0 8px var(--green); }
+ 100% { text-shadow: none; }
+}
+@keyframes flash-red {
+ 0% { color: var(--red); text-shadow: 0 0 8px var(--red); }
+ 100% { text-shadow: none; }
+}
+.stat-flash-up { animation: flash-green 1.5s ease-out; }
+.stat-flash-down { animation: flash-red 1.5s ease-out; }
+/* Sortable column headers */
+th[data-sort] {
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ padding-right: 18px !important;
+ white-space: nowrap;
+ transition: color 0.15s ease;
+}
+th[data-sort]:hover { color: var(--accent); }
+th[data-sort]::after {
+ content: '\u2195';
+ position: absolute;
+ right: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 10px;
+ opacity: 0.3;
+}
+th[data-sort].sort-asc::after { content: '\u25B2'; opacity: 1; color: var(--accent); }
+th[data-sort].sort-desc::after { content: '\u25BC'; opacity: 1; color: var(--accent); }
+th[data-sort].sort-asc, th[data-sort].sort-desc { color: var(--accent); }
+/* Missing Specs Alert Banner */
+.missing-specs-alert {
+ display: none;
+ background: rgba(239,68,68,0.12);
+ border: 1px solid var(--red);
+ border-radius: 6px;
+ padding: 8px 16px;
+ margin: 8px 0;
+ color: var(--red);
+ font-size: 12px;
+ font-weight: 600;
+}
+/* Spec Details Modal */
+.spec-modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.7);
+ z-index: 10000;
+ justify-content: center;
+ align-items: center;
+}
+.spec-modal-overlay.show { display: flex; }
+.spec-modal {
+ background: var(--card-bg, #1e1e2e);
+ border: 1px solid var(--border, #333);
+ border-radius: 12px;
+ padding: 24px;
+ max-width: 700px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ color: var(--text, #e0e0e0);
+}
+.spec-modal h3 { margin: 0 0 16px; color: var(--accent); }
+.spec-field-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 8px;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ font-size: 12px;
+}
+.spec-field-row:nth-child(even) { background: rgba(255,255,255,0.02); }
+.spec-field-name { font-family: monospace; }
+.spec-field-status { font-weight: 600; }
+.spec-field-status.present { color: var(--green); }
+.spec-field-status.missing { color: var(--red); }
+.spec-field-status.empty { color: var(--accent); }
+/* --- Global Catalog Search Bar --- */
+.global-search-bar {
+ background: linear-gradient(135deg, #1a1040 0%, #0d1a2a 100%);
+ border-bottom: 2px solid var(--accent);
+ padding: 12px 24px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.global-search-bar .search-icon {
+ font-size: 20px;
+ color: var(--accent);
+ flex-shrink: 0;
+}
+.global-search-bar .search-wrapper {
+ flex: 1;
+ position: relative;
+}
+.global-search-bar input[type="text"] {
+ width: 100%;
+ padding: 10px 16px 10px 40px;
+ font-size: 15px;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+ font-family: inherit;
+}
+.global-search-bar input[type="text"]:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
+}
+.global-search-bar input[type="text"]::placeholder {
+ color: var(--text-muted);
+}
+.global-search-bar .search-magnifier {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 16px;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+.global-search-bar .search-actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+.global-search-bar .gs-btn {
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ border: 1px solid var(--accent);
+ background: var(--accent);
+ color: #fff;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+}
+.global-search-bar .gs-btn:hover { background: var(--accent-hover); }
+.global-search-bar .gs-btn.gs-secondary {
+ background: transparent;
+ color: var(--accent);
+}
+.global-search-bar .gs-btn.gs-secondary:hover {
+ background: var(--accent-light);
+}
+.global-search-bar .gs-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+/* Search Results Panel */
+.search-results-panel {
+ display: none;
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border);
+ max-height: 70vh;
+ overflow-y: auto;
+ position: relative;
+}
+.search-results-panel.active { display: block; }
+.search-results-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-card);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+.search-results-header .sr-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+.search-results-header .sr-count {
+ font-size: 13px;
+ color: var(--accent);
+ font-weight: 600;
+}
+.search-results-header .sr-close {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 22px;
+ cursor: pointer;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+.search-results-header .sr-close:hover { color: var(--text-primary); background: var(--bg-input); }
+.search-results-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+.search-results-table thead th {
+ padding: 8px 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ white-space: nowrap;
+ background: var(--bg-primary);
+ position: sticky;
+ top: 42px;
+ z-index: 5;
+}
+.search-results-table tbody tr {
+ border-bottom: 1px solid rgba(42, 45, 69, 0.5);
+ transition: background 0.1s;
+}
+.search-results-table tbody tr:hover { background: var(--bg-card-hover); }
+.search-results-table td {
+ padding: 6px 12px;
+ font-size: 13px;
+ vertical-align: middle;
+}
+.sr-thumb {
+ width: 44px;
+ height: 44px;
+ object-fit: cover;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: var(--bg-input);
+}
+.sr-thumb-placeholder {
+ width: 44px;
+ height: 44px;
+ border-radius: 4px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ color: var(--text-muted);
+}
+.sr-shopify-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+}
+.sr-shopify-dot.on-shopify {
+ background: var(--green);
+ box-shadow: 0 0 6px var(--green);
+}
+.sr-shopify-dot.not-on-shopify {
+ background: #555;
+}
+/* FULLPRODUCT Gate Indicators */
+.gate-bar {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ flex-wrap: nowrap;
+}
+.gate-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+ flex-shrink: 0;
+ cursor: default;
+}
+.gate-dot.pass {
+ background: var(--green);
+}
+.gate-dot.fail-hard {
+ background: var(--red);
+ box-shadow: 0 0 4px var(--red);
+}
+.gate-dot.fail-soft {
+ background: #555;
+}
+.gate-score {
+ font-size: 10px;
+ font-weight: 700;
+ margin-left: 3px;
+ font-family: monospace;
+}
+.gate-score.ready { color: var(--green); }
+.gate-score.partial { color: var(--orange); }
+.gate-score.blocked { color: var(--red); }
+.sr-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 24px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-card);
+ position: sticky;
+ bottom: 0;
+}
+.sr-pagination button {
+ padding: 6px 14px;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid var(--border);
+ background: var(--bg-input);
+ color: var(--text-primary);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.sr-pagination button:hover { border-color: var(--accent); color: var(--accent); }
+.sr-pagination button:disabled { opacity: 0.3; cursor: not-allowed; }
+.sr-pagination .sr-page-info {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+.sr-vendor-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ background: var(--accent-light);
+ color: var(--accent);
+ white-space: nowrap;
+}
+.sr-sku {
+ font-family: monospace;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+.sr-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+.sr-loading .spinner-ring {
+ width: 24px;
+ height: 24px;
+ border: 3px solid var(--border);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ margin-right: 12px;
+}
+.sr-empty {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+</style>
+</head>
+<body>
+
+<div class="toast-container" id="toasts"></div>
+
+<!-- Global Catalog Search Bar -->
+<div class="global-search-bar">
+ <div class="search-wrapper">
+ <span class="search-magnifier">🔍</span>
+ <input type="text" id="globalSearchInput" placeholder="Search all products across every vendor... (pattern, color, SKU, collection)" autocomplete="off">
+ </div>
+ <div class="search-actions">
+ <button class="gs-btn" id="globalSearchBtn" onclick="performGlobalSearch()">Search</button>
+ <button class="gs-btn gs-secondary" id="viewAllBtn" onclick="viewAllProducts()">View All Items</button>
+ </div>
+</div>
+
+<!-- Search Results Panel -->
+<div class="search-results-panel" id="searchResultsPanel">
+ <div class="search-results-header">
+ <div style="display:flex;align-items:center;gap:16px">
+ <span class="sr-title" id="srTitle">Search Results</span>
+ <span class="sr-count" id="srCount"></span>
+ <select id="srVendorFilter" onchange="filterByVendor()" style="background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:4px;padding:3px 8px;font-size:11px;display:none;">
+ <option value="">All Vendors</option>
+ </select>
+ </div>
+ <button class="sr-close" onclick="closeSearchResults()">×</button>
+ </div>
+ <div id="srBody">
+ <table class="search-results-table">
+ <thead>
+ <tr>
+ <th>Image</th>
+ <th>Vendor</th>
+ <th>Pattern</th>
+ <th>Color</th>
+ <th>MFR SKU</th>
+ <th>DW SKU</th>
+ <th>Collection</th>
+ <th>Type</th>
+ <th>Width</th>
+ <th title="FULLPRODUCT Quality Gate: Image, Width, MFR SKU (hard), Repeat, Material, All Images, Spec Sheet, About Vendor (soft)">Gate</th>
+ <th>Shopify</th>
+ </tr>
+ </thead>
+ <tbody id="srTableBody"></tbody>
+ </table>
+ </div>
+ <div class="sr-pagination" id="srPagination" style="display:none">
+ <button id="srPrevBtn" onclick="srPrevPage()" disabled>« Previous</button>
+ <span class="sr-page-info" id="srPageInfo">Page 1 of 1</span>
+ <button id="srNextBtn" onclick="srNextPage()" disabled>Next »</button>
+ </div>
+</div>
+
+<div class="header">
+ <div class="header-top">
+ <div class="header-title">
+ <h1>Vendor Command Center</h1>
+ <span class="codename">Victor</span>
+ </div>
+ <div class="header-meta">
+ <span class="refresh-ts" id="lastRefresh">Loading...</span>
+ <span style="color:var(--text-muted);font-size:11px;margin:0 6px;">|</span>
+ <span style="color:var(--text-secondary);font-size:11px;">Last Updated: <span id="lastUpdatedTs">${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}</span></span>
+ <button onclick="syncNow()" id="syncNowBtn" style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;">Sync Now</button>
+ <span style="color:var(--green)">●</span>
+ <span>Port 9660</span>
+ </div>
+ </div>
+ <div class="stats-bar" id="statsBar">
+ <div class="stat-card accent" onclick="openStatModal('total_vendors','Total Vendors','catalog_count')">
+ <span class="stat-value" id="statTotalVendors">--</span>
+ <span class="stat-label">Total Vendors</span>
+ </div>
+ <div class="stat-card green" onclick="openStatModal('agents_online','Agents Online','agent_status')">
+ <span class="stat-value" id="statAgentsOnline">--</span>
+ <span class="stat-label">Agents Online</span>
+ </div>
+ <div class="stat-card blue" onclick="openStatModal('catalog','Catalog (DB)','catalog_count')">
+ <span class="stat-value" id="statTotalProducts">--</span>
+ <span class="stat-label">Catalog (DB)</span>
+ </div>
+ <div class="stat-card" style="border-color:#60a5fa" onclick="openStatModal('shopify','On Shopify','shopify_count')">
+ <span class="stat-value" id="statShopifyCount" style="color:#60a5fa">--</span>
+ <span class="stat-label">On Shopify</span>
+ </div>
+ <div class="stat-card green" onclick="openStatModal('with_cost','With Cost','products_with_cost')">
+ <span class="stat-value" id="statWithCost">--</span>
+ <span class="stat-label">With Cost</span>
+ </div>
+ <div class="stat-card orange" onclick="openStatModal('coverage','Cost Coverage','products_with_cost')">
+ <span class="stat-value" id="statCoverage">--%</span>
+ <span class="stat-label">Cost Coverage</span>
+ </div>
+ <div class="stat-card" onclick="openStatModal('with_images','With Images','products_with_images')">
+ <span class="stat-value" id="statWithImages">--</span>
+ <span class="stat-label">With Images</span>
+ </div>
+ <div class="stat-card" style="border-color:var(--red)" onclick="openStatModal('not_on_shopify','Not on Shopify','catalog_not_on_shopify')">
+ <span class="stat-value" id="statMissingImages" style="color:var(--red)">--</span>
+ <span class="stat-label">Not on Shopify</span>
+ </div>
+ <div class="stat-card" style="border-color:var(--orange)" onclick="openStatModal('to_disc','Drafts to Review','shopify_draft')">
+ <span class="stat-value" id="statToDisc" style="color:var(--orange)">--</span>
+ <span class="stat-label">Drafts to Review</span>
+ </div>
+ <div class="stat-card" style="border-color:var(--cyan)" onclick="openStatModal('with_width','With Width','catalog_with_width')">
+ <span class="stat-value" id="statWithWidth" style="color:var(--cyan)">--</span>
+ <span class="stat-label">With Width</span>
+ </div>
+ <div class="stat-card" style="border-color:var(--pink)" onclick="openStatModal('with_repeat','With Repeat','catalog_with_repeat')">
+ <span class="stat-value" id="statWithRepeat" style="color:var(--pink)">--</span>
+ <span class="stat-label">With Repeat</span>
+ </div>
+ <div class="stat-card" style="border-color:var(--purple, #a78bfa)" onclick="openStatModal('spec_fill','Avg Spec Fill','spec_completeness')">
+ <span class="stat-value" id="statSpecCompleteness" style="color:var(--purple, #a78bfa)">--%</span>
+ <span class="stat-label">Avg Spec Fill</span>
+ </div>
+ <div class="stat-card">
+ <span class="stat-value" id="statCreds">--</span>
+ <span class="stat-label">With Creds</span>
+ </div>
+ <div class="stat-card">
+ <span class="stat-value" id="statPL">--</span>
+ <span class="stat-label">Private Label</span>
+ </div>
+ <div class="stat-card" style="border-left:3px solid #e74c3c">
+ <span class="stat-value" id="statSkipShopify" style="color:#e74c3c">--</span>
+ <span class="stat-label">Skip Shopify</span>
+ </div>
+ <div class="stat-card">
+ <span class="stat-value" id="statNextCrawl" style="font-size:12px;">--</span>
+ <span class="stat-label">Next Crawl</span>
+ </div>
+ </div>
+</div>
+
+<div class="missing-specs-alert" id="missingSpecsAlert"></div>
+
+<!-- POLICY: $4.25 sample-only products (Steve, 2026-06-12) -->
+<div style="margin:10px 0;padding:11px 14px;border-left:3px solid var(--orange,#f39c12);background:rgba(243,156,18,0.08);border-radius:6px;font-size:13px;line-height:1.5;color:var(--text,#ddd)">
+ <strong>📋 Pricing policy — $4.25 = sample, not a bug.</strong>
+ A <code>$4.25</code> price is the memo-<b>sample</b> variant (normal). A product with <b>only</b> a $4.25 sample
+ (no roll/yard variant) is <b>OK to leave live as-is — do NOT draft or archive it</b>.
+ But it <b>still needs a vendor cost recorded</b> → sample-only-without-cost counts against <b>Cost Coverage</b> above until costed.
+</div>
+
+<!-- Spec Details Modal -->
+<div class="spec-modal-overlay" id="specModalOverlay" onclick="closeSpecModal(event)">
+ <div class="spec-modal" onclick="event.stopPropagation()">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
+ <h3 id="specModalTitle">Spec Completeness</h3>
+ <button onclick="document.getElementById('specModalOverlay').classList.remove('show')" style="background:none;border:none;color:var(--text-muted);font-size:20px;cursor:pointer">×</button>
+ </div>
+ <div id="specModalSummary" style="margin-bottom:12px;font-size:13px;color:var(--text-secondary)"></div>
+ <div id="specModalBody"></div>
+ </div>
+</div>
+
+<div class="tabs">
+ <button class="tab-btn active" data-tab="vendors" onclick="switchTab('vendors')">Vendors</button>
+ <button class="tab-btn" data-tab="calendar" onclick="switchTab('calendar')">Calendar</button>
+ <button class="tab-btn" data-tab="quality" onclick="switchTab('quality')">Data Quality</button>
+ <button class="tab-btn" data-tab="discontinued" onclick="switchTab('discontinued')">Discontinued</button>
+ <button class="tab-btn" data-tab="linemanager" onclick="switchTab('linemanager')">Line Manager</button>
+ <button class="tab-btn" data-tab="tagmanager" onclick="switchTab('tagmanager')">Tag Manager</button>
+ <button class="tab-btn" data-tab="standardize" onclick="switchTab('standardize')" style="background:linear-gradient(135deg,#7c3aed,#2563eb);color:#fff;">Standardize</button>
+ <button class="tab-btn" data-tab="newlaunches" onclick="switchTab('newlaunches')" style="background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;">New Launches</button>
+ <button class="tab-btn" onclick="window.open('http://45.61.58.125:9846/launch-pad','_blank','noopener')" style="background:linear-gradient(135deg,#00f5d4,#00d4aa);color:#000;" title="Open Launch Pad — products validated and ready for Shopify push">🚀 Ready to Launch</button>
+ <button class="tab-btn" data-tab="productactions" onclick="switchTab('productactions')" style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;">Product Actions</button>
+</div>
+
+<div class="controls" id="vendorControls">
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+ <div class="filter-group" id="filterGroup">
+ <button class="filter-btn active" data-filter="all">All</button>
+ <button class="filter-btn" data-filter="private_label">Private Label</button>
+ <button class="filter-btn" data-filter="skip_shopify" style="color:#e74c3c">Skip Shopify</button>
+ <button class="filter-btn" data-filter="has_creds">Has Credentials</button>
+ <button class="filter-btn" data-filter="active">Active</button>
+ <button class="filter-btn" data-filter="HIGH">HIGH</button>
+ <button class="filter-btn" data-filter="MEDIUM">MEDIUM</button>
+ <button class="filter-btn" data-filter="LOW">LOW</button>
+ </div>
+ <input type="text" class="search-input" id="searchInput" placeholder="Search vendors..." oninput="applyFilters()">
+ </div>
+ <div class="controls-right">
+ <select class="sort-select" id="sortSelect" onchange="currentSort=this.value;currentSortDir='asc';updateSortHeaders();applyFilters()">
+ <option value="name">Sort: Name</option>
+ <option value="products">Sort: Products</option>
+ <option value="updated">Sort: Last Updated</option>
+ <option value="priority">Sort: Priority</option>
+ <option value="specs">Sort: Spec Fill (Low→High)</option>
+ </select>
+ <button class="bulk-btn" id="bulkCrawlBtn" disabled onclick="bulkAction('scan')">Crawl Selected</button>
+ <button class="bulk-btn" onclick="refreshAll()">Refresh Stats</button>
+ <button class="bulk-btn" style="background:var(--accent)" onclick="syncData()">Sync Product Counts</button>
+ <button class="bulk-btn" style="background:var(--orange-bg);color:var(--orange);border:1px solid var(--orange)" onclick="scoreAllWebsites()">Score All Websites</button>
+ <button class="bulk-btn" id="launchTestBtn" disabled style="background:linear-gradient(135deg,#22c55e,#16a34a);color:#fff;border:none;font-weight:600" onclick="launchTestSelected()">Launch 1 Test SKU</button>
+ <span id="selectedCount" style="font-size:11px;color:var(--text-muted)"></span>
+ </div>
+</div>
+
+<div class="table-container" id="vendorTab">
+ <table>
+ <thead>
+ <tr>
+ <th class="cb-cell"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)"></th>
+ <th data-sort="brands" title="Show on /pages/brands">Brands</th>
+ <th data-sort="pl" title="Private Label — resell under our brand">PL</th>
+ <th data-sort="prices" title="Show prices on Shopify listings">Show $</th>
+ <th data-sort="skipshopify" title="Do NOT push products to Shopify" style="color:#e74c3c">Skip S</th>
+ <th data-sort="sample" title="Sample order price per unit">Sample $</th>
+ <th data-sort="name" class="sort-asc">Vendor</th>
+ <th data-sort="sku" title="SKU prefix for this vendor">SKU</th>
+ <th data-sort="priority" title="Crawl priority level">Priority</th>
+ <th data-sort="status" title="Agent online/offline · Credentials · Active state">Agent</th>
+ <th data-sort="site" title="Website scrapability score (1-10)">Site Score</th>
+ <th data-sort="catalog" title="Total products in our catalog DB">In Catalog</th>
+ <th data-sort="shopify" title="Products published on Shopify">On Shopify</th>
+ <th data-sort="notlisted" title="In catalog but not yet on Shopify">Not Listed</th>
+ <th data-sort="disc" title="Draft products needing review — archive or activate">Drafts</th>
+ <th data-sort="cost" title="Products that have cost/pricing data">Has Cost</th>
+ <th data-sort="images" title="Products that have at least one image">Has Images</th>
+ <th data-sort="assets" title="Data quality: Width, Repeat, Images, About, Gallery, Spec Sheet coverage">Data Quality</th>
+ <th data-sort="specs" title="Percentage of spec fields populated across catalog">Spec %</th>
+ <th data-sort="nora" title="Nora mailer pipeline — emails received from this vendor">Nora</th>
+ <th data-sort="products_upd" title="Last time product data was updated">Products</th>
+ <th data-sort="prices_upd" title="Last time pricing was updated">Prices</th>
+ <th data-sort="images_upd" title="Last time images were updated">Images</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody id="vendorTableBody"></tbody>
+ </table>
+
+ <!-- Private Label Section -->
+ <div id="privateLabelSection" style="margin-top:32px;border-top:2px solid var(--border);padding-top:16px;display:none;">
+ <h3 style="color:var(--pink);margin:0 0 12px;">
+ <span style="font-size:16px;">Private Label Lines</span>
+ <span id="plCount" style="font-size:12px;color:var(--text-secondary);margin-left:8px;"></span>
+ </h3>
+ <p style="color:var(--text-secondary);font-size:12px;margin:0 0 12px;">These are DW-owned brands. Products already exist in Shopify — scrapers should only enrich with images/specs from real manufacturers. No new SKU assignment.</p>
+ <table class="vendor-table" style="width:100%">
+ <thead><tr>
+ <th>Brand Name</th>
+ <th>Code</th>
+ <th>SKU Prefix</th>
+ <th>Catalog</th>
+ <th>Shopify</th>
+ <th>Images</th>
+ <th>Real MFR</th>
+ </tr></thead>
+ <tbody id="plTableBody"></tbody>
+ </table>
+ </div>
+</div>
+
+<div class="calendar-container" id="calendarTab">
+ <div id="calendarContent"></div>
+</div>
+
+<div id="qualityTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
+ <h3 style="color:var(--text-primary);margin:0">Catalog Data Quality Heatmap</h3>
+ <button class="bulk-btn" style="background:var(--accent);font-size:12px;padding:6px 12px" onclick="runQualityAudit()">Run Audit</button>
+ <button class="bulk-btn" style="background:#e67e22;font-size:12px;padding:6px 12px" onclick="sendQualitySlackAlert()">Slack Alert</button>
+ <select id="qualityFilter" onchange="filterQualityHeatmap()" style="background:var(--card-bg);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
+ <option value="all">All Vendors</option>
+ <option value="critical">Critical (any field <50%)</option>
+ <option value="warning">Warning (any field <80%)</option>
+ <option value="good">Good (all fields ≥80%)</option>
+ </select>
+ <span id="qualityIssueCount" style="font-size:12px;color:var(--text-muted)"></span>
+ </div>
+ <div id="qualitySummary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:16px"></div>
+ <div id="qualityHeatmap" style="overflow-x:auto"></div>
+ <div id="qualityLoading" style="color:var(--text-muted);padding:20px;text-align:center">Loading quality data...</div>
+</div>
+
+<!-- Discontinued Tab -->
+<div id="discontinuedTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
+ <h3 style="margin:0;color:var(--text-primary);font-size:16px">Discontinuation Queue — READ ONLY</h3>
+ <div style="display:flex;gap:8px;align-items:center">
+ <select id="discVendorFilter" onchange="loadDiscontinuedData()" style="background:var(--card-bg);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
+ <option value="all">All Vendors</option>
+ </select>
+ <select id="discStatusFilter" onchange="loadDiscontinuedData()" style="background:var(--card-bg);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
+ <option value="">All Statuses</option>
+ <option value="active">Still Active on Shopify</option>
+ <option value="archived">Already Archived</option>
+ <option value="not_on_shopify">Not on Shopify</option>
+ </select>
+ <span id="discPageInfo" style="font-size:11px;color:var(--text-muted)"></span>
+ <button class="bulk-btn" style="font-size:11px;padding:4px 8px" onclick="discPrevPage()">Prev</button>
+ <button class="bulk-btn" style="font-size:11px;padding:4px 8px" onclick="discNextPage()">Next</button>
+ </div>
+ </div>
+ <div id="discSummary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:16px"></div>
+ <div id="discVendorBreakdown" style="margin-bottom:16px"></div>
+ <div id="discProducts" style="overflow-x:auto"></div>
+ <div id="discLoading" style="color:var(--text-muted);padding:20px;text-align:center">Loading discontinued data...</div>
+</div>
+
+<!-- Line Manager Tab -->
+<div id="lineManagerTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
+ <h3 style="margin:0;color:var(--text-primary);font-size:16px">Brewster/York — Brand Line Manager</h3>
+ <div style="display:flex;gap:8px;">
+ <button class="bulk-btn" onclick="lmSelectAll()">Select All</button>
+ <button class="bulk-btn" onclick="lmDeselectAll()">Deselect All</button>
+ <span style="color:var(--text-muted);font-size:12px;line-height:28px;" id="lmSelectedInfo">0 brands selected</span>
+ </div>
+ </div>
+ <div style="overflow-x:auto;margin-bottom:16px;">
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">
+ <thead>
+ <tr style="background:var(--bg-input);border-bottom:2px solid var(--border);">
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;width:40px;">☑</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Brand</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Products</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Ready</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Pushed</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Last Crawled</th>
+ <th style="padding:10px 12px;text-align:left;color:var(--text-secondary);font-size:11px;text-transform:uppercase;letter-spacing:.5px;">Actions</th>
+ </tr>
+ </thead>
+ <tbody id="lmTableBody">
+ <tr><td colspan="7" style="text-align:center;padding:30px;color:var(--text-muted);">Click tab to load...</td></tr>
+ </tbody>
+ </table>
+ </div>
+
+ <!-- Push Controls -->
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
+ <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;">
+ <div style="font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;">Launch Mode</div>
+ <label style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:var(--text-primary);cursor:pointer;">
+ <input type="radio" name="lmPushMode" value="all" checked style="accent-color:var(--accent);"> Push ALL selected now
+ </label>
+ <label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-primary);cursor:pointer;">
+ <input type="radio" name="lmPushMode" value="batched" style="accent-color:var(--accent);"> Push in batches of
+ <select id="lmBatchSize" style="padding:4px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-size:12px;">
+ <option value="10">10</option><option value="20" selected>20</option><option value="30">30</option><option value="40">40</option><option value="50">50</option>
+ </select>
+ every
+ <select id="lmDelayHours" style="padding:4px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-size:12px;">
+ <option value="1">1h</option><option value="2" selected>2h</option><option value="4">4h</option><option value="6">6h</option><option value="8">8h</option><option value="12">12h</option><option value="24">24h</option>
+ </select>
+ </label>
+ </div>
+ <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;">
+ <div style="font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;">Workflow</div>
+ <div style="font-size:12px;color:var(--text-secondary);line-height:1.8;">
+ <div><span style="color:var(--accent);font-weight:600;">1.</span> Select brands & configure batching</div>
+ <div><span style="color:var(--accent);font-weight:600;">2.</span> Click <strong style="color:var(--green);">Queue for Push</strong></div>
+ <div><span style="color:var(--accent);font-weight:600;">3.</span> Review & click <strong style="color:var(--green);">Start Push</strong></div>
+ <div><span style="color:var(--accent);font-weight:600;">4.</span> Schedule <strong style="color:var(--pink);">Monthly Cron</strong></div>
+ </div>
+ </div>
+ </div>
+
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:16px;">
+ <button id="lmBtnQueue" onclick="lmQueuePush()" style="padding:12px 28px;background:linear-gradient(135deg,#1b5e20,#2e7d32);color:#66bb6a;border:1px solid #2e7d32;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">📦 Queue for Push</button>
+ <button id="lmBtnStart" onclick="lmStartPush()" style="display:none;padding:12px 28px;background:linear-gradient(135deg,#1b5e20,#2e7d32);color:#66bb6a;border:1px solid #2e7d32;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">▶ Start Push to Shopify</button>
+ <button id="lmBtnStop" onclick="lmStopPush()" style="display:none;padding:12px 28px;background:linear-gradient(135deg,#b71c1c,#c62828);color:#ef5350;border:1px solid #c62828;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">◼ Stop</button>
+ <span style="color:var(--text-muted);font-size:13px;margin-left:auto;" id="lmPushInfo"></span>
+ </div>
+
+ <!-- Queue Review -->
+ <div id="lmQueuePanel" style="display:none;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px;">
+ <div style="font-size:14px;font-weight:700;color:var(--orange);margin-bottom:8px;">📦 Queued for Push</div>
+ <div id="lmQueueDetails" style="font-size:12px;color:var(--text-secondary);"></div>
+ </div>
+
+ <!-- Progress -->
+ <div id="lmProgressPanel" style="display:none;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
+ <span id="lmProgressTitle" style="font-size:14px;font-weight:700;color:var(--accent);">Push in Progress...</span>
+ <div style="display:flex;gap:12px;font-size:12px;">
+ <span><strong style="color:var(--green);" id="lmPsPushed">0</strong> pushed</span>
+ <span><strong style="color:var(--red);" id="lmPsErrors">0</strong> errors</span>
+ <span><strong style="color:var(--orange);" id="lmPsSkipped">0</strong> skipped</span>
+ </div>
+ </div>
+ <div style="height:14px;background:var(--bg-primary);border-radius:7px;overflow:hidden;margin-bottom:10px;">
+ <div id="lmProgressBar" style="height:100%;background:linear-gradient(90deg,#1b5e20,#66bb6a);border-radius:7px;transition:width .5s;width:0%;"></div>
+ </div>
+ <div id="lmProgressLog" style="max-height:180px;overflow-y:auto;background:var(--bg-primary);border-radius:6px;padding:10px;font-family:monospace;font-size:11px;color:var(--text-muted);line-height:1.6;"></div>
+ </div>
+
+ <!-- Cron Schedule -->
+ <div id="lmCronPanel" style="display:none;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
+ <span style="font-size:14px;font-weight:700;color:var(--pink);">📅 Monthly Schedule</span>
+ <span id="lmCronBadge" style="font-size:11px;padding:3px 10px;border-radius:10px;font-weight:600;"></span>
+ </div>
+ <div id="lmCronContent"></div>
+ </div>
+</div>
+
+<div id="tagManagerTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
+ <h2 style="margin:0;font-size:18px;color:var(--text-primary)">Tag Manager</h2>
+ <div style="display:flex;gap:8px;">
+ <button onclick="tmScan()" style="background:var(--blue);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px;">Scan Store</button>
+ <button onclick="tmBatchSubmit()" style="background:var(--accent);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px;">Submit Batch</button>
+ <button onclick="tmApplyAll()" style="background:var(--green);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px;">Apply Selected</button>
+ </div>
+ </div>
+ <div id="tmStatus" style="background:var(--bg-card);border-radius:8px;padding:12px 16px;margin-bottom:16px;display:flex;gap:24px;font-size:13px;">
+ <span>Flagged: <strong id="tmFlagged">--</strong></span>
+ <span>Pending: <strong id="tmPending" style="color:var(--orange)">--</strong></span>
+ <span>Retry: <strong id="tmRetry" style="color:var(--red)">--</strong></span>
+ <span>Analyzed: <strong id="tmAnalyzed" style="color:var(--blue)">--</strong></span>
+ <span>Applied: <strong id="tmApplied" style="color:var(--green)">--</strong></span>
+ </div>
+ <div id="tmProgress" style="display:none;background:var(--bg-card);border-radius:8px;padding:12px 16px;margin-bottom:16px;">
+ <div style="display:flex;justify-content:space-between;margin-bottom:6px;">
+ <span id="tmProgressLabel" style="font-size:12px;color:var(--text-secondary)">Working...</span>
+ <span id="tmProgressPct" style="font-size:12px;color:var(--text-primary)">0%</span>
+ </div>
+ <div style="background:var(--bg-input);border-radius:4px;height:6px;overflow:hidden;">
+ <div id="tmProgressBar" style="background:var(--accent);height:100%;width:0%;transition:width 0.3s;"></div>
+ </div>
+ </div>
+ <div style="display:flex;gap:16px;">
+ <div style="width:320px;flex-shrink:0;">
+ <table style="width:100%;border-collapse:collapse;">
+ <thead><tr style="border-bottom:1px solid var(--border);">
+ <th style="text-align:left;padding:8px;font-size:11px;color:var(--text-muted);width:30px;"><input type="checkbox" id="tmSelectAll" onchange="tmToggleAll(this.checked)"></th>
+ <th style="text-align:left;padding:8px;font-size:11px;color:var(--text-muted);">Vendor</th>
+ <th style="text-align:right;padding:8px;font-size:11px;color:var(--text-muted);">Flagged</th>
+ <th style="text-align:right;padding:8px;font-size:11px;color:var(--text-muted);">Done</th>
+ </tr></thead>
+ <tbody id="tmVendorBody"></tbody>
+ </table>
+ </div>
+ <div style="flex:1;min-width:0;">
+ <div id="tmPreviewHeader" style="font-size:13px;color:var(--text-secondary);margin-bottom:8px;">Select a vendor to preview tags</div>
+ <div id="tmPreviewBody" style="max-height:600px;overflow-y:auto;"></div>
+ </div>
+ </div>
+</div>
+
+<!-- STANDARDIZATION ENGINE TAB -->
+<div id="standardizeTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
+ <div>
+ <h2 style="margin:0;font-size:18px;color:var(--text-primary);">Product Standardization Engine</h2>
+ <p style="margin:4px 0 0;font-size:12px;color:var(--text-muted);">Gemini Vision Analysis → Metafields → Tags → Title Case → SEO</p>
+ </div>
+ <div style="display:flex;gap:8px;align-items:center;">
+ <span id="stdEngineStatus" style="padding:4px 12px;border-radius:12px;font-size:11px;font-weight:700;background:var(--bg-input);color:var(--text-muted);">IDLE</span>
+ </div>
+ </div>
+
+ <!-- Engine Controls -->
+ <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px;">
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
+ <select id="stdVendorSelect" style="padding:8px 12px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:13px;min-width:250px;">
+ <option value="">Select vendor...</option>
+ </select>
+ <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer;">
+ <input type="checkbox" id="stdAnalyzeOnly"> Analyze Only (don't apply)
+ </label>
+ <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer;">
+ Limit: <input type="number" id="stdLimit" value="2" min="1" max="50000" style="width:70px;padding:4px 8px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:4px;font-size:12px;">
+ </label>
+ <button id="stdStartBtn" onclick="stdStart()" style="padding:8px 20px;background:linear-gradient(135deg,#1b5e20,#2e7d32);color:#66bb6a;border:1px solid #2e7d32;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;">▶ START</button>
+ <button id="stdStopBtn" onclick="stdStop()" style="display:none;padding:8px 20px;background:linear-gradient(135deg,#b71c1c,#c62828);color:#ef5350;border:1px solid #c62828;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;">◼ STOP</button>
+ </div>
+ <!-- Progress Bar -->
+ <div id="stdProgressWrap" style="display:none;margin-top:12px;">
+ <div style="display:flex;justify-content:space-between;margin-bottom:4px;">
+ <span id="stdProgressLabel" style="font-size:12px;color:var(--text-secondary);">Processing...</span>
+ <span id="stdProgressPct" style="font-size:12px;color:var(--text-primary);">0%</span>
+ </div>
+ <div style="background:var(--bg-input);border-radius:4px;height:8px;overflow:hidden;">
+ <div id="stdProgressBar" style="background:linear-gradient(90deg,#7c3aed,#2563eb);height:100%;width:0%;transition:width 0.5s;border-radius:4px;"></div>
+ </div>
+ <div style="display:flex;gap:20px;margin-top:8px;font-size:12px;">
+ <span>Analyzed: <strong id="stdAnalyzedCount" style="color:var(--blue);">0</strong></span>
+ <span>Applied: <strong id="stdAppliedCount" style="color:var(--green);">0</strong></span>
+ <span>Errors: <strong id="stdErrorCount" style="color:var(--red);">0</strong></span>
+ <span>Skipped: <strong id="stdSkippedCount" style="color:var(--text-muted);">0</strong></span>
+ <span>Current: <em id="stdCurrentProduct" style="color:var(--text-secondary);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;">--</em></span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Vendor Progress Table -->
+ <div style="display:flex;gap:16px;">
+ <div style="flex:1;min-width:0;">
+ <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:10px;overflow:hidden;">
+ <table style="width:100%;border-collapse:collapse;">
+ <thead>
+ <tr style="border-bottom:1px solid var(--border);">
+ <th id="stdTh-vendor" onclick="stdSortBy('vendor')" style="text-align:left;padding:10px 12px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Vendor <span class="std-sort-ind"></span></th>
+ <th id="stdTh-shopify" onclick="stdSortBy('shopify')" style="text-align:right;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Shopify <span class="std-sort-ind"></span></th>
+ <th id="stdTh-scanned" onclick="stdSortBy('scanned')" style="text-align:right;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Scanned <span class="std-sort-ind"></span></th>
+ <th id="stdTh-analyzed" onclick="stdSortBy('analyzed')" style="text-align:center;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Analyzed <span class="std-sort-ind"></span></th>
+ <th id="stdTh-applied" onclick="stdSortBy('applied')" style="text-align:center;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Applied <span class="std-sort-ind"></span></th>
+ <th id="stdTh-pct" onclick="stdSortBy('pct')" style="text-align:center;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">% <span class="std-sort-ind"></span></th>
+ <th id="stdTh-lastrun" onclick="stdSortBy('lastrun')" style="text-align:right;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Last Run <span class="std-sort-ind"></span></th>
+ <th id="stdTh-status" onclick="stdSortBy('status')" style="text-align:center;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;">Status <span class="std-sort-ind"></span></th>
+ <th id="stdTh-discount" onclick="stdSortBy('discount')" style="text-align:center;padding:10px 8px;font-size:11px;color:var(--text-muted);font-weight:600;cursor:pointer;user-select:none;" title="Our confirmed vendor discount. Green = confirmed.">Disc % <span class="std-sort-ind"></span></th>
+ </tr>
+ </thead>
+ <tbody id="stdVendorBody"></tbody>
+ </table>
+ </div>
+ </div>
+
+ <!-- Live Log -->
+ <div style="width:360px;flex-shrink:0;">
+ <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:12px;height:500px;overflow-y:auto;font-family:monospace;font-size:11px;line-height:1.6;" id="stdLogPanel">
+ <div style="color:var(--text-muted);">Engine log will appear here...</div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Preview Panel -->
+ <div id="stdPreviewPanel" style="display:none;margin-top:16px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
+ <h3 id="stdPreviewTitle" style="margin:0;font-size:15px;color:var(--text-primary);">Preview</h3>
+ <button onclick="document.getElementById('stdPreviewPanel').style.display='none'" style="background:none;border:none;color:var(--text-muted);font-size:18px;cursor:pointer;">×</button>
+ </div>
+ <div id="stdPreviewBody" style="max-height:400px;overflow-y:auto;"></div>
+ </div>
+</div>
+
+<!-- New Launches Tab -->
+<div id="newLaunchesTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
+ <div>
+ <h2 style="margin:0;font-size:18px;color:var(--text-primary);">New Product Launches</h2>
+ <p style="margin:4px 0 0;font-size:12px;color:var(--text-muted);">Recently created active products on Shopify, grouped by date</p>
+ </div>
+ <div style="display:flex;gap:10px;align-items:center;">
+ <select id="nlDateRange" style="padding:8px 12px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:13px;">
+ <option value="1">Today</option>
+ <option value="3">Last 3 Days</option>
+ <option value="7" selected>Last 7 Days</option>
+ <option value="14">Last 14 Days</option>
+ <option value="30">Last 30 Days</option>
+ <option value="60">Last 60 Days</option>
+ <option value="90">Last 90 Days</option>
+ </select>
+ <span id="nlCount" style="font-size:13px;color:var(--accent);font-weight:700;"></span>
+ <span id="nlLoading" style="display:none;font-size:12px;color:var(--text-muted);">Loading...</span>
+ </div>
+ </div>
+ <div id="nlContent"></div>
+</div>
+
+<!-- Product Actions Tab -->
+<div id="productActionsTab" style="display:none;padding:16px 0;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
+ <div>
+ <h2 style="margin:0;font-size:18px;color:var(--text-primary);">Product Bulk Actions</h2>
+ <p style="margin:4px 0 0;font-size:12px;color:var(--text-muted);">Search products, select, and apply bulk actions (Archive, Draft, Active, Redirect)</p>
+ </div>
+ </div>
+
+ <!-- Search Bar -->
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:16px;flex-wrap:wrap;">
+ <input type="text" id="paSearchInput" placeholder="Search by title, vendor, handle, SKU..." style="flex:1;min-width:300px;padding:10px 14px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:13px;" onkeydown="if(event.key==='Enter')paSearch()">
+ <select id="paStatusFilter" style="padding:10px 12px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:13px;">
+ <option value="">All Statuses</option>
+ <option value="ACTIVE">Active</option>
+ <option value="DRAFT">Draft</option>
+ <option value="ARCHIVED">Archived</option>
+ </select>
+ <button onclick="paSearch()" style="padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px;">Search</button>
+ <span id="paResultCount" style="font-size:12px;color:var(--text-muted);"></span>
+ <span id="paSearching" style="display:none;font-size:12px;color:var(--text-muted);">Searching...</span>
+ </div>
+
+ <!-- Bulk Action Bar (sticky) -->
+ <div id="paBulkBar" style="display:none;position:sticky;top:0;z-index:20;background:var(--bg-surface);border:1px solid var(--accent);border-radius:8px;padding:10px 16px;margin-bottom:12px;display:none;align-items:center;gap:12px;flex-wrap:wrap;">
+ <span id="paSelectedCount" style="font-size:13px;font-weight:600;color:var(--accent);min-width:100px;"></span>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;">
+ <button onclick="paBulkStatus('archived')" style="padding:6px 14px;background:rgba(248,81,73,0.12);color:#f85149;border:1px solid rgba(248,81,73,0.3);border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">Archive Selected</button>
+ <button onclick="paBulkStatus('draft')" style="padding:6px 14px;background:rgba(210,153,34,0.12);color:#d29922;border:1px solid rgba(210,153,34,0.3);border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">Draft Selected</button>
+ <button onclick="paBulkStatus('active')" style="padding:6px 14px;background:rgba(63,185,80,0.12);color:#3fb950;border:1px solid rgba(63,185,80,0.3);border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">Activate Selected</button>
+ <div style="display:flex;gap:4px;align-items:center;">
+ <input type="text" id="paRedirectTarget" placeholder="Redirect URL (e.g. /collections/all)" style="padding:6px 10px;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;font-size:12px;width:250px;">
+ <button onclick="paBulkRedirect()" style="padding:6px 14px;background:rgba(188,140,255,0.12);color:#bc8cff;border:1px solid rgba(188,140,255,0.3);border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">Redirect Selected</button>
+ </div>
+ </div>
+ <span id="paBulkProgress" style="font-size:11px;color:var(--text-muted);margin-left:auto;"></span>
+ </div>
+
+ <!-- Results Table -->
+ <div style="overflow-x:auto;">
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">
+ <thead>
+ <tr style="border-bottom:2px solid var(--border);">
+ <th style="padding:8px;text-align:left;width:30px;"><input type="checkbox" id="paSelectAll" onchange="paToggleAll(this.checked)"></th>
+ <th style="padding:8px;text-align:left;width:60px;">Image</th>
+ <th style="padding:8px;text-align:left;">Title</th>
+ <th style="padding:8px;text-align:left;">Vendor</th>
+ <th style="padding:8px;text-align:left;width:90px;">Status</th>
+ <th style="padding:8px;text-align:left;width:120px;">SKU</th>
+ <th style="padding:8px;text-align:left;width:100px;">Type</th>
+ <th style="padding:8px;text-align:left;width:130px;">Updated</th>
+ </tr>
+ </thead>
+ <tbody id="paTableBody"></tbody>
+ </table>
+ </div>
+
+ <!-- Pagination -->
+ <div id="paPagination" style="display:flex;justify-content:center;gap:8px;margin-top:16px;"></div>
+</div>
+
+<script>
+// State
+var vendors = [];
+var stats = {};
+var activeFilter = 'all';
+var expandedRow = null;
+var selectedVendors = {};
+var agentHealthCache = {};
+var currentSort = 'name';
+var currentSortDir = 'asc';
+
+// Close data quality popovers when clicking outside
+document.addEventListener('click', function() {
+ var pops = document.querySelectorAll('.dq-popover.show');
+ for (var i = 0; i < pops.length; i++) pops[i].classList.remove('show');
+});
+
+function updateSortHeaders() {
+ var allTh = document.querySelectorAll('#vendorTab th[data-sort]');
+ for (var i = 0; i < allTh.length; i++) {
+ allTh[i].classList.remove('sort-asc', 'sort-desc');
+ if (allTh[i].getAttribute('data-sort') === currentSort) {
+ allTh[i].classList.add(currentSortDir === 'asc' ? 'sort-asc' : 'sort-desc');
+ }
+ }
+}
+
+// Escape HTML for safe DOM insertion
+function esc(s) {
+ if (s === null || s === undefined) return '';
+ var d = document.createElement('div');
+ d.appendChild(document.createTextNode(String(s)));
+ return d.innerHTML;
+}
+
+// Toast notifications
+// Stat card click — open modal with vendor breakdown sorted by that stat
+function openStatModal(statKey, title, sortField) {
+ // Remove any existing modal
+ var existing = document.querySelector('.stat-modal-overlay');
+ if (existing) existing.remove();
+
+ // Build modal
+ var overlay = document.createElement('div');
+ overlay.className = 'stat-modal-overlay';
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
+
+ var modal = document.createElement('div');
+ modal.className = 'stat-modal';
+
+ var header = document.createElement('div');
+ header.className = 'stat-modal-header';
+ var h3 = document.createElement('h3');
+ h3.textContent = title + ' — Vendor Breakdown';
+ header.appendChild(h3);
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'stat-modal-close';
+ closeBtn.textContent = '\u2715';
+ closeBtn.addEventListener('click', function() { overlay.remove(); });
+ header.appendChild(closeBtn);
+ modal.appendChild(header);
+
+ var body = document.createElement('div');
+ body.className = 'stat-modal-body';
+
+ // Sort vendors by the relevant field
+ var sorted = (vendors || []).slice().sort(function(a, b) {
+ var va = parseFloat(a[sortField]) || 0;
+ var vb = parseFloat(b[sortField]) || 0;
+ return vb - va;
+ });
+
+ // Build table
+ var table = document.createElement('table');
+ var thead = document.createElement('thead');
+ var headerRow = document.createElement('tr');
+ var cols = [
+ { key: 'vendor_name', label: 'Vendor' },
+ { key: 'catalog_count', label: 'Catalog' },
+ { key: 'shopify_count', label: 'On Shopify' },
+ { key: 'catalog_not_on_shopify', label: 'Not Listed' },
+ { key: 'products_with_cost', label: 'Has Cost' },
+ { key: 'products_with_images', label: 'Has Images' },
+ { key: 'catalog_with_width', label: 'Width' },
+ { key: 'catalog_with_repeat', label: 'Repeat' },
+ { key: 'spec_completeness', label: 'Spec %' }
+ ];
+ // Add special columns based on stat
+ if (statKey === 'to_disc') {
+ cols.splice(3, 0, { key: 'shopify_draft', label: 'Draft' }, { key: 'shopify_archived', label: 'Archived' });
+ }
+
+ var currentSort = { key: sortField, desc: true };
+
+ function renderModalTable() {
+ // Clear existing rows
+ var existingTbody = table.querySelector('tbody');
+ if (existingTbody) existingTbody.remove();
+
+ var tbody = document.createElement('tbody');
+ var total = 0;
+ for (var i = 0; i < sorted.length; i++) {
+ var v = sorted[i];
+ var primaryVal = parseFloat(v[sortField]) || 0;
+ if (statKey !== 'total_vendors' && statKey !== 'agents_online' && primaryVal === 0) continue;
+ total += primaryVal;
+ var tr = document.createElement('tr');
+ tr.style.cursor = 'pointer';
+ tr.addEventListener('click', (function(code) {
+ return function() { overlay.remove(); scrollToVendor(code); };
+ })(v.vendor_code));
+ for (var c = 0; c < cols.length; c++) {
+ var td = document.createElement('td');
+ var val = v[cols[c].key];
+ if (cols[c].key === 'vendor_name') {
+ td.textContent = val;
+ td.style.fontWeight = '600';
+ } else if (cols[c].key === 'spec_completeness') {
+ td.textContent = (parseFloat(val) || 0).toFixed(1) + '%';
+ } else {
+ td.textContent = Number(val || 0).toLocaleString();
+ if (parseFloat(val) > 0) td.style.color = 'var(--accent)';
+ else td.style.color = 'var(--text-muted)';
+ }
+ tr.appendChild(td);
+ }
+ tbody.appendChild(tr);
+ }
+ // Total row
+ var totalTr = document.createElement('tr');
+ totalTr.style.cssText = 'font-weight:700;border-top:2px solid var(--border);';
+ for (var c2 = 0; c2 < cols.length; c2++) {
+ var totalTd = document.createElement('td');
+ if (c2 === 0) totalTd.textContent = 'TOTAL';
+ else if (cols[c2].key === sortField) { totalTd.textContent = Number(total).toLocaleString(); totalTd.style.color = 'var(--accent)'; }
+ else totalTd.textContent = '';
+ totalTr.appendChild(totalTd);
+ }
+ tbody.appendChild(totalTr);
+ table.appendChild(tbody);
+ }
+
+ // Column header clicks for re-sorting
+ for (var c = 0; c < cols.length; c++) {
+ var th = document.createElement('th');
+ th.textContent = cols[c].label;
+ th.setAttribute('data-key', cols[c].key);
+ if (cols[c].key === sortField) th.style.color = 'var(--accent)';
+ th.addEventListener('click', (function(key) {
+ return function() {
+ if (currentSort.key === key) currentSort.desc = !currentSort.desc;
+ else { currentSort.key = key; currentSort.desc = true; }
+ sorted.sort(function(a, b) {
+ var va = key === 'vendor_name' ? (a[key] || '') : (parseFloat(a[key]) || 0);
+ var vb = key === 'vendor_name' ? (b[key] || '') : (parseFloat(b[key]) || 0);
+ if (key === 'vendor_name') return currentSort.desc ? vb.localeCompare(va) : va.localeCompare(vb);
+ return currentSort.desc ? vb - va : va - vb;
+ });
+ // Update header highlights
+ headerRow.querySelectorAll('th').forEach(function(t) { t.style.color = t.getAttribute('data-key') === key ? 'var(--accent)' : ''; });
+ renderModalTable();
+ };
+ })(cols[c].key));
+ headerRow.appendChild(th);
+ }
+ thead.appendChild(headerRow);
+ table.appendChild(thead);
+ renderModalTable();
+
+ body.appendChild(table);
+ modal.appendChild(body);
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+}
+
+// Scroll to vendor row in table
+function scrollToVendor(code) {
+ // Find the row and expand it
+ var rows = document.querySelectorAll('#vendorTab tbody tr:not(.drill-down)');
+ for (var i = 0; i < rows.length; i++) {
+ var cb = rows[i].querySelector('input[data-code="' + code + '"]');
+ if (cb) {
+ rows[i].scrollIntoView({ behavior: 'smooth', block: 'center' });
+ rows[i].style.background = 'rgba(139,92,246,0.15)';
+ setTimeout(function(r) { return function() { r.style.background = ''; }; }(rows[i]), 2000);
+ toggleExpand(code);
+ return;
+ }
+ }
+}
+
+function showToast(msg, type) {
+ type = type || 'info';
+ var container = document.getElementById('toasts');
+ var el = document.createElement('div');
+ el.className = 'toast ' + type;
+ el.textContent = msg;
+ container.appendChild(el);
+ setTimeout(function() { el.remove(); }, 4000);
+}
+
+// Sync Now button handler
+function syncNow() {
+ var btn = document.getElementById('syncNowBtn');
+ btn.textContent = 'Syncing...';
+ btn.disabled = true;
+ btn.style.opacity = '0.6';
+ fetch('/api/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
+ .then(function(r) { return r.json(); })
+ .then(function(res) {
+ btn.textContent = 'Sync Now';
+ btn.disabled = false;
+ btn.style.opacity = '1';
+ if (res.success) {
+ showToast('Sync complete: ' + res.updated + ' vendors updated in ' + res.elapsed + 's', 'success');
+ // Reload data immediately
+ Promise.all([loadVendors(), loadStats()]).then(function() {
+ var now = formatTimePT(new Date());
+ document.getElementById('lastRefresh').textContent = 'Last refresh: ' + now;
+ document.getElementById('lastUpdatedTs').textContent = now;
+ });
+ } else {
+ showToast('Sync failed: ' + (res.error || 'unknown'), 'error');
+ }
+ })
+ .catch(function(err) {
+ btn.textContent = 'Sync Now';
+ btn.disabled = false;
+ btn.style.opacity = '1';
+ showToast('Sync error: ' + err.message, 'error');
+ });
+}
+
+// API fetch helper — adds explicit Basic Auth to avoid Chrome credential-in-URL blocking
+function apiCall(path, options) {
+ options = options || {};
+ options.headers = options.headers || {};
+ options.headers['Content-Type'] = 'application/json';
+ options.headers['Authorization'] = '' /* auth via httpOnly vcc_session cookie — no embedded credential */;
+ // Use clean origin (without embedded credentials) for absolute URL construction
+ var cleanUrl = window.location.protocol + '//' + window.location.host + path;
+ return fetch(cleanUrl, options).then(function(r) { return r.json(); });
+}
+
+// Load vendors
+function loadVendors() {
+ return apiCall('/api/vendors').then(function(res) {
+ if (res.success) {
+ vendors = res.data;
+ applyFilters();
+ }
+ }).catch(function(err) {
+ showToast('Failed to load vendors: ' + err.message, 'error');
+ });
+}
+
+// Load stats
+function loadStats() {
+ return apiCall('/api/stats').then(function(res) {
+ if (res.success) {
+ stats = res.data;
+ document.getElementById('statTotalVendors').textContent = stats.total_vendors;
+ document.getElementById('statTotalProducts').textContent = Number(stats.total_catalog || stats.total_products).toLocaleString();
+ document.getElementById('statShopifyCount').textContent = Number(stats.total_shopify || 0).toLocaleString();
+ document.getElementById('statWithCost').textContent = Number(stats.total_with_cost).toLocaleString();
+ document.getElementById('statCoverage').textContent = stats.cost_coverage_pct + '%';
+ document.getElementById('statWithImages').textContent = Number(stats.total_with_images).toLocaleString();
+ document.getElementById('statMissingImages').textContent = Number(stats.total_not_on_shopify || stats.missing_images || 0).toLocaleString();
+ document.getElementById('statToDisc').textContent = Number(stats.drafts_needing_review || 0).toLocaleString();
+ document.getElementById('statWithWidth').textContent = Number(stats.total_with_width || 0).toLocaleString();
+ document.getElementById('statWithRepeat').textContent = Number(stats.total_with_repeat || 0).toLocaleString();
+ document.getElementById('statSpecCompleteness').textContent = (stats.avg_spec_completeness || 0) + '%';
+ document.getElementById('statCreds').textContent = stats.with_credentials;
+ // Missing specs alert
+ var specAlertEl = document.getElementById('missingSpecsAlert');
+ var below50 = parseInt(stats.vendors_below_50_spec) || 0;
+ if (below50 > 0) {
+ specAlertEl.style.display = 'block';
+ specAlertEl.textContent = '\u26A0 ' + below50 + ' vendor' + (below50 > 1 ? 's' : '') + ' below 50% spec fill rate — click Specs column for details';
+ } else {
+ specAlertEl.style.display = 'none';
+ }
+ document.getElementById('statPL').textContent = stats.private_label_count;
+ document.getElementById('statSkipShopify').textContent = stats.skip_shopify_count || 0;
+ document.getElementById('statNextCrawl').textContent = stats.next_crawl ? fmtDateFull(stats.next_crawl) : 'None';
+ // Agents online count is updated from vendors data
+ var onlineCount = vendors.filter(function(v) { return v.agent_status === 'online'; }).length;
+ document.getElementById('statAgentsOnline').textContent = onlineCount + ' / ' + stats.total_vendors;
+ }
+ }).catch(function() {});
+}
+
+function refreshAll() {
+ showToast('Refreshing data...', 'info');
+ Promise.all([loadVendors(), loadStats()]).then(function() {
+ document.getElementById('lastRefresh').textContent = 'Last refresh: ' + formatTimePT(new Date());
+ showToast('Data refreshed', 'success');
+ });
+}
+
+function syncData() {
+ showToast('Syncing product counts from Shopify + catalog tables...', 'info');
+ apiCall('/api/sync', { method: 'POST' }).then(function(res) {
+ if (res.success) {
+ showToast('Sync complete: ' + res.updated + ' vendors updated in ' + res.elapsed + 's', 'success');
+ refreshAll();
+ } else {
+ showToast('Sync failed: ' + (res.error || 'Unknown error'), 'error');
+ }
+ });
+}
+
+// Spec details modal
+function showSpecDetails(code) {
+ var overlay = document.getElementById('specModalOverlay');
+ var title = document.getElementById('specModalTitle');
+ var summary = document.getElementById('specModalSummary');
+ var body = document.getElementById('specModalBody');
+ title.textContent = 'Loading...';
+ summary.textContent = '';
+ while (body.firstChild) body.removeChild(body.firstChild);
+ overlay.classList.add('show');
+ apiCall('/api/spec-completeness/' + encodeURIComponent(code)).then(function(res) {
+ if (!res.success) { title.textContent = 'Error'; summary.textContent = res.error; return; }
+ title.textContent = res.vendor + ' — Spec Completeness';
+ summary.textContent = res.present + ' of ' + res.total + ' required fields present (' + res.pct + '%)';
+ var fields = res.fields || {};
+ var fieldNames = Object.keys(fields);
+ for (var i = 0; i < fieldNames.length; i++) {
+ var f = fieldNames[i];
+ var info = fields[f];
+ var statusClass = info.status === 'present'
+ ? (info.filled === 0 ? 'empty' : 'present')
+ : 'missing';
+ var statusText = info.status === 'present'
+ ? (info.filled + '/' + info.total + ' filled' + (info.column !== f ? ' (as ' + info.column + ')' : ''))
+ : 'Column missing';
+ var row = document.createElement('div');
+ row.className = 'spec-field-row';
+ var nameSpan = document.createElement('span');
+ nameSpan.className = 'spec-field-name';
+ nameSpan.textContent = f;
+ var statusSpan = document.createElement('span');
+ statusSpan.className = 'spec-field-status ' + statusClass;
+ statusSpan.textContent = statusText;
+ row.appendChild(nameSpan);
+ row.appendChild(statusSpan);
+ body.appendChild(row);
+ }
+ }).catch(function(err) {
+ title.textContent = 'Error';
+ summary.textContent = err.message;
+ });
+}
+
+function closeSpecModal(e) {
+ if (e.target === document.getElementById('specModalOverlay')) {
+ document.getElementById('specModalOverlay').classList.remove('show');
+ }
+}
+
+function scoreAllWebsites() {
+ showToast('Scoring all vendor websites in background (stale/unscored only)...', 'info');
+ apiCall('/api/score-all', { method: 'POST', body: JSON.stringify({ stale_only: true }) }).then(function(res) {
+ if (res.success) {
+ showToast('Scoring ' + res.count + ' vendors in background. Refresh in a few minutes.', 'success');
+ } else {
+ showToast('Score-all failed: ' + (res.error || 'Unknown error'), 'error');
+ }
+ });
+}
+
+// Filters
+function applyFilters() {
+ var search = document.getElementById('searchInput').value.toLowerCase();
+
+ var filtered = vendors.filter(function(v) {
+ if (activeFilter === 'all') return true;
+ if (activeFilter === 'private_label') return v.is_private_label;
+ if (activeFilter === 'skip_shopify') return v.skip_shopify;
+ if (activeFilter === 'has_creds') return v.has_credentials;
+ if (activeFilter === 'active') return v.is_active;
+ if (activeFilter === 'HIGH' || activeFilter === 'MEDIUM' || activeFilter === 'LOW') return v.crawl_priority === activeFilter;
+ return true;
+ });
+
+ if (search) {
+ filtered = filtered.filter(function(v) {
+ return v.vendor_name.toLowerCase().indexOf(search) >= 0 ||
+ v.vendor_code.toLowerCase().indexOf(search) >= 0 ||
+ (v.agent_name || '').toLowerCase().indexOf(search) >= 0 ||
+ (v.sku_prefix || '').toLowerCase().indexOf(search) >= 0;
+ });
+ }
+
+ // Use column-header sort (currentSort/currentSortDir) or fallback to dropdown
+ var sort = currentSort || document.getElementById('sortSelect').value;
+ var dir = currentSortDir || 'asc';
+ var priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
+ var statusOrder = { online: 0, offline: 1, 'no-port': 2 };
+
+ filtered.sort(function(a, b) {
+ var cmp = 0;
+ switch (sort) {
+ case 'name': cmp = (a.vendor_name || '').localeCompare(b.vendor_name || ''); break;
+ case 'brands': cmp = (a.show_on_brands_page ? 1 : 0) - (b.show_on_brands_page ? 1 : 0); break;
+ case 'pl': cmp = (a.is_private_label ? 1 : 0) - (b.is_private_label ? 1 : 0); break;
+ case 'prices': cmp = (a.display_prices !== false ? 1 : 0) - (b.display_prices !== false ? 1 : 0); break;
+ case 'skipshopify': cmp = (a.skip_shopify ? 1 : 0) - (b.skip_shopify ? 1 : 0); break;
+ case 'sample': cmp = (parseFloat(a.sample_price) || 4.25) - (parseFloat(b.sample_price) || 4.25); break;
+ case 'sku': cmp = (a.sku_prefix || '').localeCompare(b.sku_prefix || ''); break;
+ case 'priority': cmp = (priorityOrder[a.crawl_priority] || 1) - (priorityOrder[b.crawl_priority] || 1); break;
+ case 'status': cmp = (statusOrder[a.agent_status] || 2) - (statusOrder[b.agent_status] || 2); break;
+ case 'site': cmp = (a.website_score || 0) - (b.website_score || 0); break;
+ case 'catalog':
+ case 'products': cmp = (a.catalog_count || a.total_products || 0) - (b.catalog_count || b.total_products || 0); break;
+ case 'shopify': cmp = (a.shopify_count || 0) - (b.shopify_count || 0); break;
+ case 'notlisted': cmp = (a.catalog_not_on_shopify || 0) - (b.catalog_not_on_shopify || 0); break;
+ case 'disc': cmp = (a.shopify_draft || 0) - (b.shopify_draft || 0); break;
+ case 'cost': cmp = (a.products_with_cost || 0) - (b.products_with_cost || 0); break;
+ case 'images': cmp = (a.products_with_images || 0) - (b.products_with_images || 0); break;
+ case 'assets':
+ var aTotal = a.catalog_count || a.total_products || 1;
+ var bTotal = b.catalog_count || b.total_products || 1;
+ var aScore = ((a.catalog_with_width || 0) + (a.catalog_with_repeat || 0) + (a.products_with_images || 0)) / aTotal;
+ var bScore = ((b.catalog_with_width || 0) + (b.catalog_with_repeat || 0) + (b.products_with_images || 0)) / bTotal;
+ cmp = aScore - bScore;
+ break;
+ case 'specs': cmp = (parseFloat(a.spec_completeness) || 0) - (parseFloat(b.spec_completeness) || 0); break;
+ case 'nora': cmp = (a.nora_mailers || 0) - (b.nora_mailers || 0); break;
+ case 'products_upd':
+ case 'updated':
+ var dpa = a.last_product_update ? new Date(a.last_product_update).getTime() : 0;
+ var dpb = b.last_product_update ? new Date(b.last_product_update).getTime() : 0;
+ cmp = dpa - dpb;
+ break;
+ case 'prices_upd':
+ var dpra = a.last_price_update ? new Date(a.last_price_update).getTime() : 0;
+ var dprb = b.last_price_update ? new Date(b.last_price_update).getTime() : 0;
+ cmp = dpra - dprb;
+ break;
+ case 'images_upd':
+ var dia = a.last_image_update ? new Date(a.last_image_update).getTime() : 0;
+ var dib = b.last_image_update ? new Date(b.last_image_update).getTime() : 0;
+ cmp = dia - dib;
+ break;
+ default: cmp = 0;
+ }
+ return dir === 'desc' ? -cmp : cmp;
+ });
+
+ renderTable(filtered);
+}
+
+// Filter buttons
+document.getElementById('filterGroup').addEventListener('click', function(e) {
+ if (e.target.classList.contains('filter-btn')) {
+ var btns = document.querySelectorAll('.filter-btn');
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
+ e.target.classList.add('active');
+ activeFilter = e.target.getAttribute('data-filter');
+ applyFilters();
+ }
+});
+
+// Sortable column headers — click any th[data-sort] to sort
+document.querySelector('#vendorTab thead').addEventListener('click', function(e) {
+ var th = e.target.closest('th[data-sort]');
+ if (!th) return;
+ var sortKey = th.getAttribute('data-sort');
+ if (currentSort === sortKey) {
+ currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort = sortKey;
+ currentSortDir = 'asc';
+ }
+ // Update header classes
+ var allTh = document.querySelectorAll('#vendorTab th[data-sort]');
+ for (var i = 0; i < allTh.length; i++) {
+ allTh[i].classList.remove('sort-asc', 'sort-desc');
+ }
+ th.classList.add(currentSortDir === 'asc' ? 'sort-asc' : 'sort-desc');
+ // Sync dropdown if matching
+ var sel = document.getElementById('sortSelect');
+ if (sel) {
+ for (var j = 0; j < sel.options.length; j++) {
+ if (sel.options[j].value === sortKey) { sel.selectedIndex = j; break; }
+ }
+ }
+ applyFilters();
+});
+
+// Render table
+function renderTable(list) {
+ var tbody = document.getElementById('vendorTableBody');
+ // Clear using safe method
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+
+ // Separate private label vendors
+ var regularVendors = [];
+ var plVendors = [];
+ for (var pi = 0; pi < list.length; pi++) {
+ if (list[pi].is_private_label) plVendors.push(list[pi]);
+ else regularVendors.push(list[pi]);
+ }
+
+ // Render private label section
+ var plBody = document.getElementById('plTableBody');
+ var plSection = document.getElementById('privateLabelSection');
+ if (plBody) {
+ while (plBody.firstChild) plBody.removeChild(plBody.firstChild);
+ document.getElementById('plCount').textContent = '(' + plVendors.length + ' brands)';
+ plSection.style.display = plVendors.length > 0 ? 'block' : 'none';
+ for (var pli = 0; pli < plVendors.length; pli++) {
+ var pv = plVendors[pli];
+ var plTr = document.createElement('tr');
+ plTr.style.cursor = 'pointer';
+ plTr.addEventListener('click', (function(code) { return function() { toggleExpand(code); }; })(pv.vendor_code));
+ var imgPct = pv.total_products > 0 ? Math.round((pv.products_with_images || 0) / pv.total_products * 100) : 0;
+
+ // Build PL row cells using DOM methods — SKU cell shows prefix + series range
+ var plSeriesText = pv.sku_prefix || '\u2014';
+ if (pv.sku_range_start != null) {
+ var plRangeStart = parseInt(pv.sku_range_start, 10);
+ var plItemCount = parseInt(pv.catalog_count || pv.total_products || 0, 10);
+ plSeriesText = (pv.sku_prefix || '') + plRangeStart.toLocaleString() + ' \u2013 ' + (plRangeStart + 4999).toLocaleString();
+ if (plItemCount > 0) plSeriesText += ' (' + plItemCount.toLocaleString() + ')';
+ }
+ var plCells = [
+ { text: pv.vendor_name, style: 'padding:8px 12px;font-weight:500;' },
+ { text: pv.vendor_code, style: 'padding:8px 12px;color:var(--text-secondary);font-size:12px;' },
+ { text: plSeriesText, style: 'padding:8px 12px;font-family:monospace;font-size:11px;white-space:nowrap;' },
+ { text: (pv.catalog_count || pv.total_products || 0).toLocaleString(), style: 'padding:8px 12px;text-align:right;' },
+ { text: (pv.shopify_count || 0).toLocaleString(), style: 'padding:8px 12px;text-align:right;' },
+ { text: (pv.products_with_images || 0).toLocaleString() + ' (' + imgPct + '%)', style: 'padding:8px 12px;text-align:right;' }
+ ];
+ for (var ci = 0; ci < plCells.length; ci++) {
+ var plTd = document.createElement('td');
+ plTd.style.cssText = plCells[ci].style;
+ plTd.textContent = plCells[ci].text;
+ plTr.appendChild(plTd);
+ }
+
+ // Real MFR input cell
+ var mfrTd = document.createElement('td');
+ mfrTd.style.cssText = 'padding:8px 12px;';
+ var mfrInput = document.createElement('input');
+ mfrInput.type = 'text';
+ mfrInput.value = pv.private_label_name || '';
+ mfrInput.placeholder = 'Real manufacturer...';
+ mfrInput.style.cssText = 'background:var(--bg-input,#1a1a2e);border:1px solid var(--border);color:var(--text-primary);padding:4px 8px;border-radius:4px;width:150px;font-size:12px;';
+ mfrInput.setAttribute('data-code', pv.vendor_code);
+ mfrInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ mfrInput.addEventListener('blur', (function(code) {
+ return function(e) { updatePLName(code, e.target.value); };
+ })(pv.vendor_code));
+ mfrTd.appendChild(mfrInput);
+ plTr.appendChild(mfrTd);
+
+ plBody.appendChild(plTr);
+ }
+ }
+
+ // Now render only regular vendors in main table
+ list = regularVendors;
+
+ for (var i = 0; i < list.length; i++) {
+ var v = list[i];
+ var tr = document.createElement('tr');
+ tr.id = 'row-' + v.vendor_code;
+ tr.setAttribute('data-code', v.vendor_code);
+
+ if (v.skip_shopify) {
+ tr.style.background = 'rgba(231,76,60,0.08)';
+ }
+ var isSelected = selectedVendors[v.vendor_code] || false;
+ var agentOnline = v.agent_status === 'online';
+ var agentDot = v.agent_status === 'online' ? 'dot-green' : (v.agent_status === 'no-port' ? 'dot-gray' : 'dot-red');
+ var agentTitle = agentOnline ? 'Agent online' : (v.agent_status === 'no-port' ? 'No port configured' : 'Agent offline');
+
+ // Build cells using DOM methods
+ // Checkbox cell
+ var cbTd = document.createElement('td');
+ cbTd.className = 'cb-cell';
+ var cbInput = document.createElement('input');
+ cbInput.type = 'checkbox';
+ cbInput.checked = isSelected;
+ cbInput.setAttribute('data-code', v.vendor_code);
+ cbInput.addEventListener('change', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ toggleSelect(code, e.target.checked);
+ };
+ })(v.vendor_code));
+ cbInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ cbTd.appendChild(cbInput);
+ tr.appendChild(cbTd);
+
+ // Brands page cell
+ var brandsTd = document.createElement('td');
+ var brandsCb = document.createElement('input');
+ brandsCb.type = 'checkbox';
+ brandsCb.className = 'pl-checkbox';
+ brandsCb.checked = v.show_on_brands_page;
+ brandsCb.title = 'Show on /pages/brands';
+ brandsCb.setAttribute('data-code', v.vendor_code);
+ brandsCb.addEventListener('change', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ updateBrandsPage(code, e.target.checked);
+ };
+ })(v.vendor_code));
+ brandsCb.addEventListener('click', function(e) { e.stopPropagation(); });
+ brandsTd.appendChild(brandsCb);
+ tr.appendChild(brandsTd);
+
+ // PL cell
+ var plTd = document.createElement('td');
+ var plCb = document.createElement('input');
+ plCb.type = 'checkbox';
+ plCb.className = 'pl-checkbox';
+ plCb.checked = v.is_private_label;
+ plCb.title = 'Private Label';
+ plCb.setAttribute('data-code', v.vendor_code);
+ plCb.addEventListener('change', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ updatePL(code, e.target.checked);
+ };
+ })(v.vendor_code));
+ plCb.addEventListener('click', function(e) { e.stopPropagation(); });
+ plTd.appendChild(plCb);
+ if (v.is_private_label) {
+ var plInput = document.createElement('input');
+ plInput.type = 'text';
+ plInput.className = 'pl-name-input';
+ plInput.value = v.private_label_name || '';
+ plInput.placeholder = 'Label name...';
+ plInput.setAttribute('data-code', v.vendor_code);
+ plInput.addEventListener('blur', (function(code) {
+ return function(e) { updatePLName(code, e.target.value); };
+ })(v.vendor_code));
+ plInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ plTd.appendChild(plInput);
+ }
+ tr.appendChild(plTd);
+
+ // Display Prices checkbox cell
+ var pricesTd = document.createElement('td');
+ var pricesCb = document.createElement('input');
+ pricesCb.type = 'checkbox';
+ pricesCb.className = 'pl-checkbox';
+ pricesCb.checked = v.display_prices !== false;
+ pricesCb.title = 'Display Prices';
+ pricesCb.setAttribute('data-code', v.vendor_code);
+ pricesCb.addEventListener('change', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ updateField(code, 'display_prices', e.target.checked);
+ };
+ })(v.vendor_code));
+ pricesCb.addEventListener('click', function(e) { e.stopPropagation(); });
+ pricesTd.appendChild(pricesCb);
+ tr.appendChild(pricesTd);
+
+ // Skip Shopify checkbox cell
+ var skipTd = document.createElement('td');
+ var skipCb = document.createElement('input');
+ skipCb.type = 'checkbox';
+ skipCb.className = 'pl-checkbox';
+ skipCb.checked = !!v.skip_shopify;
+ skipCb.title = 'Do NOT push to Shopify';
+ skipCb.style.accentColor = '#e74c3c';
+ skipCb.setAttribute('data-code', v.vendor_code);
+ skipCb.addEventListener('change', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ updateField(code, 'skip_shopify', e.target.checked);
+ };
+ })(v.vendor_code));
+ skipCb.addEventListener('click', function(e) { e.stopPropagation(); });
+ skipTd.appendChild(skipCb);
+ tr.appendChild(skipTd);
+
+ // Sample Price cell
+ var sampleTd = document.createElement('td');
+ sampleTd.className = 'sample-price';
+ sampleTd.textContent = '$' + (v.sample_price || '4.25');
+ sampleTd.style.color = 'var(--green)';
+ sampleTd.style.fontWeight = '600';
+ sampleTd.style.fontSize = '12px';
+ tr.appendChild(sampleTd);
+
+ // Vendor name cell
+ var nameTd = document.createElement('td');
+ var nameSpan = document.createElement('span');
+ nameSpan.className = 'vendor-name';
+ nameSpan.textContent = v.vendor_name;
+ nameTd.appendChild(nameSpan);
+ var agentSpan = document.createElement('span');
+ agentSpan.className = 'agent-code';
+ agentSpan.textContent = '(' + (v.agent_name || '?') + ')';
+ nameTd.appendChild(agentSpan);
+ var portSpan = document.createElement('span');
+ portSpan.className = 'port-badge';
+ portSpan.textContent = v.agent_port || '?';
+ nameTd.appendChild(portSpan);
+ tr.appendChild(nameTd);
+
+ // SKU cell — just prefix if no items, prefix + range + count if items exist
+ var skuTd = document.createElement('td');
+ skuTd.style.cssText = 'white-space:nowrap;';
+ var itemCount = parseInt(v.catalog_count || 0, 10);
+ var skuSpan = document.createElement('span');
+ skuSpan.className = 'sku-badge';
+ skuSpan.textContent = v.sku_prefix || '?';
+ skuTd.appendChild(skuSpan);
+ if (itemCount > 0 && v.sku_range_start != null) {
+ var rangeStart = parseInt(v.sku_range_start, 10);
+ var seriesDiv = document.createElement('div');
+ seriesDiv.style.cssText = 'font-family:monospace;font-size:10px;color:var(--text-secondary);margin-top:2px;';
+ seriesDiv.textContent = (v.sku_prefix || '') + rangeStart + ' \u2013 ' + (rangeStart + itemCount - 1);
+ skuTd.appendChild(seriesDiv);
+ var countDiv = document.createElement('div');
+ countDiv.style.cssText = 'font-size:9px;color:var(--green);margin-top:1px;';
+ countDiv.textContent = itemCount.toLocaleString() + ' items';
+ skuTd.appendChild(countDiv);
+ }
+ tr.appendChild(skuTd);
+
+ // Priority cell
+ var priTd = document.createElement('td');
+ var priSpan = document.createElement('span');
+ priSpan.className = 'priority-badge priority-' + (v.crawl_priority || 'MEDIUM');
+ priSpan.textContent = v.crawl_priority || 'MEDIUM';
+ priTd.appendChild(priSpan);
+ tr.appendChild(priTd);
+
+ // Agent status cell — shows online/offline dot, credential status, active state
+ var statusTd = document.createElement('td');
+ statusTd.style.cssText = 'white-space:nowrap;font-size:11px;';
+ var dotSpan = document.createElement('span');
+ dotSpan.className = 'status-dot ' + agentDot;
+ dotSpan.title = agentTitle;
+ statusTd.appendChild(dotSpan);
+ var statusLabel = document.createElement('span');
+ statusLabel.style.cssText = 'margin-right:6px;color:var(--text-secondary);';
+ statusLabel.textContent = agentOnline ? 'On' : (v.agent_status === 'no-port' ? '--' : 'Off');
+ statusTd.appendChild(statusLabel);
+ var credSpan = document.createElement('span');
+ credSpan.className = 'icon-status';
+ credSpan.style.cssText = 'cursor:help;';
+ if (v.has_credentials) {
+ credSpan.textContent = '\uD83D\uDD12';
+ credSpan.title = 'Login credentials configured — can access trade portal';
+ } else {
+ credSpan.textContent = '\uD83D\uDD13';
+ credSpan.title = 'No login credentials — cannot access trade portal';
+ credSpan.style.opacity = '0.3';
+ }
+ statusTd.appendChild(credSpan);
+ if (!v.is_active) {
+ var warnSpan = document.createElement('span');
+ warnSpan.className = 'icon-status';
+ warnSpan.style.color = 'var(--red)';
+ warnSpan.title = 'Vendor marked inactive — not being crawled';
+ warnSpan.textContent = '\u26A0';
+ statusTd.appendChild(warnSpan);
+ }
+ tr.appendChild(statusTd);
+
+ // Website score cell
+ var scoreTd = document.createElement('td');
+ scoreTd.className = 'score-cell';
+ var scoreBadge = document.createElement('span');
+ if (v.website_score) {
+ scoreBadge.className = 'score-badge ' + (v.website_score >= 7 ? 'score-high' : v.website_score >= 4 ? 'score-mid' : 'score-low');
+ scoreBadge.textContent = v.website_score;
+ scoreBadge.title = (v.website_summary || '') + ' | ' + (v.website_tech_stack || '') + (v.website_scored_at ? ' | Scored: ' + new Date(v.website_scored_at).toLocaleDateString() : '');
+ } else {
+ scoreBadge.className = 'score-badge score-none';
+ scoreBadge.textContent = '--';
+ scoreBadge.title = v.website_url ? 'Not scored yet' : 'No website URL';
+ }
+ scoreTd.appendChild(scoreBadge);
+ tr.appendChild(scoreTd);
+
+ // Product count cells — Catalog (DB) is source of truth
+ // Catalog count
+ var catTd = document.createElement('td');
+ catTd.className = 'product-count';
+ var catCount = v.catalog_count || v.total_products || 0;
+ catTd.textContent = Number(catCount).toLocaleString();
+ if (catCount > 0) catTd.style.color = 'var(--accent)';
+ else catTd.style.color = 'var(--text-muted)';
+ tr.appendChild(catTd);
+
+ // On Shopify count
+ var shopTd = document.createElement('td');
+ shopTd.className = 'product-count';
+ var shopCount = v.shopify_count || 0;
+ shopTd.textContent = Number(shopCount).toLocaleString();
+ if (shopCount > 0) shopTd.style.color = '#60a5fa';
+ else shopTd.style.color = 'var(--text-muted)';
+ tr.appendChild(shopTd);
+
+ // Not on Shopify (gap)
+ var gapTd = document.createElement('td');
+ gapTd.className = 'product-count';
+ var gapCount = v.catalog_not_on_shopify || 0;
+ gapTd.textContent = Number(gapCount).toLocaleString();
+ if (gapCount > 0) gapTd.style.color = 'var(--orange)';
+ else gapTd.style.color = 'var(--text-muted)';
+ tr.appendChild(gapTd);
+
+ // Drafts needing review (only drafts — archived are already handled)
+ var discTd = document.createElement('td');
+ discTd.className = 'product-count';
+ var draftCount = v.shopify_draft || 0;
+ discTd.textContent = Number(draftCount).toLocaleString();
+ if (draftCount > 100) { discTd.style.color = 'var(--red)'; discTd.style.fontWeight = '700'; }
+ else if (draftCount > 0) discTd.style.color = 'var(--orange)';
+ else discTd.style.color = 'var(--text-muted)';
+ discTd.title = 'Drafts: ' + draftCount + ' (Archived: ' + (v.shopify_archived || 0) + ')';
+ tr.appendChild(discTd);
+
+ // With cost
+ var costTd = document.createElement('td');
+ costTd.className = 'product-count';
+ var costCount = v.products_with_cost || 0;
+ costTd.textContent = Number(costCount).toLocaleString();
+ if (costCount > 0) costTd.style.color = 'var(--green)';
+ else costTd.style.color = 'var(--text-muted)';
+ tr.appendChild(costTd);
+
+ // With images
+ var imgTd = document.createElement('td');
+ imgTd.className = 'product-count';
+ var imgCount = v.products_with_images || 0;
+ imgTd.textContent = Number(imgCount).toLocaleString();
+ if (imgCount > 0) imgTd.style.color = 'var(--green)';
+ else imgTd.style.color = 'var(--text-muted)';
+ tr.appendChild(imgTd);
+
+ // Assets / Data Quality cell — compact summary badge with popover detail
+ var assetTd = document.createElement('td');
+ assetTd.className = 'assets-cell';
+ var totalCat = v.catalog_count || v.total_products || 0;
+ var withWidth = v.catalog_with_width || 0;
+ var withRepeat = v.catalog_with_repeat || 0;
+ var withImg = v.products_with_images || 0;
+ var aboutVendor = v.catalog_with_about_vendor || 0;
+ var allImg = v.catalog_with_all_images || 0;
+ var specSh = v.catalog_with_spec_sheet || 0;
+
+ if (totalCat === 0) {
+ var naBadge = document.createElement('span');
+ naBadge.className = 'dq-summary dq-na';
+ naBadge.textContent = 'N/A';
+ assetTd.appendChild(naBadge);
+ } else {
+ // Count how many of the 6 fields pass their threshold
+ var goodCount = 0;
+ var fields = [
+ { label: 'Width', val: withWidth, thresh: 0.8 },
+ { label: 'Repeat', val: withRepeat, thresh: 0.8 },
+ { label: 'Images', val: withImg, thresh: 0.8 },
+ { label: 'About', val: aboutVendor, thresh: 0.5 },
+ { label: 'Gallery', val: allImg, thresh: 0.5 },
+ { label: 'Spec Sheet', val: specSh, thresh: 0.5 }
+ ];
+ for (var fi = 0; fi < fields.length; fi++) {
+ if (fields[fi].val >= totalCat * fields[fi].thresh) goodCount++;
+ }
+ var summaryClass = goodCount >= 5 ? 'dq-good' : (goodCount >= 3 ? 'dq-partial' : 'dq-bad');
+ var summaryBadge = document.createElement('span');
+ summaryBadge.className = 'dq-summary ' + summaryClass;
+ summaryBadge.textContent = goodCount + '/6';
+ summaryBadge.title = 'Click for data quality detail';
+
+ // Build popover
+ var popover = document.createElement('div');
+ popover.className = 'dq-popover';
+ for (var fi2 = 0; fi2 < fields.length; fi2++) {
+ var f = fields[fi2];
+ var pct = Math.round(f.val / totalCat * 100);
+ var ok = f.val >= totalCat * f.thresh;
+ var row = document.createElement('div');
+ row.className = 'dq-row';
+ var lbl = document.createElement('span');
+ lbl.className = 'dq-label';
+ lbl.textContent = f.label;
+ var val = document.createElement('span');
+ val.className = 'dq-val';
+ val.style.color = f.val === 0 ? 'var(--red)' : (ok ? 'var(--green)' : 'var(--orange)');
+ val.textContent = f.val === 0 ? 'None' : (ok ? pct + '% OK' : pct + '%');
+ row.appendChild(lbl);
+ row.appendChild(val);
+ popover.appendChild(row);
+ }
+
+ assetTd.appendChild(summaryBadge);
+ assetTd.appendChild(popover);
+
+ // Click anywhere in the cell to toggle popover
+ assetTd.addEventListener('click', function(pop) {
+ return function(e) {
+ e.stopPropagation();
+ // Close any other open popovers
+ var allPops = document.querySelectorAll('.dq-popover.show');
+ for (var p = 0; p < allPops.length; p++) {
+ if (allPops[p] !== pop) allPops[p].classList.remove('show');
+ }
+ pop.classList.toggle('show');
+ };
+ }(popover));
+ }
+ tr.appendChild(assetTd);
+
+ // Spec Completeness cell
+ var specTd = document.createElement('td');
+ specTd.className = 'spec-cell';
+ var specPct = parseFloat(v.spec_completeness) || 0;
+ var specPresent = v.spec_fields_present || 0;
+ var specTotalFields = v.spec_fields_total || 35;
+ if (!v.catalog_table) {
+ var specNa = document.createElement('span');
+ specNa.className = 'asset-badge na';
+ specNa.textContent = 'N/A';
+ specTd.appendChild(specNa);
+ } else {
+ var specBadge = document.createElement('span');
+ specBadge.style.cssText = 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;';
+ if (specPct >= 80) {
+ specBadge.style.background = 'rgba(34,197,94,0.15)';
+ specBadge.style.color = 'var(--green)';
+ } else if (specPct >= 50) {
+ specBadge.style.background = 'rgba(251,191,36,0.15)';
+ specBadge.style.color = 'var(--orange)';
+ } else {
+ specBadge.style.background = 'rgba(239,68,68,0.15)';
+ specBadge.style.color = 'var(--red)';
+ }
+ specBadge.textContent = specPct + '%';
+ specBadge.title = specPresent + '/' + specTotalFields + ' fields present\\nClick for details';
+ specBadge.addEventListener('click', (function(code) {
+ return function(e) { e.stopPropagation(); showSpecDetails(code); };
+ })(v.vendor_code));
+ specTd.appendChild(specBadge);
+ }
+ tr.appendChild(specTd);
+
+ // Nora mailer cell
+ var noraTd = document.createElement('td');
+ noraTd.style.cssText = 'text-align:center;font-size:11px;';
+ var noraCount = v.nora_mailers || 0;
+ if (noraCount > 0) {
+ var noraBadge = document.createElement('span');
+ noraBadge.style.cssText = 'display:inline-block;padding:2px 8px;border-radius:4px;font-weight:700;background:rgba(168,85,247,0.15);color:#a855f7;cursor:pointer;';
+ noraBadge.textContent = noraCount;
+ noraBadge.title = noraCount + ' mailer(s) from ' + v.vendor_name + (v.nora_active ? ' (' + v.nora_active + ' active)' : '') + (v.nora_latest ? '\\nLatest: ' + new Date(v.nora_latest).toLocaleDateString() : '');
+ noraBadge.addEventListener('click', function(e) {
+ e.stopPropagation();
+ window.open('http://45.61.58.125:9812', '_blank');
+ });
+ noraTd.appendChild(noraBadge);
+ } else {
+ noraTd.textContent = '--';
+ noraTd.style.color = 'var(--text-muted)';
+ }
+ tr.appendChild(noraTd);
+
+ // Date cells
+ var dateFields = ['last_product_update', 'last_price_update', 'last_image_update'];
+ for (var d = 0; d < dateFields.length; d++) {
+ var dateTd = document.createElement('td');
+ dateTd.className = 'date-cell';
+ setDateCellContent(dateTd, v[dateFields[d]]);
+ tr.appendChild(dateTd);
+ }
+
+ // Expand button cell
+ var expTd = document.createElement('td');
+ var expBtn = document.createElement('button');
+ expBtn.className = 'expand-btn';
+ expBtn.textContent = expandedRow === v.vendor_code ? '\u25B2' : '\u25BC';
+ expBtn.setAttribute('data-code', v.vendor_code);
+ expBtn.addEventListener('click', (function(code) {
+ return function(e) { e.stopPropagation(); toggleExpand(code); };
+ })(v.vendor_code));
+ expTd.appendChild(expBtn);
+ tr.appendChild(expTd);
+
+ // Row click handler
+ tr.addEventListener('click', (function(code) {
+ return function(e) {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'TEXTAREA') return;
+ if (e.target.closest && e.target.closest('.action-btn')) return;
+ if (e.target.closest && (e.target.closest('.dq-summary') || e.target.closest('.dq-popover') || e.target.closest('.assets-cell'))) return;
+ toggleExpand(code);
+ };
+ })(v.vendor_code));
+
+ if (expandedRow === v.vendor_code) tr.classList.add('expanded');
+
+ tbody.appendChild(tr);
+
+ // Drill-down row
+ var drillTr = document.createElement('tr');
+ drillTr.id = 'drill-' + v.vendor_code;
+ drillTr.className = 'drill-down' + (expandedRow === v.vendor_code ? ' show' : '');
+ var drillTd = document.createElement('td');
+ drillTd.colSpan = 21;
+ buildDrillDown(drillTd, v);
+ drillTr.appendChild(drillTd);
+ tbody.appendChild(drillTr);
+ }
+}
+
+function setDateCellContent(td, dateStr) {
+ if (!dateStr) {
+ var span = document.createElement('span');
+ span.style.color = 'var(--text-muted)';
+ span.textContent = '--';
+ td.appendChild(span);
+ return;
+ }
+ var dt = new Date(dateStr);
+ var now = new Date();
+ var diff = now - dt;
+ var days = Math.floor(diff / 86400000);
+ var span = document.createElement('span');
+ if (days === 0) {
+ span.style.color = 'var(--green)';
+ span.textContent = 'Today';
+ } else if (days === 1) {
+ span.style.color = 'var(--green)';
+ span.textContent = '1d ago';
+ } else if (days < 7) {
+ span.style.color = 'var(--text-secondary)';
+ span.textContent = days + 'd ago';
+ } else if (days < 30) {
+ span.style.color = 'var(--orange)';
+ span.textContent = Math.floor(days/7) + 'w ago';
+ } else if (days < 365) {
+ span.style.color = 'var(--red)';
+ span.textContent = Math.floor(days/30) + 'mo ago';
+ } else {
+ span.style.color = 'var(--red)';
+ span.textContent = Math.floor(days/365) + 'y ago';
+ }
+ td.appendChild(span);
+}
+
+function buildDrillDown(container, v) {
+ var content = document.createElement('div');
+ content.className = 'drill-content';
+
+ // Actions section
+ var actionsSection = document.createElement('div');
+ actionsSection.className = 'drill-section';
+ var actionsTitle = document.createElement('h4');
+ actionsTitle.textContent = 'Actions';
+ actionsSection.appendChild(actionsTitle);
+
+ var actionsDiv = document.createElement('div');
+ actionsDiv.className = 'drill-actions';
+ var actionDefs = [
+ { label: 'Update Products', action: 'scan' },
+ { label: 'Update Prices', action: 'update-prices' },
+ { label: 'Update Images', action: 'update-images' },
+ { label: 'Update Specs', action: 'update-specs' },
+ { label: 'Check Discontinued', action: 'check-discontinued' },
+ { label: 'Schedule Blog', action: 'blog' },
+ { label: 'Schedule Instagram', action: 'instagram' },
+ { label: 'Score Website', action: 'score-website' }
+ ];
+ for (var a = 0; a < actionDefs.length; a++) {
+ var btn = document.createElement('button');
+ btn.className = 'action-btn';
+ var spinner = document.createElement('span');
+ spinner.className = 'spinner';
+ btn.appendChild(spinner);
+ btn.appendChild(document.createTextNode(' ' + actionDefs[a].label));
+ btn.setAttribute('data-action', actionDefs[a].action);
+ btn.setAttribute('data-code', v.vendor_code);
+ btn.addEventListener('click', (function(code, action) {
+ return function(e) {
+ e.stopPropagation();
+ vendorAction(code, action, e.currentTarget);
+ };
+ })(v.vendor_code, actionDefs[a].action));
+ actionsDiv.appendChild(btn);
+ }
+
+ // Assign DW SKUs button (separate — uses different endpoint)
+ var skuBtn = document.createElement('button');
+ skuBtn.className = 'action-btn';
+ skuBtn.style.background = 'rgba(124,58,237,0.15)';
+ skuBtn.style.borderColor = 'rgba(124,58,237,0.3)';
+ skuBtn.style.color = 'var(--accent)';
+ var skuSpinner = document.createElement('span');
+ skuSpinner.className = 'spinner';
+ skuBtn.appendChild(skuSpinner);
+ skuBtn.appendChild(document.createTextNode(' Assign DW SKUs'));
+ skuBtn.addEventListener('click', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ assignDwSkus(code, e.currentTarget);
+ };
+ })(v.vendor_code));
+ actionsDiv.appendChild(skuBtn);
+
+ // Inquiry Page button — show for vendors with 0 catalog products
+ if (!v.catalog_count || parseInt(v.catalog_count) === 0) {
+ var inqBtn = document.createElement('button');
+ inqBtn.className = 'action-btn';
+ inqBtn.style.background = 'rgba(79,195,247,0.15)';
+ inqBtn.style.borderColor = 'rgba(79,195,247,0.3)';
+ inqBtn.style.color = '#4fc3f7';
+ var inqSpinner = document.createElement('span');
+ inqSpinner.className = 'spinner';
+ inqSpinner.style.display = 'none';
+ inqBtn.appendChild(inqSpinner);
+ inqBtn.appendChild(document.createTextNode(' Create Inquiry Page'));
+ inqBtn.addEventListener('click', (function(code, name) {
+ return function(e) {
+ e.stopPropagation();
+ createInquiryPage(code, name, e.currentTarget);
+ };
+ })(v.vendor_code, v.vendor_name));
+ actionsDiv.appendChild(inqBtn);
+ }
+
+ actionsSection.appendChild(actionsDiv);
+
+ // Agent link
+ var linkDiv = document.createElement('div');
+ linkDiv.style.marginTop = '12px';
+ var agentLink = document.createElement('a');
+ agentLink.className = 'agent-link';
+ agentLink.href = 'http://45.61.58.125:' + (v.agent_port || '');
+ agentLink.target = '_blank';
+ agentLink.textContent = 'Open ' + (v.agent_name || v.vendor_code) + ' Agent Dashboard (port ' + (v.agent_port || '?') + ')';
+ linkDiv.appendChild(agentLink);
+ actionsSection.appendChild(linkDiv);
+
+ content.appendChild(actionsSection);
+
+ // Details section
+ var detailsSection = document.createElement('div');
+ detailsSection.className = 'drill-section';
+ var detailsTitle = document.createElement('h4');
+ detailsTitle.textContent = 'Details';
+ detailsSection.appendChild(detailsTitle);
+
+ var detailGrid = document.createElement('div');
+ detailGrid.className = 'detail-grid';
+ var details = [
+ { label: 'Vendor Code', value: v.vendor_code },
+ { label: 'Agent Name', value: v.agent_name || '-' },
+ { label: 'PM2 Name', value: v.pm2_name || '-' },
+ { label: 'SKU Range Start', value: v.sku_range_start || '-' },
+ { label: 'Catalog Table', value: v.catalog_table || '-' },
+ { label: 'Login URL', value: v.login_url || '-' },
+ { label: 'Website URL', value: v.website_url || '-' },
+ { label: 'Website Score', value: v.website_score ? v.website_score + '/10 — ' + (v.website_summary || '') : 'Not scored' },
+ { label: 'Tech Stack', value: v.website_tech_stack || '-' },
+ { label: 'Scored At', value: fmtDateFull(v.website_scored_at) },
+ { label: 'Crawl Cron', value: v.crawl_cron || 'Not set' },
+ { label: 'Next Crawl', value: fmtDateFull(v.next_scheduled_crawl) },
+ { label: 'Last Blog', value: fmtDateFull(v.last_blog_post) },
+ { label: 'Last Instagram', value: fmtDateFull(v.last_instagram_post) },
+ { label: 'Last Discontinued Check', value: fmtDateFull(v.last_discontinued_check) },
+ { label: 'Created', value: fmtDateFull(v.created_at) }
+ ];
+ for (var d = 0; d < details.length; d++) {
+ var item = document.createElement('div');
+ item.className = 'detail-item';
+ var lbl = document.createElement('span');
+ lbl.className = 'label';
+ lbl.textContent = details[d].label;
+ item.appendChild(lbl);
+ var val = document.createElement('span');
+ val.className = 'value';
+ val.textContent = details[d].value;
+ if (details[d].label === 'Login URL') {
+ val.style.fontSize = '11px';
+ val.style.wordBreak = 'break-all';
+ }
+ item.appendChild(val);
+ detailGrid.appendChild(item);
+ }
+ detailsSection.appendChild(detailGrid);
+ content.appendChild(detailsSection);
+
+ // Pricing & Discount section
+ var pricingSection = document.createElement('div');
+ pricingSection.className = 'drill-section';
+ pricingSection.style.gridColumn = '1 / -1';
+ var pricingTitle = document.createElement('h4');
+ pricingTitle.textContent = 'Pricing & Discount';
+ pricingSection.appendChild(pricingTitle);
+
+ var pricingGrid = document.createElement('div');
+ pricingGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:12px;';
+
+ // Discount %
+ var discBox = document.createElement('div');
+ discBox.style.cssText = 'background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:12px;';
+ var discLabel = document.createElement('div');
+ discLabel.style.cssText = 'font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;';
+ discLabel.textContent = 'Vendor Discount Off List';
+ discBox.appendChild(discLabel);
+ var discInput = document.createElement('input');
+ discInput.type = 'number';
+ discInput.id = 'discount-pct-' + v.vendor_code;
+ discInput.min = '0';
+ discInput.max = '100';
+ discInput.step = '0.5';
+ discInput.value = v.vendor_discount_pct != null ? v.vendor_discount_pct : '';
+ discInput.placeholder = 'e.g. 20';
+ discInput.style.cssText = 'width:80px;padding:6px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:16px;font-weight:600;';
+ discInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ discBox.appendChild(discInput);
+ var discSuffix = document.createElement('span');
+ discSuffix.style.cssText = 'font-size:14px;color:var(--text-secondary);margin-left:4px;';
+ discSuffix.textContent = '% off list';
+ discBox.appendChild(discSuffix);
+ pricingGrid.appendChild(discBox);
+
+ // Pricing Unit
+ var unitBox = document.createElement('div');
+ unitBox.style.cssText = 'background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:12px;';
+ var unitLabel = document.createElement('div');
+ unitLabel.style.cssText = 'font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;';
+ unitLabel.textContent = 'Pricing Unit';
+ unitBox.appendChild(unitLabel);
+ var unitSelect = document.createElement('select');
+ unitSelect.id = 'pricing-unit-' + v.vendor_code;
+ unitSelect.style.cssText = 'padding:6px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:14px;';
+ var units = ['yard', 'roll', 'single roll', 'double roll', 'bolt', 'sqft', 'panel', 'box', 'meter', 'linear foot'];
+ for (var u = 0; u < units.length; u++) {
+ var opt = document.createElement('option');
+ opt.value = units[u];
+ opt.textContent = units[u].charAt(0).toUpperCase() + units[u].slice(1);
+ if ((v.pricing_unit || 'yard') === units[u]) opt.selected = true;
+ unitSelect.appendChild(opt);
+ }
+ unitSelect.addEventListener('click', function(e) { e.stopPropagation(); });
+ unitBox.appendChild(unitSelect);
+ pricingGrid.appendChild(unitBox);
+
+ // Formula preview (built with safe DOM methods)
+ var formulaBox = document.createElement('div');
+ formulaBox.style.cssText = 'background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:12px;';
+ var formulaLabel = document.createElement('div');
+ formulaLabel.style.cssText = 'font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;';
+ formulaLabel.textContent = 'Retail Price Formula';
+ formulaBox.appendChild(formulaLabel);
+ var formulaText = document.createElement('div');
+ formulaText.id = 'formula-preview-' + v.vendor_code;
+ formulaText.style.cssText = 'font-size:13px;color:var(--text);font-family:monospace;line-height:1.6;';
+
+ function buildFormulaDisplay(container, discPct) {
+ container.textContent = '';
+ if (discPct !== null && !isNaN(discPct) && discPct >= 0 && discPct <= 100) {
+ var costMult = (1 - discPct / 100).toFixed(2);
+ var line1 = document.createElement('div');
+ line1.textContent = 'List x ' + costMult + ' = ';
+ var costSpan = document.createElement('span');
+ costSpan.style.color = 'var(--cyan)';
+ costSpan.textContent = 'Cost';
+ line1.appendChild(costSpan);
+ container.appendChild(line1);
+
+ var line2 = document.createElement('div');
+ line2.textContent = 'Cost / 0.65 / 0.85 = ';
+ var retailSpan = document.createElement('span');
+ retailSpan.style.color = 'var(--green)';
+ retailSpan.textContent = 'Retail';
+ line2.appendChild(retailSpan);
+ container.appendChild(line2);
+
+ var example = document.createElement('div');
+ example.style.cssText = 'font-size:11px;color:var(--text-muted);margin-top:4px;';
+ var exRetail = (50 * (1 - discPct / 100) / 0.65 / 0.85).toFixed(2);
+ example.textContent = 'Example: $50 list -> $' + (50 * (1 - discPct / 100)).toFixed(2) + ' cost -> $' + exRetail + ' retail';
+ container.appendChild(example);
+ } else {
+ var warn = document.createElement('span');
+ warn.style.color = 'var(--yellow)';
+ warn.textContent = 'Set discount % to see formula';
+ container.appendChild(warn);
+ }
+ }
+
+ var disc = v.vendor_discount_pct != null ? parseFloat(v.vendor_discount_pct) : null;
+ buildFormulaDisplay(formulaText, disc);
+ formulaBox.appendChild(formulaText);
+ pricingGrid.appendChild(formulaBox);
+
+ // Sample price
+ var sampleBox = document.createElement('div');
+ sampleBox.style.cssText = 'background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;padding:12px;';
+ var sampleLabel = document.createElement('div');
+ sampleLabel.style.cssText = 'font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;';
+ sampleLabel.textContent = 'Sample Price';
+ sampleBox.appendChild(sampleLabel);
+ var sampleInput = document.createElement('input');
+ sampleInput.type = 'number';
+ sampleInput.id = 'sample-price-' + v.vendor_code;
+ sampleInput.min = '0';
+ sampleInput.step = '0.25';
+ sampleInput.value = v.sample_price != null ? v.sample_price : '4.25';
+ sampleInput.style.cssText = 'width:80px;padding:6px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:16px;font-weight:600;';
+ sampleInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ sampleBox.appendChild(sampleInput);
+ pricingGrid.appendChild(sampleBox);
+
+ pricingSection.appendChild(pricingGrid);
+
+ // Live formula updater
+ discInput.addEventListener('input', (function(code) {
+ return function() {
+ var d = parseFloat(document.getElementById('discount-pct-' + code).value);
+ var preview = document.getElementById('formula-preview-' + code);
+ buildFormulaDisplay(preview, isNaN(d) ? null : d);
+ };
+ })(v.vendor_code));
+
+ // Pricing notes
+ var notesDiv = document.createElement('div');
+ notesDiv.style.cssText = 'margin-top:8px;';
+ var notesLabel2 = document.createElement('div');
+ notesLabel2.style.cssText = 'font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px;';
+ notesLabel2.textContent = 'Pricing Notes (minimums, freight, terms)';
+ notesDiv.appendChild(notesLabel2);
+ var notesInput = document.createElement('input');
+ notesInput.type = 'text';
+ notesInput.id = 'pricing-notes-' + v.vendor_code;
+ notesInput.value = v.pricing_notes || '';
+ notesInput.placeholder = 'e.g. Net 30, $500 min order, free freight over $2000...';
+ notesInput.style.cssText = 'width:100%;padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;box-sizing:border-box;';
+ notesInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ notesDiv.appendChild(notesInput);
+ pricingSection.appendChild(notesDiv);
+
+ // Save button
+ var savePricingBtn = document.createElement('button');
+ savePricingBtn.className = 'action-btn';
+ savePricingBtn.textContent = 'Save Pricing Settings';
+ savePricingBtn.style.cssText = 'margin-top:10px;font-size:12px;padding:8px 20px;background:var(--green);color:#000;font-weight:600;';
+ savePricingBtn.addEventListener('click', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ var discVal = document.getElementById('discount-pct-' + code).value;
+ var unitVal = document.getElementById('pricing-unit-' + code).value;
+ var notesVal = document.getElementById('pricing-notes-' + code).value;
+ var sampleVal = document.getElementById('sample-price-' + code).value;
+ var body = {};
+ body.vendor_discount_pct = discVal !== '' ? parseFloat(discVal) : null;
+ body.pricing_unit = unitVal;
+ body.pricing_notes = notesVal;
+ body.sample_price = sampleVal !== '' ? parseFloat(sampleVal) : 4.25;
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify(body)
+ }).then(function(res) {
+ if (res.success) {
+ savePricingBtn.textContent = 'Saved!';
+ savePricingBtn.style.background = 'var(--cyan)';
+ setTimeout(function() { savePricingBtn.textContent = 'Save Pricing Settings'; savePricingBtn.style.background = 'var(--green)'; }, 2000);
+ } else {
+ savePricingBtn.textContent = 'Error: ' + (res.error || 'Failed');
+ savePricingBtn.style.background = 'var(--red)';
+ setTimeout(function() { savePricingBtn.textContent = 'Save Pricing Settings'; savePricingBtn.style.background = 'var(--green)'; }, 3000);
+ }
+ });
+ };
+ })(v.vendor_code));
+ pricingSection.appendChild(savePricingBtn);
+
+ content.appendChild(pricingSection);
+
+ // Learnings section (full width)
+ var learningsSection = document.createElement('div');
+ learningsSection.className = 'drill-section';
+ learningsSection.style.gridColumn = '1 / -1';
+ var learningsTitle = document.createElement('h4');
+ learningsTitle.textContent = 'Learnings & Notes';
+ learningsSection.appendChild(learningsTitle);
+
+ var textarea = document.createElement('textarea');
+ textarea.className = 'learnings-textarea';
+ textarea.id = 'learnings-' + v.vendor_code;
+ textarea.placeholder = 'Add vendor notes, observations, issues...';
+ textarea.value = v.learnings || '';
+ textarea.addEventListener('blur', (function(code) {
+ return function(e) { saveLearnings(code, e.target.value); };
+ })(v.vendor_code));
+ textarea.addEventListener('click', function(e) { e.stopPropagation(); });
+ learningsSection.appendChild(textarea);
+
+ var hint = document.createElement('div');
+ hint.style.fontSize = '10px';
+ hint.style.color = 'var(--text-muted)';
+ hint.style.marginTop = '4px';
+ hint.textContent = 'Auto-saves on blur';
+ learningsSection.appendChild(hint);
+
+ content.appendChild(learningsSection);
+
+ // Agent Chat section (full width)
+ var chatSection = document.createElement('div');
+ chatSection.className = 'drill-section';
+ chatSection.style.gridColumn = '1 / -1';
+ var chatTitle = document.createElement('h4');
+ chatTitle.textContent = 'Chat with ' + (v.agent_name || v.vendor_code);
+ chatSection.appendChild(chatTitle);
+
+ var chatMessages = document.createElement('div');
+ chatMessages.id = 'chat-messages-' + v.vendor_code;
+ chatMessages.style.cssText = 'max-height:250px;overflow-y:auto;margin-bottom:8px;padding:8px;background:var(--bg-primary);border-radius:6px;border:1px solid var(--border);min-height:60px;';
+ var chatPlaceholder = document.createElement('div');
+ chatPlaceholder.style.cssText = 'color:var(--text-muted);font-size:12px;padding:8px;font-style:italic;';
+ chatPlaceholder.textContent = 'Ask anything about this vendor — e.g. "why only ' + (v.catalog_count || 0) + ' items?" or "what specs are missing?"';
+ chatMessages.appendChild(chatPlaceholder);
+ chatSection.appendChild(chatMessages);
+
+ var chatInputDiv = document.createElement('div');
+ chatInputDiv.style.cssText = 'display:flex;gap:8px;';
+ var chatInput = document.createElement('input');
+ chatInput.type = 'text';
+ chatInput.id = 'chat-input-' + v.vendor_code;
+ chatInput.placeholder = 'Ask ' + (v.agent_name || 'agent') + ' a question...';
+ chatInput.style.cssText = 'flex:1;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;';
+ chatInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ chatInput.addEventListener('keydown', (function(code) {
+ return function(e) { if (e.key === 'Enter') sendChat(code); };
+ })(v.vendor_code));
+ chatInputDiv.appendChild(chatInput);
+ var chatSendBtn = document.createElement('button');
+ chatSendBtn.className = 'action-btn';
+ chatSendBtn.textContent = 'Send';
+ chatSendBtn.style.cssText = 'font-size:12px;padding:8px 16px;';
+ chatSendBtn.addEventListener('click', (function(code) {
+ return function(e) { e.stopPropagation(); sendChat(code); };
+ })(v.vendor_code));
+ chatInputDiv.appendChild(chatSendBtn);
+ chatSection.appendChild(chatInputDiv);
+ content.appendChild(chatSection);
+
+ // Sample Product section (full width)
+ var sampleSection = document.createElement('div');
+ sampleSection.className = 'drill-section';
+ sampleSection.style.gridColumn = '1 / -1';
+ var sampleTitle = document.createElement('h4');
+ sampleTitle.textContent = 'Sample Product (Best Populated)';
+ sampleSection.appendChild(sampleTitle);
+ var sampleContainer = document.createElement('div');
+ sampleContainer.id = 'sample-product-' + v.vendor_code;
+ sampleContainer.style.cssText = 'padding:8px;';
+ var sampleLoading = document.createElement('div');
+ sampleLoading.style.cssText = 'color:var(--text-muted);font-size:12px;';
+ sampleLoading.textContent = 'Loading sample product...';
+ sampleContainer.appendChild(sampleLoading);
+ sampleSection.appendChild(sampleContainer);
+
+ // Rescrape Schema button
+ var rescrapeDiv = document.createElement('div');
+ rescrapeDiv.style.cssText = 'margin-top:8px;display:flex;gap:8px;align-items:center;';
+ var rescrapeBtn = document.createElement('button');
+ rescrapeBtn.className = 'action-btn';
+ rescrapeBtn.textContent = 'Rescrape & Refresh Schema';
+ rescrapeBtn.style.fontSize = '11px';
+ rescrapeBtn.addEventListener('click', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ rescrapeBtn.classList.add('loading');
+ vendorAction(code, 'scan', rescrapeBtn);
+ setTimeout(function() { loadSampleProduct(code); }, 3000);
+ };
+ })(v.vendor_code));
+ rescrapeDiv.appendChild(rescrapeBtn);
+ var schemaHint = document.createElement('span');
+ schemaHint.style.cssText = 'font-size:10px;color:var(--text-muted);';
+ schemaHint.textContent = 'Triggers a full rescrape then reloads the sample product';
+ rescrapeDiv.appendChild(schemaHint);
+ sampleSection.appendChild(rescrapeDiv);
+ content.appendChild(sampleSection);
+
+ // NOTE: loadSampleProduct is called lazily from toggleExpand, not here
+
+ // Observations section (full width)
+ var obsSection = document.createElement('div');
+ obsSection.className = 'drill-section';
+ obsSection.style.gridColumn = '1 / -1';
+ var obsTitle = document.createElement('h4');
+ obsTitle.textContent = 'Recent Observations';
+ obsSection.appendChild(obsTitle);
+ var obsList = document.createElement('div');
+ obsList.id = 'obs-' + v.vendor_code;
+ obsList.style.maxHeight = '200px';
+ obsList.style.overflowY = 'auto';
+ var obsLoading = document.createElement('div');
+ obsLoading.style.color = 'var(--text-muted)';
+ obsLoading.style.fontSize = '12px';
+ obsLoading.style.padding = '8px';
+ obsLoading.textContent = 'Loading observations...';
+ obsList.appendChild(obsLoading);
+ obsSection.appendChild(obsList);
+ content.appendChild(obsSection);
+
+ // Products section (full width)
+ var productsSection = document.createElement('div');
+ productsSection.className = 'drill-section';
+ productsSection.style.gridColumn = '1 / -1';
+ var productsTitle = document.createElement('h4');
+ productsTitle.textContent = 'Products (' + (v.total_products || 0) + ')';
+ productsSection.appendChild(productsTitle);
+
+ var prodSearchDiv = document.createElement('div');
+ prodSearchDiv.style.display = 'flex';
+ prodSearchDiv.style.gap = '8px';
+ prodSearchDiv.style.marginBottom = '8px';
+ var prodSearchInput = document.createElement('input');
+ prodSearchInput.type = 'text';
+ prodSearchInput.placeholder = 'Search products by title or SKU...';
+ prodSearchInput.style.cssText = 'flex:1;padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;';
+ prodSearchInput.id = 'prod-search-' + v.vendor_code;
+ prodSearchInput.addEventListener('click', function(e) { e.stopPropagation(); });
+ prodSearchDiv.appendChild(prodSearchInput);
+ var prodSearchBtn = document.createElement('button');
+ prodSearchBtn.className = 'action-btn';
+ prodSearchBtn.textContent = 'Load Products';
+ prodSearchBtn.style.fontSize = '11px';
+ prodSearchBtn.addEventListener('click', (function(code) {
+ return function(e) {
+ e.stopPropagation();
+ loadProducts(code, 0);
+ };
+ })(v.vendor_code));
+ prodSearchDiv.appendChild(prodSearchBtn);
+ productsSection.appendChild(prodSearchDiv);
+
+ var prodContainer = document.createElement('div');
+ prodContainer.id = 'products-' + v.vendor_code;
+ prodContainer.style.maxHeight = '400px';
+ prodContainer.style.overflowY = 'auto';
+ var prodHint = document.createElement('div');
+ prodHint.style.color = 'var(--text-muted)';
+ prodHint.style.fontSize = '12px';
+ prodHint.style.padding = '8px';
+ prodHint.textContent = 'Click "Load Products" to view Shopify product data';
+ prodContainer.appendChild(prodHint);
+ productsSection.appendChild(prodContainer);
+ content.appendChild(productsSection);
+
+ container.appendChild(content);
+}
+
+// Load products for a vendor
+function loadProducts(code, offset) {
+ var el = document.getElementById('products-' + code);
+ if (!el) return;
+ var searchVal = '';
+ var searchInput = document.getElementById('prod-search-' + code);
+ if (searchInput) searchVal = searchInput.value;
+ var url = '/api/vendors/' + encodeURIComponent(code) + '/products?limit=50&offset=' + offset;
+ if (searchVal) url += '&search=' + encodeURIComponent(searchVal);
+
+ if (offset === 0) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ var loading = document.createElement('div');
+ loading.style.cssText = 'color:var(--text-muted);font-size:12px;padding:8px;';
+ loading.textContent = 'Loading products...';
+ el.appendChild(loading);
+ }
+
+ apiCall(url).then(function(res) {
+ if (offset === 0) while (el.firstChild) el.removeChild(el.firstChild);
+
+ if (!res.data || res.data.length === 0) {
+ if (offset === 0) {
+ var msg = document.createElement('div');
+ msg.style.cssText = 'color:var(--text-muted);font-size:12px;padding:8px;';
+ msg.textContent = res.message || 'No products found';
+ el.appendChild(msg);
+ }
+ return;
+ }
+
+ // Summary bar
+ if (offset === 0) {
+ var summary = document.createElement('div');
+ summary.style.cssText = 'padding:6px 10px;background:var(--surface);border-radius:6px;margin-bottom:8px;font-size:12px;color:var(--text-secondary);';
+ summary.textContent = res.count + ' total products for ' + res.vendor + ' | Showing ' + Math.min(res.data.length, 50) + ' of ' + res.count;
+ el.appendChild(summary);
+ }
+
+ // Product table
+ var table = document.createElement('table');
+ table.style.cssText = 'width:100%;border-collapse:collapse;font-size:11px;';
+ if (offset === 0) {
+ var thead = document.createElement('thead');
+ var headerRow = document.createElement('tr');
+ ['Image','Title','Vendor','SKU','DW SKU','Cost','Retail','Price','Status'].forEach(function(h) {
+ var th = document.createElement('th');
+ th.style.cssText = 'padding:6px 8px;text-align:left;border-bottom:1px solid var(--border);color:var(--text-muted);font-weight:600;white-space:nowrap;';
+ th.textContent = h;
+ headerRow.appendChild(th);
+ });
+ thead.appendChild(headerRow);
+ table.appendChild(thead);
+ }
+ var tbod = document.createElement('tbody');
+ for (var i = 0; i < res.data.length; i++) {
+ var p = res.data[i];
+ var row = document.createElement('tr');
+ row.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
+
+ // Image cell
+ var imgTd = document.createElement('td');
+ imgTd.style.padding = '4px';
+ if (p.image_url) {
+ var img = document.createElement('img');
+ img.src = p.image_url;
+ img.style.cssText = 'width:40px;height:40px;object-fit:cover;border-radius:4px;';
+ img.loading = 'lazy';
+ imgTd.appendChild(img);
+ } else {
+ var noImg = document.createElement('span');
+ noImg.style.cssText = 'color:var(--text-muted);font-size:10px;';
+ noImg.textContent = 'No img';
+ imgTd.appendChild(noImg);
+ }
+ row.appendChild(imgTd);
+
+ // Title
+ var titleTd = document.createElement('td');
+ titleTd.style.cssText = 'padding:4px 8px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ titleTd.textContent = p.title || '-';
+ titleTd.title = p.title || '';
+ row.appendChild(titleTd);
+
+ // Vendor
+ var vendTd = document.createElement('td');
+ vendTd.style.cssText = 'padding:4px 8px;font-size:10px;color:var(--text-muted);max-width:120px;overflow:hidden;text-overflow:ellipsis;';
+ vendTd.textContent = p.vendor || '-';
+ row.appendChild(vendTd);
+
+ // SKU
+ var skuTd = document.createElement('td');
+ skuTd.style.cssText = 'padding:4px 8px;font-family:monospace;font-size:10px;';
+ skuTd.textContent = p.sku || p.mfr_sku || '-';
+ row.appendChild(skuTd);
+
+ // DW SKU
+ var dwSkuTd = document.createElement('td');
+ dwSkuTd.style.cssText = 'padding:4px 8px;font-family:monospace;font-size:10px;color:var(--accent);';
+ dwSkuTd.textContent = p.dw_sku || '-';
+ row.appendChild(dwSkuTd);
+
+ // Cost
+ var costTd = document.createElement('td');
+ costTd.style.cssText = 'padding:4px 8px;';
+ if (p.cost_price && parseFloat(p.cost_price) > 0) {
+ costTd.style.color = 'var(--green)';
+ costTd.textContent = '$' + parseFloat(p.cost_price).toFixed(2);
+ } else {
+ costTd.style.color = 'var(--text-muted)';
+ costTd.textContent = '-';
+ }
+ row.appendChild(costTd);
+
+ // Retail
+ var retailTd = document.createElement('td');
+ retailTd.style.cssText = 'padding:4px 8px;';
+ retailTd.textContent = p.retail_price ? '$' + parseFloat(p.retail_price).toFixed(2) : '-';
+ row.appendChild(retailTd);
+
+ // Price
+ var priceTd = document.createElement('td');
+ priceTd.style.cssText = 'padding:4px 8px;';
+ priceTd.textContent = p.price ? '$' + parseFloat(p.price).toFixed(2) : '-';
+ row.appendChild(priceTd);
+
+ // Status
+ var statusTd = document.createElement('td');
+ statusTd.style.cssText = 'padding:4px 8px;font-size:10px;';
+ statusTd.textContent = p.status || '-';
+ if (p.status === 'active') statusTd.style.color = 'var(--green)';
+ else if (p.status === 'draft') statusTd.style.color = 'var(--orange)';
+ else statusTd.style.color = 'var(--text-muted)';
+ row.appendChild(statusTd);
+
+ tbod.appendChild(row);
+ }
+ table.appendChild(tbod);
+ el.appendChild(table);
+
+ // Load more button
+ if (res.data.length >= 50 && (offset + 50) < res.count) {
+ var moreBtn = document.createElement('button');
+ moreBtn.className = 'action-btn';
+ moreBtn.style.cssText = 'margin:8px 0;font-size:11px;width:100%;';
+ moreBtn.textContent = 'Load more (' + (res.count - offset - 50) + ' remaining)';
+ moreBtn.addEventListener('click', (function(c, o) {
+ return function(e) {
+ e.stopPropagation();
+ e.target.remove();
+ loadProducts(c, o);
+ };
+ })(code, offset + 50));
+ el.appendChild(moreBtn);
+ }
+ });
+}
+
+// Toggle expand
+function toggleExpand(code) {
+ if (expandedRow === code) {
+ expandedRow = null;
+ } else {
+ expandedRow = code;
+ }
+ applyFilters();
+ if (expandedRow === code) {
+ loadObservations(code);
+ loadSampleProduct(code);
+ loadProducts(code, 0);
+ }
+}
+
+// Send chat message to vendor agent
+function sendChat(code) {
+ var input = document.getElementById('chat-input-' + code);
+ var container = document.getElementById('chat-messages-' + code);
+ if (!input || !container) return;
+ var msg = input.value.trim();
+ if (!msg) return;
+ input.value = '';
+
+ // Clear placeholder
+ var placeholder = container.querySelector('[style*="font-style:italic"]');
+ if (placeholder) placeholder.remove();
+
+ // Add user message
+ var userDiv = document.createElement('div');
+ userDiv.style.cssText = 'margin-bottom:8px;text-align:right;';
+ var userBubble = document.createElement('span');
+ userBubble.style.cssText = 'display:inline-block;padding:6px 12px;background:var(--accent);color:#fff;border-radius:12px 12px 2px 12px;font-size:12px;max-width:80%;text-align:left;';
+ userBubble.textContent = msg;
+ userDiv.appendChild(userBubble);
+ container.appendChild(userDiv);
+
+ // Add typing indicator
+ var typingDiv = document.createElement('div');
+ typingDiv.id = 'typing-' + code;
+ typingDiv.style.cssText = 'margin-bottom:8px;';
+ var typingBubble = document.createElement('span');
+ typingBubble.style.cssText = 'display:inline-block;padding:6px 12px;background:var(--bg-input);border-radius:12px 12px 12px 2px;font-size:12px;color:var(--text-muted);';
+ typingBubble.textContent = 'Thinking...';
+ typingDiv.appendChild(typingBubble);
+ container.appendChild(typingDiv);
+ container.scrollTop = container.scrollHeight;
+
+ apiCall('/api/vendors/' + encodeURIComponent(code) + '/chat', {
+ method: 'POST',
+ body: JSON.stringify({ message: msg })
+ }).then(function(res) {
+ var typing = document.getElementById('typing-' + code);
+ if (typing) typing.remove();
+ var respDiv = document.createElement('div');
+ respDiv.style.cssText = 'margin-bottom:8px;';
+ var respBubble = document.createElement('span');
+ respBubble.style.cssText = 'display:inline-block;padding:8px 12px;background:var(--bg-input);border-radius:12px 12px 12px 2px;font-size:12px;max-width:90%;white-space:pre-wrap;line-height:1.5;color:var(--text-primary);';
+ if (res.success) {
+ respBubble.textContent = res.response;
+ var sourceTag = document.createElement('div');
+ sourceTag.style.cssText = 'font-size:9px;color:var(--text-muted);margin-top:4px;';
+ sourceTag.textContent = 'Source: ' + (res.source === 'agent' ? 'Agent direct' : 'Catalog data');
+ respDiv.appendChild(respBubble);
+ respDiv.appendChild(sourceTag);
+ } else {
+ respBubble.style.color = 'var(--red)';
+ respBubble.textContent = 'Error: ' + (res.error || 'Failed to get response');
+ respDiv.appendChild(respBubble);
+ }
+ container.appendChild(respDiv);
+ container.scrollTop = container.scrollHeight;
+ }).catch(function(err) {
+ var typing = document.getElementById('typing-' + code);
+ if (typing) typing.remove();
+ var errDiv = document.createElement('div');
+ errDiv.style.cssText = 'margin-bottom:8px;';
+ var errBubble = document.createElement('span');
+ errBubble.style.cssText = 'display:inline-block;padding:6px 12px;background:rgba(239,68,68,0.1);border-radius:12px;font-size:12px;color:var(--red);';
+ errBubble.textContent = 'Error: ' + err.message;
+ errDiv.appendChild(errBubble);
+ container.appendChild(errDiv);
+ });
+}
+
+// Load sample product card for a vendor
+function loadSampleProduct(code) {
+ var container = document.getElementById('sample-product-' + code);
+ if (!container) return;
+ apiCall('/api/vendors/' + encodeURIComponent(code) + '/sample-product')
+ .then(function(res) {
+ container.textContent = '';
+ if (!res.success || !res.data) {
+ container.style.cssText = 'color:var(--text-muted);font-size:12px;padding:8px;';
+ container.textContent = res.message || 'No catalog data found';
+ return;
+ }
+ var p = res.data;
+ var schema = res.schema || [];
+
+ // Card layout: image left, fields right
+ var card = document.createElement('div');
+ card.style.cssText = 'display:flex;gap:16px;flex-wrap:wrap;';
+
+ // Image
+ if (p.image_url) {
+ var imgWrap = document.createElement('div');
+ imgWrap.style.cssText = 'flex-shrink:0;width:180px;';
+ var img = document.createElement('img');
+ img.src = p.image_url;
+ img.alt = p.pattern_name || p.mfr_sku || 'Product';
+ img.style.cssText = 'width:180px;height:auto;border-radius:8px;border:1px solid var(--border);object-fit:cover;max-height:240px;';
+ img.onerror = function() { this.style.display = 'none'; };
+ imgWrap.appendChild(img);
+
+ // Show additional images count if available
+ if (p.all_images) {
+ try {
+ var allImgs = JSON.parse(p.all_images);
+ if (allImgs && allImgs.length > 1) {
+ var imgCount = document.createElement('div');
+ imgCount.style.cssText = 'font-size:10px;color:var(--text-muted);margin-top:4px;text-align:center;';
+ imgCount.textContent = '+' + (allImgs.length - 1) + ' more images';
+ imgWrap.appendChild(imgCount);
+ }
+ } catch (e) {}
+ }
+ card.appendChild(imgWrap);
+ }
+
+ // Fields grid
+ var fieldsWrap = document.createElement('div');
+ fieldsWrap.style.cssText = 'flex:1;min-width:300px;';
+
+ // Product title
+ var titleDiv = document.createElement('div');
+ titleDiv.style.cssText = 'font-size:14px;font-weight:700;margin-bottom:8px;color:var(--text-primary);';
+ titleDiv.textContent = (p.pattern_name || '') + (p.color_name ? ' — ' + p.color_name : '') + (p.mfr_sku ? ' (' + p.mfr_sku + ')' : '');
+ fieldsWrap.appendChild(titleDiv);
+
+ // Schema coverage grid
+ var fieldGrid = document.createElement('div');
+ fieldGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:4px 12px;';
+
+ var skipFields = ['id', 'created_at', 'updated_at', 'last_scraped'];
+ for (var i = 0; i < schema.length; i++) {
+ var col = schema[i].column_name;
+ if (skipFields.indexOf(col) >= 0) continue;
+ var val = p[col];
+ var hasValue = val !== null && val !== undefined && val !== '';
+ var fieldDiv = document.createElement('div');
+ fieldDiv.style.cssText = 'display:flex;align-items:baseline;gap:4px;font-size:11px;padding:2px 0;';
+ var dot = document.createElement('span');
+ dot.style.cssText = 'width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-top:3px;' + (hasValue ? 'background:var(--green);' : 'background:var(--red);opacity:0.5;');
+ fieldDiv.appendChild(dot);
+ var fieldLabel = document.createElement('span');
+ fieldLabel.style.cssText = 'color:var(--text-muted);min-width:80px;';
+ fieldLabel.textContent = col;
+ fieldDiv.appendChild(fieldLabel);
+ var fieldVal = document.createElement('span');
+ fieldVal.style.cssText = 'color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px;';
+ fieldVal.textContent = hasValue ? String(val).substring(0, 60) : '—';
+ fieldVal.title = hasValue ? String(val) : 'Empty';
+ fieldDiv.appendChild(fieldVal);
+ fieldGrid.appendChild(fieldDiv);
+ }
+ fieldsWrap.appendChild(fieldGrid);
+
+ // Schema summary
+ var filled = schema.filter(function(s) { var v2 = p[s.column_name]; return v2 !== null && v2 !== undefined && v2 !== ''; }).length;
+ var summaryDiv = document.createElement('div');
+ summaryDiv.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:6px;';
+ summaryDiv.textContent = 'Schema: ' + filled + '/' + schema.length + ' fields populated (' + Math.round(filled/schema.length*100) + '%) · Table: ' + (res.vendor || '');
+ fieldsWrap.appendChild(summaryDiv);
+
+ card.appendChild(fieldsWrap);
+ container.appendChild(card);
+ })
+ .catch(function(err) {
+ container.textContent = '';
+ container.style.cssText = 'color:var(--red);font-size:12px;padding:8px;';
+ container.textContent = 'Error loading sample: ' + err.message;
+ });
+}
+
+// Load observations for a vendor
+function loadObservations(code) {
+ var el = document.getElementById('obs-' + code);
+ if (!el) return;
+ apiCall('/api/vendors/' + encodeURIComponent(code) + '/observations')
+ .then(function(res) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ var obs = res.data || [];
+ if (Array.isArray(obs) && obs.length > 0) {
+ for (var i = 0; i < Math.min(obs.length, 20); i++) {
+ var item = document.createElement('div');
+ item.style.padding = '6px 8px';
+ item.style.borderBottom = '1px solid var(--border)';
+ item.style.fontSize = '12px';
+ item.style.color = 'var(--text-secondary)';
+ var text = typeof obs[i] === 'string' ? obs[i] : (obs[i].message || obs[i].text || JSON.stringify(obs[i]));
+ item.textContent = text;
+ el.appendChild(item);
+ }
+ } else {
+ var msg = document.createElement('div');
+ msg.style.color = 'var(--text-muted)';
+ msg.style.fontSize = '12px';
+ msg.style.padding = '8px';
+ msg.textContent = res.message || 'No observations available';
+ el.appendChild(msg);
+ }
+ })
+ .catch(function(err) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ var errMsg = document.createElement('div');
+ errMsg.style.color = 'var(--text-muted)';
+ errMsg.style.fontSize = '12px';
+ errMsg.style.padding = '8px';
+ errMsg.textContent = 'Failed to load: ' + err.message;
+ el.appendChild(errMsg);
+ });
+}
+
+// Select
+function toggleSelect(code, checked) {
+ if (checked) selectedVendors[code] = true;
+ else delete selectedVendors[code];
+ updateBulkUI();
+}
+
+function toggleSelectAll(checked) {
+ var rows = document.querySelectorAll('#vendorTableBody > tr:not(.drill-down)');
+ for (var i = 0; i < rows.length; i++) {
+ var cb = rows[i].querySelector('.cb-cell input[type="checkbox"]');
+ if (cb) {
+ var code = rows[i].getAttribute('data-code');
+ if (code) {
+ if (checked) selectedVendors[code] = true;
+ else delete selectedVendors[code];
+ cb.checked = checked;
+ }
+ }
+ }
+ updateBulkUI();
+}
+
+function updateBulkUI() {
+ var cnt = Object.keys(selectedVendors).length;
+ document.getElementById('selectedCount').textContent = cnt > 0 ? cnt + ' selected' : '';
+ document.getElementById('bulkCrawlBtn').disabled = cnt === 0;
+ var ltBtn = document.getElementById('launchTestBtn');
+ if (ltBtn) {
+ ltBtn.disabled = cnt === 0;
+ ltBtn.textContent = cnt > 0 ? 'Launch 1 Test SKU (' + cnt + ')' : 'Launch 1 Test SKU';
+ }
+}
+
+function launchTestSelected() {
+ var codes = Object.keys(selectedVendors);
+ if (!codes.length) return showToast('Select vendors first', 'error');
+ if (!confirm('Launch 1 newest SKU per vendor to Shopify for ' + codes.length + ' vendor(s)?\\n\\nEach goes through Silas validation. Products created as ACTIVE with metafields.')) return;
+
+ var btn = document.getElementById('launchTestBtn');
+ btn.disabled = true;
+ btn.textContent = 'Launching...';
+
+ apiCall('/api/launch-test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendor_codes: codes })
+ }).then(function(res) {
+ if (res.success) {
+ var msg = 'Launched ' + res.launched + '/' + res.total + ' vendors';
+ showToast(msg, res.launched > 0 ? 'success' : 'warning');
+ // Show details
+ var details = res.results.map(function(r) {
+ return r.vendor + ': ' + r.status + (r.sku ? ' (' + r.sku + ')' : '') + (r.reason ? ' - ' + r.reason : '');
+ }).join('\\n');
+ alert('Launch Test Results:\\n\\n' + details);
+ } else {
+ showToast('Launch failed: ' + (res.error || 'Unknown'), 'error');
+ }
+ btn.textContent = 'Launch 1 Test SKU';
+ btn.disabled = false;
+ updateBulkUI();
+ }).catch(function(err) {
+ showToast('Error: ' + err.message, 'error');
+ btn.textContent = 'Launch 1 Test SKU';
+ btn.disabled = false;
+ });
+}
+
+// Vendor action
+function vendorAction(code, action, btn) {
+ btn.classList.add('loading');
+ var path;
+ if (action === 'score-website') {
+ path = '/api/vendors/' + encodeURIComponent(code) + '/score';
+ } else if (action === 'update-prices') {
+ path = '/api/vendors/' + encodeURIComponent(code) + '/update-prices';
+ } else {
+ path = '/api/vendors/' + encodeURIComponent(code) + '/scan';
+ }
+ apiCall(path, { method: 'POST', body: JSON.stringify({ action: action }) })
+ .then(function(res) {
+ if (res.success) {
+ if (action === 'score-website' && res.data) {
+ showToast(code + ': Score ' + res.data.score + '/10 — ' + (res.data.summary || ''), 'success');
+ loadVendors(); // Refresh to show new score
+ } else {
+ showToast(code + ': ' + action + ' sent to ' + (res.agent || 'agent'), 'success');
+ }
+ } else {
+ showToast(code + ': ' + (res.error || 'Failed'), 'error');
+ }
+ btn.classList.remove('loading');
+ })
+ .catch(function(err) {
+ showToast(code + ': ' + err.message, 'error');
+ btn.classList.remove('loading');
+ });
+}
+
+// Bulk action
+function bulkAction(action) {
+ var codes = Object.keys(selectedVendors);
+ if (codes.length === 0) return;
+ showToast('Sending ' + action + ' to ' + codes.length + ' vendors...', 'info');
+
+ if (action === 'scan') {
+ // Use the dedicated bulk-scan endpoint
+ apiCall('/api/bulk-scan', {
+ method: 'POST',
+ body: JSON.stringify({ codes: codes })
+ }).then(function(res) {
+ if (res.success) {
+ var triggered = 0, failed = 0;
+ for (var i = 0; i < (res.results || []).length; i++) {
+ if (res.results[i].status === 'triggered') triggered++;
+ else failed++;
+ }
+ showToast('Bulk scan: ' + triggered + ' triggered, ' + failed + ' failed/unreachable', triggered > 0 ? 'success' : 'error');
+ }
+ }).catch(function(err) {
+ showToast('Bulk scan failed: ' + err.message, 'error');
+ });
+ return;
+ }
+
+ // Fallback to bulk-action for other action types
+ apiCall('/api/bulk-action', {
+ method: 'POST',
+ body: JSON.stringify({ action: action, vendorCodes: codes })
+ }).then(function(res) {
+ if (res.success) {
+ var ok = 0, fail = 0;
+ for (var i = 0; i < res.results.length; i++) {
+ if (res.results[i].success) ok++;
+ else fail++;
+ }
+ showToast('Bulk ' + action + ': ' + ok + ' success, ' + fail + ' failed', ok > 0 ? 'success' : 'error');
+ }
+ }).catch(function(err) {
+ showToast('Bulk action failed: ' + err.message, 'error');
+ });
+}
+
+// Private Label updates
+function updateBrandsPage(code, checked) {
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify({ show_on_brands_page: checked })
+ }).then(function(res) {
+ if (res.success) {
+ for (var i = 0; i < vendors.length; i++) {
+ if (vendors[i].vendor_code === code) vendors[i].show_on_brands_page = checked;
+ }
+ showToast(code + ': Brands page ' + (checked ? 'added' : 'removed'), 'success');
+ }
+ }).catch(function(err) {
+ showToast('Failed to update: ' + err.message, 'error');
+ });
+}
+
+function updatePL(code, checked) {
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify({ is_private_label: checked })
+ }).then(function(res) {
+ if (res.success) {
+ for (var i = 0; i < vendors.length; i++) {
+ if (vendors[i].vendor_code === code) vendors[i].is_private_label = checked;
+ }
+ applyFilters();
+ showToast(code + ': Private Label ' + (checked ? 'enabled' : 'disabled'), 'success');
+ }
+ }).catch(function(err) {
+ showToast('Failed to update: ' + err.message, 'error');
+ });
+}
+
+function updatePLName(code, name) {
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify({ private_label_name: name })
+ }).then(function(res) {
+ if (res.success) {
+ for (var i = 0; i < vendors.length; i++) {
+ if (vendors[i].vendor_code === code) vendors[i].private_label_name = name;
+ }
+ showToast(code + ': Label name saved', 'success');
+ }
+ }).catch(function(err) {
+ showToast('Failed to save label name: ' + err.message, 'error');
+ });
+}
+
+// Generic field updater (used for display_prices, sample_price, etc.)
+function updateField(code, field, value) {
+ var body = {};
+ body[field] = value;
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify(body)
+ }).then(function(res) {
+ if (res.success) {
+ for (var i = 0; i < vendors.length; i++) {
+ if (vendors[i].vendor_code === code) vendors[i][field] = value;
+ }
+ showToast(code + ': ' + field + ' updated', 'success');
+ }
+ }).catch(function(err) {
+ showToast('Failed to update ' + field + ': ' + err.message, 'error');
+ });
+}
+
+// Learnings
+function saveLearnings(code, text) {
+ apiCall('/api/vendors/' + encodeURIComponent(code), {
+ method: 'PUT',
+ body: JSON.stringify({ learnings: text })
+ }).then(function(res) {
+ if (res.success) {
+ for (var i = 0; i < vendors.length; i++) {
+ if (vendors[i].vendor_code === code) vendors[i].learnings = text;
+ }
+ showToast(code + ': Learnings saved', 'success');
+ }
+ }).catch(function(err) {
+ showToast('Failed to save learnings: ' + err.message, 'error');
+ });
+}
+
+// Agent health is now included in /api/vendors response (server-side cached 30s)
+
+// Tab switching
+function switchTab(tab) {
+ var btns = document.querySelectorAll('.tab-btn');
+ for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active');
+ document.querySelector('.tab-btn[data-tab="' + tab + '"]').classList.add('active');
+ document.getElementById('vendorTab').style.display = tab === 'vendors' ? 'block' : 'none';
+ document.getElementById('vendorControls').style.display = tab === 'vendors' ? 'flex' : 'none';
+ document.getElementById('calendarTab').className = 'calendar-container' + (tab === 'calendar' ? ' show' : '');
+ document.getElementById('qualityTab').style.display = tab === 'quality' ? 'block' : 'none';
+ document.getElementById('discontinuedTab').style.display = tab === 'discontinued' ? 'block' : 'none';
+ document.getElementById('lineManagerTab').style.display = tab === 'linemanager' ? 'block' : 'none';
+ document.getElementById('tagManagerTab').style.display = tab === 'tagmanager' ? 'block' : 'none';
+ document.getElementById('standardizeTab').style.display = tab === 'standardize' ? 'block' : 'none';
+ document.getElementById('newLaunchesTab').style.display = tab === 'newlaunches' ? 'block' : 'none';
+ document.getElementById('productActionsTab').style.display = tab === 'productactions' ? 'block' : 'none';
+ if (tab === 'calendar') renderCalendar();
+ if (tab === 'quality') loadQualityData();
+ if (tab === 'discontinued') loadDiscontinuedData();
+ if (tab === 'linemanager') loadLineManager();
+ if (tab === 'tagmanager') loadTagManager();
+ if (tab === 'standardize') loadStandardize();
+ if (tab === 'newlaunches') loadNewLaunches();
+}
+
+// Calendar rendering using safe DOM methods
+function renderCalendar() {
+ var container = document.getElementById('calendarContent');
+ while (container.firstChild) container.removeChild(container.firstChild);
+
+ var now = new Date();
+ var year = now.getFullYear();
+ var month = now.getMonth();
+ var firstDay = new Date(year, month, 1).getDay();
+ var daysInMonth = new Date(year, month + 1, 0).getDate();
+ var monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
+ var dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+
+ var scheduleMap = {};
+ for (var i = 0; i < vendors.length; i++) {
+ var v = vendors[i];
+ if (v.next_scheduled_crawl) {
+ var dd = new Date(v.next_scheduled_crawl);
+ if (dd.getMonth() === month && dd.getFullYear() === year) {
+ var day = dd.getDate();
+ if (!scheduleMap[day]) scheduleMap[day] = [];
+ scheduleMap[day].push(v);
+ }
+ }
+ }
+
+ var title = document.createElement('h3');
+ title.style.marginBottom = '12px';
+ title.style.color = 'var(--text-primary)';
+ title.textContent = monthNames[month] + ' ' + year;
+ container.appendChild(title);
+
+ var grid = document.createElement('div');
+ grid.className = 'calendar-grid';
+
+ for (var h = 0; h < dayNames.length; h++) {
+ var hdr = document.createElement('div');
+ hdr.className = 'cal-header';
+ hdr.textContent = dayNames[h];
+ grid.appendChild(hdr);
+ }
+
+ for (var e = 0; e < firstDay; e++) {
+ var emptyDay = document.createElement('div');
+ emptyDay.className = 'cal-day empty';
+ grid.appendChild(emptyDay);
+ }
+
+ for (var d = 1; d <= daysInMonth; d++) {
+ var dayDiv = document.createElement('div');
+ dayDiv.className = 'cal-day';
+ if (d === now.getDate()) dayDiv.style.borderColor = 'var(--accent)';
+
+ var dayNum = document.createElement('div');
+ dayNum.className = 'day-num';
+ dayNum.textContent = d;
+ dayDiv.appendChild(dayNum);
+
+ var vendorsToday = scheduleMap[d] || [];
+ for (var vi = 0; vi < vendorsToday.length; vi++) {
+ var vv = vendorsToday[vi];
+ var vendorChip = document.createElement('div');
+ vendorChip.className = 'cal-vendor';
+ if (vv.crawl_priority === 'HIGH') {
+ vendorChip.style.background = 'var(--red-bg)';
+ vendorChip.style.color = 'var(--red)';
+ } else if (vv.crawl_priority === 'MEDIUM') {
+ vendorChip.style.background = 'var(--orange-bg)';
+ vendorChip.style.color = 'var(--orange)';
+ } else {
+ vendorChip.style.background = 'var(--green-bg)';
+ vendorChip.style.color = 'var(--green)';
+ }
+ vendorChip.title = vv.vendor_name;
+ vendorChip.textContent = vv.vendor_name;
+ dayDiv.appendChild(vendorChip);
+ }
+
+ grid.appendChild(dayDiv);
+ }
+
+ container.appendChild(grid);
+}
+
+// Date formatting
+function fmtDateFull(d) {
+ if (!d) return '-';
+ var dt = new Date(d);
+ return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Los_Angeles' })
+ + ' ' + dt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Los_Angeles' });
+}
+
+function formatTimePT(d) {
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'America/Los_Angeles' }) + ' PT';
+}
+
+// Init
+Promise.all([loadVendors(), loadStats()]).then(function() {
+ document.getElementById('lastRefresh').textContent = 'Last refresh: ' + formatTimePT(new Date());
+ // Update online count after both vendors and stats loaded
+ var onlineCount = vendors.filter(function(v) { return v.agent_status === 'online'; }).length;
+ var el = document.getElementById('statAgentsOnline');
+ if (el) el.textContent = onlineCount + ' / ' + vendors.length;
+});
+
+// ?vendor=<name> deep-link focus (from the CNCP vendors popout). Drops the name into the
+// search box + filters so the linked vendor is front-and-center, and scrolls to it if unique.
+(function(){
+ try {
+ var vName = new URLSearchParams(location.search).get('vendor');
+ if (!vName) return;
+ var apply = function(){
+ var sb = document.getElementById('searchInput');
+ if (sb) { sb.value = vName; if (typeof applyFilters === 'function') applyFilters(); }
+ var m = (vendors || []).filter(function(v){ return (v.vendor_name || '').toLowerCase() === vName.toLowerCase(); });
+ if (m.length === 1 && m[0].vendor_code && typeof scrollToVendor === 'function') { try { scrollToVendor(m[0].vendor_code); } catch(e){} }
+ };
+ var tries = 0;
+ (function wait(){ if ((typeof vendors !== 'undefined' && vendors && vendors.length) || tries > 30) return apply(); tries++; setTimeout(wait, 200); })();
+ } catch(e){}
+})();
+
+// Auto-refresh every 30 seconds
+setInterval(function() {
+ Promise.all([loadVendors(), loadStats()]).then(function() {
+ document.getElementById('lastRefresh').textContent = 'Last refresh: ' + formatTimePT(new Date());
+ var onlineCount = vendors.filter(function(v) { return v.agent_status === 'online'; }).length;
+ var el = document.getElementById('statAgentsOnline');
+ if (el) el.textContent = onlineCount + ' / ' + vendors.length;
+ });
+}, 30000);
+
+// --- Live Totals Poller (real-time catalog counts) ---
+var prevLiveTotals = {};
+function animateStat(elId, newVal) {
+ var el = document.getElementById(elId);
+ if (!el) return;
+ var oldText = el.textContent.replace(/,/g, '');
+ var oldVal = parseInt(oldText) || 0;
+ var formatted = Number(newVal).toLocaleString();
+ if (newVal !== oldVal && oldVal > 0) {
+ el.textContent = formatted;
+ el.classList.remove('stat-flash-up', 'stat-flash-down');
+ void el.offsetWidth; // force reflow
+ el.classList.add(newVal > oldVal ? 'stat-flash-up' : 'stat-flash-down');
+ } else {
+ el.textContent = formatted;
+ }
+}
+function pollLiveTotals() {
+ fetch('/api/live-totals', { headers: { 'Authorization': '' /* auth via httpOnly vcc_session cookie — no embedded credential */ }})
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (!data.success) return;
+ animateStat('statTotalProducts', data.catalog_total);
+ animateStat('statShopifyCount', data.shopify_total);
+ animateStat('statWithCost', data.with_cost);
+ animateStat('statWithImages', data.with_images);
+ animateStat('statWithWidth', data.with_width);
+ animateStat('statWithRepeat', data.with_repeat);
+ animateStat('statMissingImages', data.not_on_shopify);
+ animateStat('statToDisc', data.to_discontinue);
+ // Update coverage %
+ var covEl = document.getElementById('statCoverage');
+ if (covEl && data.catalog_total > 0) {
+ covEl.textContent = ((data.with_cost / data.catalog_total) * 100).toFixed(1) + '%';
+ }
+ })
+ .catch(function() {});
+}
+pollLiveTotals();
+setInterval(pollLiveTotals, 10000);
+
+// --- Live Activity Ticker ---
+var lastActivityTs = null;
+function loadActivity() {
+ var url = '/api/activity?limit=30';
+ if (lastActivityTs) url += '&since=' + encodeURIComponent(lastActivityTs);
+ fetch(url, { headers: { 'Authorization': '' /* auth via httpOnly vcc_session cookie — no embedded credential */ }})
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ var entries = data.entries || [];
+ if (entries.length === 0 && lastActivityTs) return;
+ if (entries.length > 0) lastActivityTs = entries[0].ts;
+ var container = document.getElementById('tickerEntries');
+ if (!container) return;
+ // Build entries HTML (doubled for seamless scroll)
+ var html = '';
+ var allEntries = entries.length > 0 ? entries : [{ts: new Date().toISOString(), type: 'info', message: 'System idle — waiting for activity'}];
+ for (var i = 0; i < allEntries.length; i++) {
+ var e = allEntries[i];
+ var t = new Date(e.ts);
+ var timeStr = t.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'America/Los_Angeles' });
+ html += '<span class="ticker-entry"><span class="ticker-time">' + timeStr + '</span>';
+ html += '<span class="ticker-type ' + e.type + '">[' + e.type.toUpperCase() + ']</span> ';
+ html += e.message + '</span>';
+ }
+ // Duplicate for seamless scroll
+ container.innerHTML = html + html;
+ // Update count
+ var countEl = document.getElementById('tickerCount');
+ if (countEl) countEl.textContent = data.total + ' events';
+ // Adjust animation speed based on content width
+ var scrollW = container.scrollWidth / 2;
+ var speed = Math.max(20, scrollW / 50);
+ container.style.animationDuration = speed + 's';
+ })
+ .catch(function() {});
+}
+loadActivity();
+setInterval(loadActivity, 5000);
+
+// ============================================================================
+// DATA QUALITY TAB
+// ============================================================================
+var qualityLoaded = false;
+var qualityVendors = [];
+var qualitySortCol = 'catalog_count';
+var qualitySortDir = 'desc';
+
+function loadQualityData() {
+ if (qualityLoaded) return;
+ apiCall('/api/data-quality/catalog').then(function(res) {
+ if (!res.success) return;
+ qualityLoaded = true;
+ qualityVendors = res.vendors;
+ document.getElementById('qualityLoading').style.display = 'none';
+ renderQualitySummary(res.vendors);
+ renderQualityHeatmap(res.vendors);
+ }).catch(function(err) {
+ document.getElementById('qualityLoading').textContent = 'Failed to load: ' + err.message;
+ });
+}
+
+function renderQualitySummary(vendors) {
+ var totalProducts = 0, totalImages = 0, totalWidth = 0, totalRepeat = 0;
+ for (var i = 0; i < vendors.length; i++) {
+ var v = vendors[i];
+ totalProducts += parseInt(v.catalog_count) || 0;
+ totalImages += parseInt(v.products_with_images) || 0;
+ totalWidth += parseInt(v.catalog_with_width) || 0;
+ totalRepeat += parseInt(v.catalog_with_repeat) || 0;
+ }
+ var cards = [
+ { label: 'Total Products', value: totalProducts.toLocaleString(), color: '#5dade2' },
+ { label: 'Vendors', value: String(vendors.length), color: '#8e44ad' },
+ { label: 'Images', value: (totalProducts > 0 ? (100*totalImages/totalProducts).toFixed(1) : '0') + '%', color: totalImages/totalProducts > 0.8 ? '#27ae60' : '#e67e22' },
+ { label: 'Width', value: (totalProducts > 0 ? (100*totalWidth/totalProducts).toFixed(1) : '0') + '%', color: totalWidth/totalProducts > 0.5 ? '#e67e22' : '#e74c3c' },
+ { label: 'Repeat', value: (totalProducts > 0 ? (100*totalRepeat/totalProducts).toFixed(1) : '0') + '%', color: totalRepeat/totalProducts > 0.5 ? '#e67e22' : '#e74c3c' }
+ ];
+ var container = document.getElementById('qualitySummary');
+ while (container.firstChild) container.removeChild(container.firstChild);
+ for (var c = 0; c < cards.length; c++) {
+ var d = document.createElement('div');
+ d.style.cssText = 'background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center;';
+ var valEl = document.createElement('div');
+ valEl.style.cssText = 'font-size:22px;font-weight:700;color:' + cards[c].color;
+ valEl.textContent = cards[c].value;
+ var lblEl = document.createElement('div');
+ lblEl.style.cssText = 'font-size:11px;color:var(--text-muted);margin-top:4px';
+ lblEl.textContent = cards[c].label;
+ d.appendChild(valEl);
+ d.appendChild(lblEl);
+ container.appendChild(d);
+ }
+}
+
+function filterQualityHeatmap() {
+ var filter = document.getElementById('qualityFilter').value;
+ var filtered = qualityVendors.filter(function(v) {
+ var img = parseFloat(v.image_pct) || 0;
+ var wid = parseFloat(v.width_pct) || 0;
+ var spec = parseFloat(v.spec_completeness) || 0;
+ var minVal = Math.min(img, wid, spec);
+ if (filter === 'critical') return minVal < 50;
+ if (filter === 'warning') return minVal < 80;
+ if (filter === 'good') return img >= 80 && wid >= 80 && spec >= 80;
+ return true;
+ });
+ document.getElementById('qualityHeatmap').textContent = '';
+ renderQualityHeatmap(filtered);
+}
+
+function sortQuality(col) {
+ if (qualitySortCol === col) { qualitySortDir = qualitySortDir === 'asc' ? 'desc' : 'asc'; }
+ else { qualitySortCol = col; qualitySortDir = 'desc'; }
+ filterQualityHeatmap();
+}
+
+function renderQualityHeatmap(vendors) {
+ var container = document.getElementById('qualityHeatmap');
+ container.textContent = '';
+
+ var sorted = vendors.slice().sort(function(a, b) {
+ var av = qualitySortCol === 'vendor_name' ? (a[qualitySortCol] || '').toLowerCase() : (parseFloat(a[qualitySortCol]) || 0);
+ var bv = qualitySortCol === 'vendor_name' ? (b[qualitySortCol] || '').toLowerCase() : (parseFloat(b[qualitySortCol]) || 0);
+ if (av < bv) return qualitySortDir === 'asc' ? -1 : 1;
+ if (av > bv) return qualitySortDir === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ var tbl = document.createElement('table');
+ tbl.style.cssText = 'width:100%;border-collapse:collapse;font-size:12px;';
+
+ var thead = document.createElement('thead');
+ var hr = document.createElement('tr');
+ var cols = [
+ { label: 'Vendor', key: 'vendor_name' },
+ { label: 'Products', key: 'catalog_count' },
+ { label: 'Images %', key: 'image_pct' },
+ { label: 'Width %', key: 'width_pct' },
+ { label: 'Repeat %', key: 'repeat_pct' },
+ { label: 'Cost %', key: 'cost_pct' },
+ { label: 'Spec Fill %', key: 'spec_completeness' }
+ ];
+ for (var i = 0; i < cols.length; i++) {
+ var th = document.createElement('th');
+ th.textContent = cols[i].label + (qualitySortCol === cols[i].key ? (qualitySortDir === 'asc' ? ' \\u25B2' : ' \\u25BC') : '');
+ th.style.cssText = 'padding:8px 10px;text-align:' + (i === 0 ? 'left' : 'right') + ';color:var(--text-secondary);border-bottom:1px solid var(--border);font-weight:500;white-space:nowrap;cursor:pointer;user-select:none;';
+ th.onclick = (function(key) { return function() { sortQuality(key); }; })(cols[i].key);
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ tbl.appendChild(thead);
+
+ var tbody = document.createElement('tbody');
+ for (var j = 0; j < sorted.length; j++) {
+ var v = sorted[j];
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid rgba(42,45,69,0.5);';
+
+ var tdName = document.createElement('td');
+ tdName.style.cssText = 'padding:6px 10px;color:var(--text-primary);font-weight:500;white-space:nowrap;';
+ tdName.textContent = v.vendor_name;
+ tr.appendChild(tdName);
+
+ var tdCount = document.createElement('td');
+ tdCount.style.cssText = 'padding:6px 10px;text-align:right;color:var(--text-secondary);';
+ tdCount.textContent = Number(v.catalog_count).toLocaleString();
+ tr.appendChild(tdCount);
+
+ var pcts = [
+ parseFloat(v.image_pct) || 0,
+ parseFloat(v.width_pct) || 0,
+ parseFloat(v.repeat_pct) || 0,
+ parseFloat(v.cost_pct) || 0,
+ parseFloat(v.spec_completeness) || 0
+ ];
+
+ for (var k = 0; k < pcts.length; k++) {
+ var td = document.createElement('td');
+ td.style.cssText = 'padding:6px 10px;text-align:right;font-weight:600;';
+ var pct = pcts[k];
+ var color, bg;
+ if (pct >= 80) { color = 'var(--green)'; bg = 'var(--green-bg)'; }
+ else if (pct >= 50) { color = 'var(--orange)'; bg = 'var(--orange-bg)'; }
+ else { color = 'var(--red)'; bg = 'var(--red-bg)'; }
+ td.style.color = color;
+ td.style.background = bg;
+ td.style.borderRadius = '4px';
+ td.textContent = pct.toFixed(1) + '%';
+ tr.appendChild(td);
+ }
+
+ tbody.appendChild(tr);
+ }
+ tbl.appendChild(tbody);
+ container.appendChild(tbl);
+
+ document.getElementById('qualityIssueCount').textContent = sorted.length + ' vendors shown';
+}
+
+function sendQualitySlackAlert() {
+ var btn = event.target;
+ btn.disabled = true;
+ btn.textContent = 'Sending...';
+ apiCall('/api/data-quality/slack-alert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threshold: 80 }) }).then(function(res) {
+ btn.disabled = false;
+ btn.textContent = 'Slack Alert';
+ if (res.success) {
+ showToast('Slack alert sent: ' + res.flagged + ' vendors flagged below ' + (res.threshold || 80) + '%', 'success');
+ } else {
+ showToast('Alert failed: ' + (res.error || 'Unknown'), 'error');
+ }
+ }).catch(function(err) {
+ btn.disabled = false;
+ btn.textContent = 'Slack Alert';
+ showToast('Alert error: ' + err.message, 'error');
+ });
+}
+
+function runQualityAudit() {
+ var btn = event.target;
+ btn.disabled = true;
+ btn.textContent = 'Running...';
+ apiCall('/api/data-quality/audit', { method: 'POST' }).then(function(res) {
+ btn.disabled = false;
+ btn.textContent = 'Run Audit';
+ if (res.success) {
+ showToast('Audit complete: ' + res.issues_created + ' issues found across ' + res.products_audited + ' products', 'success');
+ document.getElementById('qualityIssueCount').textContent = res.issues_created + ' open issues';
+ } else {
+ showToast('Audit failed: ' + (res.error || 'Unknown'), 'error');
+ }
+ }).catch(function(err) {
+ btn.disabled = false;
+ btn.textContent = 'Run Audit';
+ showToast('Audit error: ' + err.message, 'error');
+ });
+}
+
+// --- Discontinued Tab ---
+var discPage = 1;
+var discData = null;
+
+function loadDiscontinuedData() {
+ var vendorFilter = document.getElementById('discVendorFilter').value;
+ var statusFilter = document.getElementById('discStatusFilter').value;
+ document.getElementById('discLoading').style.display = 'block';
+ var url = '/api/discontinued?page=' + discPage + '&limit=50&vendor=' + encodeURIComponent(vendorFilter) + '&status=' + encodeURIComponent(statusFilter);
+ apiCall(url).then(function(data) {
+ discData = data;
+ document.getElementById('discLoading').style.display = 'none';
+ renderDiscSummary(data.summary);
+ renderDiscVendors(data.vendors);
+ renderDiscProducts(data.products, data.pagination);
+ var sel = document.getElementById('discVendorFilter');
+ if (sel.options.length <= 1) {
+ data.vendors.forEach(function(v) {
+ var opt = document.createElement('option');
+ opt.value = v.vendor_name;
+ opt.textContent = v.vendor_name + ' (' + v.total + ')';
+ sel.appendChild(opt);
+ });
+ }
+ }).catch(function(err) {
+ document.getElementById('discLoading').style.display = 'none';
+ document.getElementById('discProducts').textContent = 'Error: ' + err.message;
+ });
+}
+
+function makeCard(label, value, color) {
+ var d = document.createElement('div');
+ d.style.cssText = 'background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center';
+ var val = document.createElement('div');
+ val.style.cssText = 'font-size:22px;font-weight:700;color:' + color;
+ val.textContent = value;
+ var lbl = document.createElement('div');
+ lbl.style.cssText = 'font-size:11px;color:var(--text-muted);margin-top:4px';
+ lbl.textContent = label;
+ d.appendChild(val);
+ d.appendChild(lbl);
+ return d;
+}
+
+function renderDiscSummary(s) {
+ var c = document.getElementById('discSummary');
+ while (c.firstChild) c.removeChild(c.firstChild);
+ c.appendChild(makeCard('Total Discontinued', Number(s.total_discontinued).toLocaleString(), '#e74c3c'));
+ c.appendChild(makeCard('Vendors', s.vendor_count, 'var(--accent)'));
+ c.appendChild(makeCard('On Shopify', Number(s.matched_shopify).toLocaleString(), '#3498db'));
+ c.appendChild(makeCard('Still ACTIVE', Number(s.still_active).toLocaleString(), '#e67e22'));
+ c.appendChild(makeCard('Already Archived', Number(s.already_archived).toLocaleString(), '#2ecc71'));
+ c.appendChild(makeCard('In Draft', Number(s.in_draft).toLocaleString(), '#9b59b6'));
+ c.appendChild(makeCard('Not on Shopify', Number(s.not_on_shopify).toLocaleString(), 'var(--text-muted)'));
+}
+
+function makeTh(text, align) {
+ var th = document.createElement('th');
+ th.style.cssText = 'text-align:' + (align||'left') + ';padding:6px;color:var(--text-muted)';
+ th.textContent = text;
+ return th;
+}
+
+function makeTd(text, style) {
+ var td = document.createElement('td');
+ td.style.cssText = style || 'padding:6px';
+ td.textContent = text || '';
+ return td;
+}
+
+function renderDiscVendors(vendors) {
+ var c = document.getElementById('discVendorBreakdown');
+ while (c.firstChild) c.removeChild(c.firstChild);
+ if (!vendors || vendors.length === 0) return;
+ var t = document.createElement('table');
+ t.style.cssText = 'width:100%;border-collapse:collapse;font-size:12px';
+ var thead = document.createElement('thead');
+ var hRow = document.createElement('tr');
+ hRow.style.borderBottom = '1px solid var(--border)';
+ hRow.appendChild(makeTh('Vendor'));
+ hRow.appendChild(makeTh('Total','right'));
+ hRow.appendChild(makeTh('On Shopify','right'));
+ hRow.appendChild(makeTh('Still Active','right'));
+ hRow.appendChild(makeTh('Archived','right'));
+ hRow.appendChild(makeTh('Draft','right'));
+ thead.appendChild(hRow);
+ t.appendChild(thead);
+ var tbody = document.createElement('tbody');
+ vendors.slice(0, 30).forEach(function(v) {
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid var(--border);cursor:pointer';
+ tr.onclick = function() { document.getElementById('discVendorFilter').value = v.vendor_name; discPage = 1; loadDiscontinuedData(); };
+ tr.appendChild(makeTd(v.vendor_name || 'Unknown', 'padding:6px;color:var(--text-primary)'));
+ tr.appendChild(makeTd(v.total, 'text-align:right;padding:6px'));
+ tr.appendChild(makeTd(v.on_shopify, 'text-align:right;padding:6px'));
+ tr.appendChild(makeTd(v.active, 'text-align:right;padding:6px;' + (Number(v.active) > 0 ? 'color:#e67e22;font-weight:700' : '')));
+ tr.appendChild(makeTd(v.archived, 'text-align:right;padding:6px;color:#2ecc71'));
+ tr.appendChild(makeTd(v.draft, 'text-align:right;padding:6px'));
+ tbody.appendChild(tr);
+ });
+ t.appendChild(tbody);
+ c.appendChild(t);
+}
+
+function makeStatusBadge(status) {
+ var span = document.createElement('span');
+ if (status === 'active') { span.style.cssText = 'background:#e67e22;color:#fff;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600'; span.textContent = 'ACTIVE'; }
+ else if (status === 'archived') { span.style.cssText = 'background:#2ecc71;color:#fff;padding:2px 6px;border-radius:4px;font-size:10px'; span.textContent = 'Archived'; }
+ else if (status === 'draft') { span.style.cssText = 'background:#9b59b6;color:#fff;padding:2px 6px;border-radius:4px;font-size:10px'; span.textContent = 'Draft'; }
+ else { span.style.cssText = 'color:var(--text-muted);font-size:10px'; span.textContent = 'Not on Shopify'; }
+ return span;
+}
+
+function renderDiscProducts(products, pag) {
+ var info = document.getElementById('discPageInfo');
+ info.textContent = 'Page ' + pag.page + ' of ' + pag.pages + ' (' + pag.total.toLocaleString() + ' products)';
+ var c = document.getElementById('discProducts');
+ while (c.firstChild) c.removeChild(c.firstChild);
+ if (!products || products.length === 0) {
+ var empty = document.createElement('div');
+ empty.style.cssText = 'color:var(--text-muted);padding:20px;text-align:center';
+ empty.textContent = 'No products found for this filter.';
+ c.appendChild(empty);
+ return;
+ }
+ var t = document.createElement('table');
+ t.style.cssText = 'width:100%;border-collapse:collapse;font-size:11px';
+ var thead = document.createElement('thead');
+ var hRow = document.createElement('tr');
+ hRow.style.borderBottom = '1px solid var(--border)';
+ hRow.appendChild(makeTh('Img'));
+ hRow.appendChild(makeTh('DW SKU'));
+ hRow.appendChild(makeTh('MFR SKU'));
+ hRow.appendChild(makeTh('Title'));
+ hRow.appendChild(makeTh('Vendor'));
+ hRow.appendChild(makeTh('Shopify Status', 'center'));
+ hRow.appendChild(makeTh('Archive Tag'));
+ thead.appendChild(hRow);
+ t.appendChild(thead);
+ var tbody = document.createElement('tbody');
+ products.forEach(function(p) {
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid var(--border)';
+ var imgTd = document.createElement('td');
+ imgTd.style.cssText = 'padding:4px 6px';
+ if (p.image_url) {
+ var img = document.createElement('img');
+ img.src = p.image_url;
+ img.style.cssText = 'width:36px;height:36px;object-fit:cover;border-radius:4px';
+ img.onerror = function() { this.style.display = 'none'; };
+ imgTd.appendChild(img);
+ } else {
+ var placeholder = document.createElement('div');
+ placeholder.style.cssText = 'width:36px;height:36px;background:var(--border);border-radius:4px';
+ imgTd.appendChild(placeholder);
+ }
+ tr.appendChild(imgTd);
+ tr.appendChild(makeTd(p.dw_sku, 'padding:6px;font-family:monospace;font-size:10px'));
+ tr.appendChild(makeTd(p.mfr_sku, 'padding:6px;font-family:monospace;font-size:10px'));
+ tr.appendChild(makeTd(p.title || p.dw_handle || '', 'padding:6px;color:var(--text-primary);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'));
+ tr.appendChild(makeTd(p.vendor_name || p.shopify_vendor || '', 'padding:6px'));
+ var statusTd = document.createElement('td');
+ statusTd.style.cssText = 'text-align:center;padding:6px';
+ statusTd.appendChild(makeStatusBadge(p.shopify_status));
+ tr.appendChild(statusTd);
+ tr.appendChild(makeTd(p.archive_tag, 'padding:6px;font-size:10px;color:var(--text-muted)'));
+ tbody.appendChild(tr);
+ });
+ t.appendChild(tbody);
+ c.appendChild(t);
+}
+
+function discPrevPage() { if (discPage > 1) { discPage--; loadDiscontinuedData(); } }
+function discNextPage() { if (discData && discPage < discData.pagination.pages) { discPage++; loadDiscontinuedData(); } }
+
+// ==================== Assign DW SKUs ====================
+function assignDwSkus(code, btn) {
+ btn.disabled = true;
+ var origText = btn.textContent;
+ btn.textContent = 'Assigning...';
+ apiCall('/api/vendors/' + encodeURIComponent(code) + '/assign-skus', { method: 'POST' })
+ .then(function(res) {
+ if (res.success) {
+ btn.textContent = res.assigned + ' assigned';
+ btn.style.color = 'var(--green)';
+ showToast(res.message + ' (' + res.range + ')', 'success');
+ // Refresh vendor data after a brief delay
+ setTimeout(function() {
+ btn.textContent = origText;
+ btn.disabled = false;
+ btn.style.color = 'var(--accent)';
+ loadVendors();
+ }, 3000);
+ } else {
+ btn.textContent = 'Error';
+ btn.style.color = 'var(--red)';
+ showToast('SKU assignment failed: ' + (res.error || 'Unknown'), 'error');
+ setTimeout(function() { btn.textContent = origText; btn.disabled = false; btn.style.color = 'var(--accent)'; }, 3000);
+ }
+ })
+ .catch(function(err) {
+ btn.textContent = 'Error';
+ btn.style.color = 'var(--red)';
+ showToast('SKU assignment error: ' + err.message, 'error');
+ setTimeout(function() { btn.textContent = origText; btn.disabled = false; btn.style.color = 'var(--accent)'; }, 3000);
+ });
+}
+
+// ==================== Create Inquiry Page ====================
+function createInquiryPage(code, name, btn) {
+ btn.disabled = true;
+ var origText = btn.textContent;
+ btn.textContent = 'Creating...';
+ apiCall('/api/vendors/' + encodeURIComponent(code) + '/inquiry-page', { method: 'POST' })
+ .then(function(res) {
+ if (res.success) {
+ if (res.existing) {
+ btn.textContent = 'Page exists';
+ btn.style.color = '#4fc3f7';
+ showToast(name + ' inquiry page already exists', 'info');
+ } else {
+ btn.textContent = 'Page created';
+ btn.style.color = 'var(--green)';
+ showToast(name + ' inquiry page created on Shopify', 'success');
+ }
+ setTimeout(function() { btn.textContent = origText; btn.disabled = false; btn.style.color = '#4fc3f7'; }, 4000);
+ } else {
+ btn.textContent = 'Error';
+ btn.style.color = 'var(--red)';
+ showToast('Page creation failed: ' + (res.error || 'Unknown'), 'error');
+ setTimeout(function() { btn.textContent = origText; btn.disabled = false; btn.style.color = '#4fc3f7'; }, 3000);
+ }
+ })
+ .catch(function(err) {
+ btn.textContent = 'Error';
+ btn.style.color = 'var(--red)';
+ showToast('Inquiry page error: ' + err.message, 'error');
+ setTimeout(function() { btn.textContent = origText; btn.disabled = false; btn.style.color = '#4fc3f7'; }, 3000);
+ });
+}
+
+// ==================== Line Manager ====================
+var lmLines = [];
+var lmSelected = new Set();
+var lmLoaded = false;
+var lmQueuedConfig = null;
+var lmPollInterval = null;
+
+function lmCommaNum(n) {
+ return (n || 0).toLocaleString();
+}
+
+function lmTimeAgo(ts) {
+ if (!ts) return 'Never';
+ var diff = Date.now() - new Date(ts).getTime();
+ var mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'Just now';
+ if (mins < 60) return mins + 'm ago';
+ var hrs = Math.floor(mins / 60);
+ if (hrs < 24) return hrs + 'h ago';
+ var days = Math.floor(hrs / 24);
+ if (days < 30) return days + 'd ago';
+ return Math.floor(days / 30) + 'mo ago';
+}
+
+function loadLineManager() {
+ if (lmLoaded) return;
+ apiCall('/api/lm/lines').then(function(resp) {
+ lmLines = resp.data || resp;
+ if (Array.isArray(resp)) lmLines = resp;
+ lmLoaded = true;
+ renderLmTable();
+ lmCheckPushStatus();
+ lmLoadCronStatus();
+ }).catch(function(err) {
+ var tbody = document.getElementById('lmTableBody');
+ tbody.textContent = '';
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 7;
+ td.style.cssText = 'color:var(--red);text-align:center;padding:20px;';
+ td.textContent = 'Error loading lines: ' + err.message;
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ });
+}
+
+function renderLmTable() {
+ var tbody = document.getElementById('lmTableBody');
+ tbody.textContent = '';
+ if (!lmLines || !lmLines.length) {
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 7;
+ td.style.cssText = 'text-align:center;color:var(--text-muted);padding:20px;';
+ td.textContent = 'No brand lines found';
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ return;
+ }
+ lmLines.forEach(function(line) {
+ var tr = document.createElement('tr');
+ tr.style.borderBottom = '1px solid var(--border)';
+ tr.style.transition = 'background .15s';
+ tr.addEventListener('mouseenter', function() { tr.style.background = 'rgba(124,58,237,0.06)'; });
+ tr.addEventListener('mouseleave', function() { tr.style.background = ''; });
+ // Checkbox
+ var tdCb = document.createElement('td');
+ tdCb.style.padding = '10px 12px';
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = lmSelected.has(line.brand);
+ cb.style.accentColor = 'var(--accent)';
+ cb.addEventListener('change', function() { lmToggle(line.brand, this.checked); });
+ tdCb.appendChild(cb);
+ tr.appendChild(tdCb);
+ // Brand
+ var tdBrand = document.createElement('td');
+ tdBrand.style.cssText = 'padding:10px 12px;font-weight:600;color:var(--text-primary)';
+ tdBrand.textContent = line.brand;
+ tr.appendChild(tdBrand);
+ // Total
+ var tdTotal = document.createElement('td');
+ tdTotal.style.cssText = 'padding:10px 12px;color:var(--text-secondary)';
+ tdTotal.textContent = lmCommaNum(line.total);
+ tr.appendChild(tdTotal);
+ // Ready
+ var readyPct = line.total > 0 ? Math.round(line.ready / line.total * 100) : 0;
+ var tdReady = document.createElement('td');
+ tdReady.style.cssText = 'padding:10px 12px;color:var(--green);font-weight:600';
+ tdReady.textContent = lmCommaNum(line.ready) + ' (' + readyPct + '%)';
+ tr.appendChild(tdReady);
+ // Pushed
+ var tdPushed = document.createElement('td');
+ tdPushed.style.cssText = 'padding:10px 12px;color:var(--accent);font-weight:600';
+ tdPushed.textContent = lmCommaNum(line.pushed);
+ tr.appendChild(tdPushed);
+ // Last Crawled
+ var tdCrawled = document.createElement('td');
+ tdCrawled.style.cssText = 'padding:10px 12px;color:var(--text-muted);font-size:12px';
+ tdCrawled.textContent = lmTimeAgo(line.last_crawled);
+ tr.appendChild(tdCrawled);
+ // Actions
+ var tdAct = document.createElement('td');
+ tdAct.style.padding = '10px 12px';
+ var crawlBtn = document.createElement('button');
+ crawlBtn.style.cssText = 'padding:4px 12px;font-size:11px;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;';
+ crawlBtn.textContent = 'Crawl';
+ crawlBtn.addEventListener('click', function() { lmTriggerCrawl(line.brand, crawlBtn); });
+ tdAct.appendChild(crawlBtn);
+ tr.appendChild(tdAct);
+ tbody.appendChild(tr);
+ });
+ lmUpdateSelectedInfo();
+}
+
+function lmToggle(brand, checked) {
+ if (checked) lmSelected.add(brand);
+ else lmSelected.delete(brand);
+ lmUpdateSelectedInfo();
+}
+
+function lmSelectAll() {
+ lmLines.forEach(function(l) { lmSelected.add(l.brand); });
+ renderLmTable();
+}
+
+function lmDeselectAll() {
+ lmSelected.clear();
+ renderLmTable();
+}
+
+function lmUpdateSelectedInfo() {
+ var count = lmSelected.size;
+ var totalReady = 0;
+ lmLines.forEach(function(l) { if (lmSelected.has(l.brand)) totalReady += l.ready; });
+ document.getElementById('lmSelectedInfo').textContent =
+ count + ' brand' + (count !== 1 ? 's' : '') + ' selected (' + lmCommaNum(totalReady) + ' ready)';
+ document.getElementById('lmPushInfo').textContent =
+ count > 0 ? lmCommaNum(totalReady) + ' products ready to push' : '';
+}
+
+function lmTriggerCrawl(brand, btn) {
+ btn.disabled = true;
+ btn.textContent = 'Running...';
+ apiCall('/api/lm/crawl/' + encodeURIComponent(brand), { method: 'POST' }).then(function() {
+ btn.textContent = 'Done';
+ btn.style.color = 'var(--green)';
+ btn.style.borderColor = 'var(--green)';
+ setTimeout(function() { lmLoaded = false; loadLineManager(); }, 1500);
+ }).catch(function() {
+ btn.textContent = 'Error';
+ btn.style.color = 'var(--red)';
+ setTimeout(function() { btn.textContent = 'Crawl'; btn.disabled = false; btn.style.color = ''; btn.style.borderColor = ''; }, 3000);
+ });
+}
+
+function lmGetPushConfig() {
+ var modeEls = document.querySelectorAll('input[name="lmPushMode"]');
+ var mode = 'all';
+ for (var i = 0; i < modeEls.length; i++) { if (modeEls[i].checked) mode = modeEls[i].value; }
+ var batchSize = parseInt(document.getElementById('lmBatchSize').value, 10);
+ var delayHours = parseInt(document.getElementById('lmDelayHours').value, 10);
+ return {
+ brands: Array.from(lmSelected),
+ mode: mode === 'all' ? 'all' : 'batched',
+ batchSize: mode === 'batched' ? batchSize : 9999,
+ delayHours: mode === 'batched' ? delayHours : 0
+ };
+}
+
+function lmQueuePush() {
+ if (lmSelected.size === 0) {
+ showToast('Select at least one brand line to queue.', 'error');
+ return;
+ }
+ lmQueuedConfig = lmGetPushConfig();
+ var qp = document.getElementById('lmQueuePanel');
+ qp.style.display = 'block';
+ var totalReady = 0;
+ var brandList = [];
+ lmLines.forEach(function(l) {
+ if (lmSelected.has(l.brand)) {
+ totalReady += l.ready;
+ brandList.push(l.brand + ' (' + lmCommaNum(l.ready) + ')');
+ }
+ });
+ var detailsEl = document.getElementById('lmQueueDetails');
+ detailsEl.textContent = '';
+ var modeDiv = document.createElement('div');
+ modeDiv.style.marginBottom = '6px';
+ modeDiv.textContent = lmCommaNum(totalReady) + ' products from ' + lmSelected.size + ' brands — ' +
+ (lmQueuedConfig.mode === 'all' ? 'Push all at once' : 'Batches of ' + lmQueuedConfig.batchSize + ' every ' + lmQueuedConfig.delayHours + 'h');
+ detailsEl.appendChild(modeDiv);
+ var brandsDiv = document.createElement('div');
+ brandsDiv.style.cssText = 'line-height:1.5;font-size:11px;color:var(--text-muted);';
+ brandsDiv.textContent = 'Brands: ' + brandList.join(', ');
+ detailsEl.appendChild(brandsDiv);
+ document.getElementById('lmBtnQueue').style.display = 'none';
+ document.getElementById('lmBtnStart').style.display = '';
+ document.getElementById('lmBtnStart').disabled = false;
+ showToast('Push queued — review and click Start Push to begin.', 'info');
+}
+
+function lmStartPush() {
+ if (!lmQueuedConfig || !lmQueuedConfig.brands.length) {
+ showToast('No products queued. Queue first.', 'error');
+ return;
+ }
+ document.getElementById('lmBtnStart').disabled = true;
+ apiCall('/api/lm/push/start', {
+ method: 'POST',
+ body: JSON.stringify(lmQueuedConfig)
+ }).then(function(data) {
+ if (data.success || data.jobId) {
+ document.getElementById('lmBtnStart').style.display = 'none';
+ document.getElementById('lmBtnStop').style.display = '';
+ document.getElementById('lmProgressPanel').style.display = 'block';
+ document.getElementById('lmQueuePanel').style.display = 'none';
+ lmStartPolling();
+ showToast('Push started! ' + lmCommaNum(data.totalProducts || 0) + ' products queued.', 'success');
+ } else {
+ showToast('Failed to start push: ' + (data.error || 'Unknown error'), 'error');
+ document.getElementById('lmBtnStart').disabled = false;
+ }
+ }).catch(function(err) {
+ showToast('Error: ' + err.message, 'error');
+ document.getElementById('lmBtnStart').disabled = false;
+ });
+}
+
+function lmStopPush() {
+ apiCall('/api/lm/push/stop', { method: 'POST' }).then(function() {
+ showToast('Push stop requested', 'info');
+ }).catch(function() {});
+}
+
+function lmStartPolling() {
+ if (lmPollInterval) clearInterval(lmPollInterval);
+ lmPollInterval = setInterval(lmPollStatus, 1500);
+}
+
+function lmStopPolling() {
+ if (lmPollInterval) { clearInterval(lmPollInterval); lmPollInterval = null; }
+}
+
+function lmCheckPushStatus() {
+ apiCall('/api/lm/push/status').then(function(d) {
+ if (d && d.active) {
+ document.getElementById('lmBtnQueue').style.display = 'none';
+ document.getElementById('lmBtnStart').style.display = 'none';
+ document.getElementById('lmBtnStop').style.display = '';
+ document.getElementById('lmProgressPanel').style.display = 'block';
+ lmUpdateProgressUI(d);
+ lmStartPolling();
+ }
+ }).catch(function() {});
+}
+
+function lmPollStatus() {
+ apiCall('/api/lm/push/status').then(function(d) {
+ lmUpdateProgressUI(d);
+ if (!d.active) {
+ lmStopPolling();
+ document.getElementById('lmBtnStop').style.display = 'none';
+ document.getElementById('lmBtnQueue').style.display = '';
+ document.getElementById('lmBtnStart').style.display = 'none';
+ document.getElementById('lmProgressTitle').textContent =
+ d.cancelled ? 'Push Cancelled' : 'Push Complete';
+ if (!d.cancelled && d.pushed > 0) {
+ lmShowCronScheduler();
+ }
+ lmLoaded = false;
+ loadLineManager();
+ }
+ }).catch(function() {
+ lmStopPolling();
+ });
+}
+
+function lmUpdateProgressUI(d) {
+ var total = d.totalProducts || 1;
+ var pct = Math.round((d.pushed + d.errors) / total * 100);
+ document.getElementById('lmProgressBar').style.width = pct + '%';
+ document.getElementById('lmPsPushed').textContent = lmCommaNum(d.pushed);
+ document.getElementById('lmPsErrors').textContent = d.errors || 0;
+ document.getElementById('lmPsSkipped').textContent = d.skipped || 0;
+ if (d.active) {
+ document.getElementById('lmProgressTitle').textContent =
+ 'Push in Progress... (' + pct + '%) — Batch ' + (d.currentBatch || 0) + '/' + (d.totalBatches || 0);
+ }
+ var logEl = document.getElementById('lmProgressLog');
+ if (d.log && d.log.length) {
+ logEl.textContent = '';
+ d.log.forEach(function(line) {
+ var div = document.createElement('div');
+ div.style.lineHeight = '1.6';
+ if (line.indexOf('Error:') !== -1 || line.indexOf('Fatal') !== -1) div.style.color = 'var(--red)';
+ else if (line.indexOf('Pushed:') !== -1) div.style.color = 'var(--green)';
+ div.textContent = line;
+ logEl.appendChild(div);
+ });
+ logEl.scrollTop = logEl.scrollHeight;
+ }
+}
+
+// ==================== Line Manager Cron ====================
+function lmShowCronScheduler() {
+ var panel = document.getElementById('lmCronPanel');
+ panel.style.display = 'block';
+ lmRenderCronForm();
+}
+
+function lmLoadCronStatus() {
+ apiCall('/api/lm/push/cron').then(function(data) {
+ if (data && data.enabled) {
+ document.getElementById('lmCronPanel').style.display = 'block';
+ lmRenderCronActive(data);
+ } else if (data && !data.enabled) {
+ document.getElementById('lmCronPanel').style.display = 'block';
+ lmRenderCronForm();
+ }
+ }).catch(function() {});
+}
+
+function lmOrdinal(n) {
+ var s = ['th','st','nd','rd'];
+ var v = n % 100;
+ return n + (s[(v-20)%10] || s[v] || s[0]);
+}
+
+function lmRenderCronForm() {
+ var badge = document.getElementById('lmCronBadge');
+ badge.style.cssText = 'background:rgba(255,255,255,0.05);color:var(--text-muted);';
+ badge.textContent = 'Not Scheduled';
+
+ var content = document.getElementById('lmCronContent');
+ content.textContent = '';
+
+ var desc = document.createElement('div');
+ desc.style.cssText = 'font-size:12px;color:var(--text-muted);margin-bottom:10px;';
+ desc.textContent = 'Schedule this push to run automatically every month with the same brands and settings.';
+ content.appendChild(desc);
+
+ var form = document.createElement('div');
+ form.style.cssText = 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;';
+
+ var dayLabel = document.createElement('span');
+ dayLabel.style.cssText = 'font-size:12px;color:var(--text-secondary);';
+ dayLabel.textContent = 'Day of month:';
+ form.appendChild(dayLabel);
+
+ var daySelect = document.createElement('select');
+ daySelect.id = 'lmCronDay';
+ daySelect.style.cssText = 'padding:4px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-size:12px;';
+ for (var i = 1; i <= 28; i++) {
+ var opt = document.createElement('option');
+ opt.value = i;
+ opt.textContent = i;
+ if (i === 1) opt.selected = true;
+ daySelect.appendChild(opt);
+ }
+ form.appendChild(daySelect);
+
+ var atLabel = document.createElement('span');
+ atLabel.style.cssText = 'font-size:12px;color:var(--text-secondary);';
+ atLabel.textContent = 'at';
+ form.appendChild(atLabel);
+
+ var hourSelect = document.createElement('select');
+ hourSelect.id = 'lmCronHour';
+ hourSelect.style.cssText = 'padding:4px 8px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);font-size:12px;';
+ for (var h = 0; h < 24; h++) {
+ var opt = document.createElement('option');
+ opt.value = h;
+ var ampm = h < 12 ? 'AM' : 'PM';
+ var disp = h === 0 ? 12 : (h > 12 ? h - 12 : h);
+ opt.textContent = disp + ':00 ' + ampm + ' PT';
+ if (h === 2) opt.selected = true;
+ hourSelect.appendChild(opt);
+ }
+ form.appendChild(hourSelect);
+
+ var schedBtn = document.createElement('button');
+ schedBtn.style.cssText = 'padding:6px 16px;background:var(--pink);color:#fff;border:none;border-radius:4px;font-size:12px;font-weight:600;cursor:pointer;';
+ schedBtn.textContent = 'Schedule Monthly';
+ schedBtn.addEventListener('click', lmScheduleCron);
+ form.appendChild(schedBtn);
+
+ content.appendChild(form);
+}
+
+function lmRenderCronActive(config) {
+ var badge = document.getElementById('lmCronBadge');
+ badge.style.cssText = 'background:rgba(46,125,50,0.2);color:var(--green);';
+ badge.textContent = 'Active';
+
+ var content = document.getElementById('lmCronContent');
+ content.textContent = '';
+
+ var ampm = config.hour < 12 ? 'AM' : 'PM';
+ var disp = config.hour === 0 ? 12 : (config.hour > 12 ? config.hour - 12 : config.hour);
+ var timeStr = disp + ':00 ' + ampm + ' PT';
+
+ var info = document.createElement('div');
+ info.style.cssText = 'font-size:13px;color:var(--text-primary);line-height:1.8;';
+
+ var line1 = document.createElement('div');
+ line1.textContent = 'Runs on the ' + lmOrdinal(config.dayOfMonth) + ' of every month at ' + timeStr;
+ info.appendChild(line1);
+
+ var line2 = document.createElement('div');
+ line2.style.cssText = 'font-size:12px;color:var(--text-muted);';
+ line2.textContent = (config.brands ? config.brands.length : '?') + ' brands, ' +
+ (config.mode === 'all' ? 'all at once' : 'batches of ' + config.batchSize + ' every ' + config.delayHours + 'h');
+ info.appendChild(line2);
+
+ if (config.createdAt) {
+ var line3 = document.createElement('div');
+ line3.style.cssText = 'font-size:11px;color:var(--text-muted);margin-top:4px;';
+ line3.textContent = 'Created: ' + new Date(config.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ info.appendChild(line3);
+ }
+ content.appendChild(info);
+
+ var btnWrap = document.createElement('div');
+ btnWrap.style.marginTop = '10px';
+ var removeBtn = document.createElement('button');
+ removeBtn.style.cssText = 'padding:6px 16px;background:rgba(239,83,80,0.15);color:var(--red);border:1px solid rgba(239,83,80,0.3);border-radius:4px;font-size:12px;cursor:pointer;';
+ removeBtn.textContent = 'Remove Schedule';
+ removeBtn.addEventListener('click', lmRemoveCron);
+ btnWrap.appendChild(removeBtn);
+ content.appendChild(btnWrap);
+}
+
+function lmScheduleCron() {
+ var config = lmQueuedConfig || lmGetPushConfig();
+ if (!config.brands || config.brands.length === 0) {
+ if (lmSelected.size === 0) {
+ showToast('No brands selected for cron schedule.', 'error');
+ return;
+ }
+ config = lmGetPushConfig();
+ }
+ var dayOfMonth = parseInt(document.getElementById('lmCronDay').value, 10);
+ var hour = parseInt(document.getElementById('lmCronHour').value, 10);
+ apiCall('/api/lm/push/cron', {
+ method: 'POST',
+ body: JSON.stringify({
+ brands: config.brands,
+ mode: config.mode,
+ batchSize: config.batchSize,
+ delayHours: config.delayHours,
+ dayOfMonth: dayOfMonth,
+ hour: hour
+ })
+ }).then(function(data) {
+ if (data.success) {
+ lmRenderCronActive(data.config);
+ showToast('Monthly cron scheduled!', 'success');
+ } else {
+ showToast('Failed to schedule: ' + (data.error || 'Unknown error'), 'error');
+ }
+ }).catch(function(err) {
+ showToast('Error: ' + err.message, 'error');
+ });
+}
+
+function lmRemoveCron() {
+ if (!confirm('Remove the monthly push schedule?')) return;
+ apiCall('/api/lm/push/cron', { method: 'DELETE' }).then(function() {
+ lmRenderCronForm();
+ showToast('Monthly schedule removed.', 'info');
+ }).catch(function(err) {
+ showToast('Error: ' + err.message, 'error');
+ });
+}
+
+// ===== TAG MANAGER =====
+var tmLoaded = false;
+var tmVendors = [];
+var tmSelectedVendors = {};
+var tmSelectedVendor = null;
+var tmPollTimer = null;
+
+function loadTagManager() {
+ if (tmLoaded) return;
+ updateTmStats();
+ tmLoaded = true;
+}
+
+function updateTmStats() {
+ apiCall('/api/tags/stats').then(function(resp) {
+ tmVendors = resp.vendors || [];
+ var totals = { total: 0, pending: 0, retry: 0, analyzed: 0, applied: 0 };
+ for (var i = 0; i < tmVendors.length; i++) {
+ var v = tmVendors[i];
+ totals.total += parseInt(v.total) || 0;
+ totals.pending += parseInt(v.pending) || 0;
+ totals.retry += parseInt(v.retry) || 0;
+ totals.analyzed += parseInt(v.analyzed) || 0;
+ totals.applied += parseInt(v.applied) || 0;
+ }
+ document.getElementById('tmFlagged').textContent = totals.total.toLocaleString();
+ document.getElementById('tmPending').textContent = totals.pending.toLocaleString();
+ document.getElementById('tmRetry').textContent = totals.retry.toLocaleString();
+ document.getElementById('tmAnalyzed').textContent = totals.analyzed.toLocaleString();
+ document.getElementById('tmApplied').textContent = totals.applied.toLocaleString();
+ renderTmVendors();
+ }).catch(function(err) {
+ showToast('Error loading tag stats: ' + err.message, 'error');
+ });
+}
+
+function renderTmVendors() {
+ var tbody = document.getElementById('tmVendorBody');
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+ if (tmVendors.length === 0) {
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 4;
+ td.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);font-size:13px;';
+ td.textContent = 'No scanned vendors yet. Click "Scan Store" to begin.';
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ return;
+ }
+ for (var i = 0; i < tmVendors.length; i++) {
+ var v = tmVendors[i];
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid var(--border);cursor:pointer;';
+ tr.setAttribute('data-vendor', v.vendor);
+ // Checkbox cell
+ var tdCb = document.createElement('td');
+ tdCb.style.padding = '8px';
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = !!tmSelectedVendors[v.vendor];
+ cb.style.accentColor = 'var(--accent)';
+ cb.setAttribute('data-vendor', v.vendor);
+ cb.addEventListener('change', (function(vendor) {
+ return function(e) {
+ tmSelectedVendors[vendor] = e.target.checked;
+ e.stopPropagation();
+ };
+ })(v.vendor));
+ tdCb.appendChild(cb);
+ tr.appendChild(tdCb);
+ // Vendor name cell
+ var tdName = document.createElement('td');
+ tdName.style.cssText = 'padding:8px;font-size:13px;color:var(--text-primary);';
+ tdName.textContent = v.vendor;
+ tr.appendChild(tdName);
+ // Flagged count
+ var tdFlagged = document.createElement('td');
+ tdFlagged.style.cssText = 'padding:8px;text-align:right;font-size:13px;color:var(--orange);';
+ var pendingRetry = (parseInt(v.pending) || 0) + (parseInt(v.retry) || 0);
+ tdFlagged.textContent = pendingRetry.toLocaleString();
+ tr.appendChild(tdFlagged);
+ // Done count
+ var tdDone = document.createElement('td');
+ tdDone.style.cssText = 'padding:8px;text-align:right;font-size:13px;color:var(--green);';
+ tdDone.textContent = (parseInt(v.applied) || 0).toLocaleString();
+ tr.appendChild(tdDone);
+ // Click row to preview
+ tr.addEventListener('click', (function(vendor) {
+ return function() { tmSelectVendor(vendor); };
+ })(v.vendor));
+ tbody.appendChild(tr);
+ }
+}
+
+function tmSelectVendor(vendor) {
+ tmSelectedVendor = vendor;
+ document.getElementById('tmPreviewHeader').textContent = 'Loading ' + vendor + '...';
+ var body = document.getElementById('tmPreviewBody');
+ while (body.firstChild) body.removeChild(body.firstChild);
+ // Highlight selected row
+ var rows = document.querySelectorAll('#tmVendorBody tr');
+ for (var i = 0; i < rows.length; i++) {
+ rows[i].style.background = rows[i].getAttribute('data-vendor') === vendor ? 'var(--bg-hover)' : '';
+ }
+ apiCall('/api/tags/preview?vendor=' + encodeURIComponent(vendor) + '&limit=50').then(function(resp) {
+ document.getElementById('tmPreviewHeader').textContent = vendor + ' — ' + (resp.products || []).length + ' products';
+ renderTmPreview(resp.products || []);
+ }).catch(function(err) {
+ document.getElementById('tmPreviewHeader').textContent = 'Error loading preview';
+ showToast('Preview error: ' + err.message, 'error');
+ });
+}
+
+function renderTmPreview(products) {
+ var body = document.getElementById('tmPreviewBody');
+ while (body.firstChild) body.removeChild(body.firstChild);
+ if (products.length === 0) {
+ var p = document.createElement('p');
+ p.style.cssText = 'color:var(--text-muted);font-size:13px;';
+ p.textContent = 'No products found for this vendor.';
+ body.appendChild(p);
+ return;
+ }
+ for (var i = 0; i < products.length; i++) {
+ var pr = products[i];
+ var card = document.createElement('div');
+ card.style.cssText = 'background:var(--bg-card);border-radius:8px;padding:12px;margin-bottom:8px;border:1px solid var(--border);';
+ // Title + status
+ var header = document.createElement('div');
+ header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;';
+ var titleEl = document.createElement('span');
+ titleEl.style.cssText = 'font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:70%;';
+ titleEl.textContent = pr.title || 'Untitled';
+ titleEl.title = pr.title || '';
+ header.appendChild(titleEl);
+ var badge = document.createElement('span');
+ badge.style.cssText = 'font-size:11px;padding:2px 8px;border-radius:10px;font-weight:600;';
+ var statusColors = { pending: 'var(--orange)', analyzed: 'var(--blue)', applied: 'var(--green)', retry: 'var(--red)' };
+ badge.style.background = (statusColors[pr.status] || 'var(--text-muted)');
+ badge.style.color = '#fff';
+ badge.textContent = (pr.status || 'unknown').toUpperCase();
+ header.appendChild(badge);
+ card.appendChild(header);
+ // Current tags
+ var curLabel = document.createElement('div');
+ curLabel.style.cssText = 'font-size:11px;color:var(--text-muted);margin-bottom:2px;';
+ curLabel.textContent = 'CURRENT TAGS';
+ card.appendChild(curLabel);
+ var curTags = document.createElement('div');
+ curTags.style.cssText = 'font-size:12px;color:var(--text-secondary);margin-bottom:8px;word-wrap:break-word;max-height:60px;overflow:hidden;';
+ curTags.textContent = pr.currentTags || '(none)';
+ card.appendChild(curTags);
+ // Suggested tags (if analyzed)
+ if (pr.suggestedTags) {
+ var sugLabel = document.createElement('div');
+ sugLabel.style.cssText = 'font-size:11px;color:var(--accent);margin-bottom:2px;';
+ sugLabel.textContent = 'SUGGESTED TAGS';
+ card.appendChild(sugLabel);
+ var sugTags = document.createElement('div');
+ sugTags.style.cssText = 'font-size:12px;color:var(--text-primary);word-wrap:break-word;max-height:60px;overflow:hidden;';
+ sugTags.textContent = pr.suggestedTags;
+ card.appendChild(sugTags);
+ }
+ // AI analysis info
+ if (pr.aiColors || pr.aiStyles) {
+ var aiRow = document.createElement('div');
+ aiRow.style.cssText = 'display:flex;gap:12px;margin-top:6px;font-size:11px;color:var(--text-muted);';
+ if (pr.aiColors) {
+ var colSpan = document.createElement('span');
+ var colors = typeof pr.aiColors === 'string' ? JSON.parse(pr.aiColors) : pr.aiColors;
+ colSpan.textContent = 'Colors: ' + (Array.isArray(colors) ? colors.join(', ') : JSON.stringify(colors));
+ aiRow.appendChild(colSpan);
+ }
+ if (pr.aiStyles) {
+ var stySpan = document.createElement('span');
+ var styles = typeof pr.aiStyles === 'string' ? JSON.parse(pr.aiStyles) : pr.aiStyles;
+ stySpan.textContent = 'Styles: ' + (Array.isArray(styles) ? styles.join(', ') : JSON.stringify(styles));
+ aiRow.appendChild(stySpan);
+ }
+ card.appendChild(aiRow);
+ }
+ body.appendChild(card);
+ }
+}
+
+function tmScan() {
+ showToast('Starting store scan...', 'info');
+ apiCall('/api/tags/scan').then(function(resp) {
+ showToast(resp.message || 'Scan started', 'info');
+ tmStartPolling('scan');
+ }).catch(function(err) {
+ showToast('Scan error: ' + err.message, 'error');
+ });
+}
+
+function tmStartPolling(type) {
+ tmStopPolling();
+ var progressEl = document.getElementById('tmProgress');
+ progressEl.style.display = 'block';
+ tmPollTimer = setInterval(function() {
+ var url = type === 'scan' ? '/api/tags/scan-status' :
+ type === 'batch' ? '/api/tags/batch-status' :
+ '/api/tags/apply-status';
+ apiCall(url).then(function(state) {
+ if (type === 'scan') {
+ var pct = state.total > 0 ? Math.round((state.scanned / Math.max(state.total, state.scanned)) * 100) : 0;
+ document.getElementById('tmProgressLabel').textContent = 'Scanning... ' + (state.scanned || 0).toLocaleString() + ' products (' + (state.flagged || 0) + ' flagged)';
+ document.getElementById('tmProgressPct').textContent = pct + '%';
+ document.getElementById('tmProgressBar').style.width = pct + '%';
+ if (!state.running) {
+ tmStopPolling();
+ showToast('Scan complete! ' + (state.flagged || 0) + ' products flagged.', 'info');
+ tmLoaded = false;
+ loadTagManager();
+ }
+ } else if (type === 'batch') {
+ var pct = state.total > 0 ? Math.round((state.analyzed / state.total) * 100) : 0;
+ document.getElementById('tmProgressLabel').textContent = 'Analyzing... ' + (state.analyzed || 0) + '/' + (state.total || 0) + (state.retrying > 0 ? ' (' + state.retrying + ' retrying)' : '');
+ document.getElementById('tmProgressPct').textContent = pct + '%';
+ document.getElementById('tmProgressBar').style.width = pct + '%';
+ if (state.status !== 'processing') {
+ tmStopPolling();
+ showToast('Batch analysis complete! ' + (state.analyzed || 0) + ' analyzed.', 'info');
+ tmLoaded = false;
+ loadTagManager();
+ }
+ } else {
+ var pct = state.total > 0 ? Math.round((state.applied / state.total) * 100) : 0;
+ document.getElementById('tmProgressLabel').textContent = 'Applying... ' + (state.applied || 0) + '/' + (state.total || 0) + (state.errors > 0 ? ' (' + state.errors + ' errors)' : '');
+ document.getElementById('tmProgressPct').textContent = pct + '%';
+ document.getElementById('tmProgressBar').style.width = pct + '%';
+ if (!state.running) {
+ tmStopPolling();
+ showToast('Apply complete! ' + (state.applied || 0) + ' updated.', 'info');
+ tmLoaded = false;
+ loadTagManager();
+ }
+ }
+ });
+ }, 2000);
+}
+
+function tmStopPolling() {
+ if (tmPollTimer) { clearInterval(tmPollTimer); tmPollTimer = null; }
+ document.getElementById('tmProgress').style.display = 'none';
+}
+
+function tmBatchSubmit() {
+ var selected = [];
+ for (var v in tmSelectedVendors) {
+ if (tmSelectedVendors[v]) selected.push(v);
+ }
+ if (selected.length === 0) {
+ showToast('Select at least one vendor first.', 'error');
+ return;
+ }
+ showToast('Submitting batch for ' + selected.length + ' vendor(s)...', 'info');
+ apiCall('/api/tags/batch-submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendors: selected, limit: 5000 })
+ }).then(function(resp) {
+ showToast(resp.message || 'Batch submitted', 'info');
+ if (resp.productCount > 0) tmStartPolling('batch');
+ }).catch(function(err) {
+ showToast('Batch error: ' + err.message, 'error');
+ });
+}
+
+function tmApplyAll() {
+ var selected = [];
+ for (var v in tmSelectedVendors) {
+ if (tmSelectedVendors[v]) selected.push(v);
+ }
+ if (selected.length === 0 && tmSelectedVendor) {
+ selected = [tmSelectedVendor];
+ }
+ if (selected.length === 0) {
+ showToast('Select at least one vendor first.', 'error');
+ return;
+ }
+ // Apply one vendor at a time
+ var vendor = selected[0];
+ showToast('Applying tags for ' + vendor + '...', 'info');
+ apiCall('/api/tags/apply', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendor: vendor })
+ }).then(function(resp) {
+ showToast(resp.message || 'Applying...', 'info');
+ if (resp.total > 0) tmStartPolling('apply');
+ }).catch(function(err) {
+ showToast('Apply error: ' + err.message, 'error');
+ });
+}
+
+function tmToggleAll(checked) {
+ tmSelectedVendors = {};
+ for (var i = 0; i < tmVendors.length; i++) {
+ tmSelectedVendors[tmVendors[i].vendor] = checked;
+ }
+ var cbs = document.querySelectorAll('#tmVendorBody input[type="checkbox"]');
+ for (var j = 0; j < cbs.length; j++) cbs[j].checked = checked;
+}
+
+// =============================================================
+// STANDARDIZATION ENGINE
+// =============================================================
+var stdPolling = null;
+var stdVendorData = [];
+var stdSortKey = 'vendor';
+var stdSortDir = 'asc'; // 'asc' | 'desc'
+
+function stdGetSortVal(v, key) {
+ var total = parseInt(v.std_total) || 0;
+ var analyzed = parseInt(v.std_analyzed) || 0;
+ var applied = parseInt(v.std_applied) || 0;
+ var shopify = parseInt(v.shopify_count) || 0;
+ switch (key) {
+ case 'vendor': return (v.vendor_name || '').toLowerCase();
+ case 'shopify': return shopify;
+ case 'scanned': return total;
+ case 'analyzed': return analyzed;
+ case 'applied': return applied;
+ case 'pct': return total > 0 ? applied / total : -1;
+ case 'lastrun': return v.last_analyzed ? new Date(v.last_analyzed).getTime() : 0;
+ case 'status': {
+ if (applied > 0 && applied >= total && total > 0) return 3;
+ if (analyzed > 0) return 2;
+ if (total > 0) return 1;
+ return 0;
+ }
+ case 'discount': {
+ // Confirmed > Unconfirmed-with-value > Unset, then by pct desc
+ const conf = v.discount_confirmed ? 1 : 0;
+ const pct = v.vendor_discount_pct != null ? Number(v.vendor_discount_pct) : -1;
+ return conf * 1000 + pct;
+ }
+ default: return 0;
+ }
+}
+
+function stdSortBy(key) {
+ if (stdSortKey === key) {
+ stdSortDir = (stdSortDir === 'asc') ? 'desc' : 'asc';
+ } else {
+ stdSortKey = key;
+ // Numeric columns default to desc (biggest first); text cols asc
+ stdSortDir = (key === 'vendor') ? 'asc' : 'desc';
+ }
+ stdRenderVendorTable();
+}
+
+function stdApplySortIndicators() {
+ var keys = ['vendor','shopify','scanned','analyzed','applied','pct','lastrun','status','discount'];
+ for (var i = 0; i < keys.length; i++) {
+ var th = document.getElementById('stdTh-' + keys[i]);
+ if (!th) continue;
+ var ind = th.querySelector('.std-sort-ind');
+ if (!ind) continue;
+ if (keys[i] === stdSortKey) {
+ ind.textContent = (stdSortDir === 'asc') ? ' \u25B2' : ' \u25BC';
+ th.style.color = 'var(--accent)';
+ } else {
+ ind.textContent = '';
+ th.style.color = 'var(--text-muted)';
+ }
+ }
+}
+
+function loadStandardize() {
+ // Load vendor list
+ apiCall('/api/standardize/vendors').then(function(data) {
+ stdVendorData = data.vendors || [];
+ var sel = document.getElementById('stdVendorSelect');
+ sel.innerHTML = '<option value="">Select vendor...</option>';
+ for (var i = 0; i < stdVendorData.length; i++) {
+ var v = stdVendorData[i];
+ var opt = document.createElement('option');
+ opt.value = v.vendor_name;
+ opt.textContent = v.vendor_name + ' (' + (v.shopify_count || 0) + ' products)';
+ sel.appendChild(opt);
+ }
+ stdRenderVendorTable();
+ }).catch(function(err) {
+ console.error('Failed to load standardize vendors:', err);
+ });
+ // Check engine status
+ stdPollStatus();
+}
+
+function stdRenderVendorTable() {
+ var body = document.getElementById('stdVendorBody');
+ body.innerHTML = '';
+ var totalShopify = 0, totalScanned = 0, totalAnalyzed = 0, totalApplied = 0;
+
+ // Sort a copy so we never mutate the source array (status poller re-uses it)
+ var stdSorted = stdVendorData.slice().sort(function(a, b) {
+ var av = stdGetSortVal(a, stdSortKey);
+ var bv = stdGetSortVal(b, stdSortKey);
+ var cmp = (av < bv) ? -1 : (av > bv) ? 1 : 0;
+ return stdSortDir === 'asc' ? cmp : -cmp;
+ });
+ stdApplySortIndicators();
+
+ for (var i = 0; i < stdSorted.length; i++) {
+ var v = stdSorted[i];
+ var total = parseInt(v.std_total) || 0;
+ var analyzed = parseInt(v.std_analyzed) || 0;
+ var applied = parseInt(v.std_applied) || 0;
+ var shopify = parseInt(v.shopify_count) || 0;
+ var errors = parseInt(v.std_errors) || 0;
+ var pct = total > 0 ? Math.round((applied / total) * 100) : 0;
+ var pctAnalyzed = total > 0 ? Math.round((analyzed / total) * 100) : 0;
+
+ totalShopify += shopify;
+ totalScanned += total;
+ totalAnalyzed += analyzed;
+ totalApplied += applied;
+
+ var statusIcon = '';
+ if (applied > 0 && applied >= total && total > 0) statusIcon = '<span style="color:var(--green);font-size:16px;">✔</span>';
+ else if (analyzed > 0) statusIcon = '<span style="color:var(--blue);font-size:13px;">●</span>';
+ else if (total > 0) statusIcon = '<span style="color:var(--orange);font-size:13px;">●</span>';
+ else statusIcon = '<span style="color:var(--text-muted);font-size:13px;">◯</span>';
+
+ var lastRun = v.last_analyzed ? new Date(v.last_analyzed).toLocaleDateString('en-US', { month:'short', day:'numeric', timeZone:'America/Los_Angeles' }) : '--';
+
+ var pctBar = total > 0
+ ? '<div style="background:var(--bg-input);border-radius:3px;height:4px;width:60px;display:inline-block;vertical-align:middle;margin-right:4px;"><div style="background:' + (pct >= 100 ? 'var(--green)' : pct > 0 ? 'var(--blue)' : 'var(--text-muted)') + ';height:100%;width:' + pct + '%;border-radius:3px;"></div></div>' + pct + '%'
+ : '<span style="color:var(--text-muted);">--</span>';
+
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid var(--border);cursor:pointer;';
+ tr.setAttribute('onclick', 'stdPreviewVendor("' + esc(v.vendor_name).replace(/"/g, '"') + '")');
+ tr.innerHTML = '<td style="padding:8px 12px;font-size:13px;font-weight:500;">' + esc(v.vendor_name) + (v.is_private_label ? ' <span style="color:var(--pink);font-size:9px;">PL</span>' : '') + '</td>'
+ + '<td style="text-align:right;padding:8px;font-size:12px;color:var(--text-secondary);">' + shopify.toLocaleString() + '</td>'
+ + '<td style="text-align:right;padding:8px;font-size:12px;color:var(--text-secondary);">' + (total > 0 ? total.toLocaleString() : '--') + '</td>'
+ + '<td style="text-align:center;padding:8px;font-size:12px;color:var(--blue);">' + (analyzed > 0 ? analyzed.toLocaleString() : '--') + '</td>'
+ + '<td style="text-align:center;padding:8px;font-size:12px;color:var(--green);">' + (applied > 0 ? applied.toLocaleString() : '--') + '</td>'
+ + '<td style="text-align:center;padding:8px;font-size:12px;">' + pctBar + '</td>'
+ + '<td style="text-align:right;padding:8px;font-size:11px;color:var(--text-muted);">' + lastRun + '</td>'
+ + '<td style="text-align:center;padding:8px;">' + statusIcon + '</td>'
+ + '<td class="std-disc-cell" data-vendor="' + esc(v.vendor_name).replace(/"/g, '"') + '" data-vendor-code="' + esc(v.vendor_code) + '" style="text-align:center;padding:8px;"></td>';
+ body.appendChild(tr);
+
+ // Paint the discount cell programmatically (input + confirm button) so
+ // click events don't bubble up to the row's onclick="stdPreviewVendor(...)"
+ (function(v, row){
+ var cell = row.querySelector('.std-disc-cell');
+ var wrap = document.createElement('div');
+ wrap.style.cssText = 'display:inline-flex;gap:4px;align-items:center;';
+ var inp = document.createElement('input');
+ inp.type = 'number'; inp.min = '0'; inp.max = '80'; inp.step = '0.5';
+ inp.placeholder = '—';
+ inp.style.cssText = 'width:46px;background:var(--bg-input);border:1px solid var(--border);color:var(--text-primary);padding:2px 4px;border-radius:3px;font:11px inherit;text-align:right;';
+ if (v.vendor_discount_pct != null) inp.value = v.vendor_discount_pct;
+ inp.addEventListener('click', function(e){ e.stopPropagation(); });
+ inp.addEventListener('keydown', function(e){ e.stopPropagation(); });
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ var confirmed = !!v.discount_confirmed;
+ btn.textContent = confirmed ? '✓' : 'OK';
+ btn.title = confirmed ? 'Confirmed — click to re-save with new %' : 'Confirm this % for ' + v.vendor_code;
+ btn.style.cssText = 'background:' + (confirmed ? 'rgba(34,197,94,.15)' : 'rgba(251,191,36,.15)') +
+ ';color:' + (confirmed ? 'var(--green)' : 'var(--orange)') +
+ ';border:1px solid ' + (confirmed ? 'var(--green)' : 'var(--orange)') +
+ ';padding:2px 6px;border-radius:3px;font:10px inherit;font-weight:700;cursor:pointer;';
+ btn.addEventListener('click', function(e){
+ e.stopPropagation();
+ var pct = inp.value.trim() === '' ? null : Number(inp.value);
+ btn.textContent = '...'; btn.disabled = true;
+ apiCall('/api/vendor/discount', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendor_code: v.vendor_code, discount_pct: pct, confirmed: true })
+ }).then(function(d){
+ if (d && d.ok) {
+ btn.textContent = '✓';
+ btn.style.background = 'rgba(34,197,94,.15)';
+ btn.style.color = 'var(--green)';
+ btn.style.borderColor = 'var(--green)';
+ v.vendor_discount_pct = pct;
+ v.discount_confirmed = true;
+ } else {
+ btn.textContent = 'err';
+ }
+ btn.disabled = false;
+ }).catch(function(){ btn.textContent = 'err'; btn.disabled = false; });
+ });
+ wrap.appendChild(inp); wrap.appendChild(btn);
+ cell.appendChild(wrap);
+ })(v, tr);
+ }
+}
+
+function stdStart() {
+ var vendor = document.getElementById('stdVendorSelect').value;
+ if (!vendor) { showToast('Select a vendor first', 'error'); return; }
+ var analyzeOnly = document.getElementById('stdAnalyzeOnly').checked;
+ var limit = parseInt(document.getElementById('stdLimit').value) || 2;
+
+ apiCall('/api/standardize/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ vendor: vendor, analyzeOnly: analyzeOnly, limit: limit })
+ }).then(function(data) {
+ if (data.error) { showToast(data.error, 'error'); return; }
+ showToast('Engine started for ' + vendor, 'success');
+ document.getElementById('stdStartBtn').style.display = 'none';
+ document.getElementById('stdStopBtn').style.display = '';
+ document.getElementById('stdProgressWrap').style.display = 'block';
+ stdStartPolling();
+ }).catch(function(err) {
+ showToast('Start error: ' + err.message, 'error');
+ });
+}
+
+function stdStop() {
+ apiCall('/api/standardize/stop', { method: 'POST' }).then(function(data) {
+ showToast(data.message || 'Stopping...', 'info');
+ });
+}
+
+function stdStartPolling() {
+ if (stdPolling) clearInterval(stdPolling);
+ stdPolling = setInterval(stdPollStatus, 2000);
+}
+
+function stdPollStatus() {
+ apiCall('/api/standardize/status').then(function(s) {
+ // Update status badge
+ var badge = document.getElementById('stdEngineStatus');
+ if (s.running) {
+ badge.textContent = 'RUNNING';
+ badge.style.cssText = 'padding:4px 12px;border-radius:12px;font-size:11px;font-weight:700;background:rgba(34,197,94,0.15);color:var(--green);';
+ document.getElementById('stdStartBtn').style.display = 'none';
+ document.getElementById('stdStopBtn').style.display = '';
+ document.getElementById('stdProgressWrap').style.display = 'block';
+ } else {
+ badge.textContent = s.paused ? 'PAUSED' : 'IDLE';
+ badge.style.cssText = 'padding:4px 12px;border-radius:12px;font-size:11px;font-weight:700;background:var(--bg-input);color:var(--text-muted);';
+ document.getElementById('stdStartBtn').style.display = '';
+ document.getElementById('stdStopBtn').style.display = 'none';
+ if (s.total > 0 && !s.running) {
+ document.getElementById('stdProgressWrap').style.display = 'block';
+ }
+ if (stdPolling && !s.running) {
+ clearInterval(stdPolling);
+ stdPolling = null;
+ // Refresh vendor table
+ loadStandardize();
+ }
+ }
+
+ // Update progress
+ if (s.total > 0) {
+ var pct = Math.round(((s.analyzed + s.applied + s.skipped + s.errors) / s.total) * 100);
+ document.getElementById('stdProgressBar').style.width = pct + '%';
+ document.getElementById('stdProgressPct').textContent = pct + '%';
+ document.getElementById('stdProgressLabel').textContent = s.running ? ('Processing ' + (s.currentVendor || '')) : (s.paused ? 'Paused' : 'Complete');
+ document.getElementById('stdAnalyzedCount').textContent = s.analyzed;
+ document.getElementById('stdAppliedCount').textContent = s.applied;
+ document.getElementById('stdErrorCount').textContent = s.errors;
+ document.getElementById('stdSkippedCount').textContent = s.skipped;
+ document.getElementById('stdCurrentProduct').textContent = s.lastProductTitle || '--';
+ }
+
+ // Update log
+ if (s.log && s.log.length > 0) {
+ var logPanel = document.getElementById('stdLogPanel');
+ logPanel.innerHTML = '';
+ for (var i = 0; i < s.log.length; i++) {
+ var entry = s.log[i];
+ var div = document.createElement('div');
+ div.style.cssText = 'padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.03);';
+ var isError = entry.msg.toLowerCase().includes('error') || entry.msg.toLowerCase().includes('fatal');
+ div.innerHTML = '<span style="color:var(--text-muted);">' + esc(entry.ts) + '</span> <span style="color:' + (isError ? 'var(--red)' : 'var(--text-secondary)') + ';">' + esc(entry.msg) + '</span>';
+ logPanel.appendChild(div);
+ }
+ }
+ }).catch(function() {});
+}
+
+function stdPreviewVendor(vendor) {
+ apiCall('/api/standardize/preview/' + encodeURIComponent(vendor)).then(function(data) {
+ var panel = document.getElementById('stdPreviewPanel');
+ panel.style.display = 'block';
+ document.getElementById('stdPreviewTitle').textContent = vendor + ' — Analyzed Products';
+ var body = document.getElementById('stdPreviewBody');
+ body.innerHTML = '';
+
+ var products = data.products || [];
+ if (products.length === 0) {
+ body.innerHTML = '<div style="color:var(--text-muted);font-size:13px;">No products analyzed yet. Select this vendor and click START.</div>';
+ return;
+ }
+
+ for (var i = 0; i < products.length; i++) {
+ var p = products[i];
+ var colors = [];
+ try { colors = JSON.parse(p.ai_colors || '[]'); } catch(e) {}
+ var hexCodes = [];
+ try { hexCodes = JSON.parse(p.ai_hex_codes || '[]'); } catch(e) {}
+ var styles = [];
+ try { styles = JSON.parse(p.ai_styles || '[]'); } catch(e) {}
+ var patterns = [];
+ try { patterns = JSON.parse(p.ai_patterns || '[]'); } catch(e) {}
+
+ var swatches = '';
+ for (var h = 0; h < hexCodes.length; h++) {
+ swatches += '<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:' + esc(hexCodes[h]) + ';border:1px solid rgba(255,255,255,0.2);margin-right:3px;vertical-align:middle;" title="' + esc(colors[h] || hexCodes[h]) + '"></span>';
+ }
+
+ var statusColor = p.status === 'applied' ? 'var(--green)' : p.status === 'analyzed' ? 'var(--blue)' : p.status === 'error' ? 'var(--red)' : 'var(--text-muted)';
+
+ var card = document.createElement('div');
+ card.style.cssText = 'background:var(--bg-input);border-radius:8px;padding:12px;margin-bottom:8px;';
+ card.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">'
+ + '<div><strong style="color:var(--text-primary);font-size:13px;">' + esc(p.title_standardized || p.title) + '</strong>'
+ + ' <span style="color:' + statusColor + ';font-size:10px;font-weight:700;text-transform:uppercase;">' + esc(p.status) + '</span></div>'
+ + '<a href="https://admin.shopify.com/store/designer-laboratory-sandbox/products/' + esc(p.id) + '" target="_blank" style="color:var(--accent);font-size:11px;">View ↗</a>'
+ + '</div>'
+ + '<div style="display:flex;gap:16px;font-size:12px;flex-wrap:wrap;">'
+ + '<div><span style="color:var(--text-muted);">Colors:</span> ' + swatches + '</div>'
+ + '<div><span style="color:var(--text-muted);">Styles:</span> <span style="color:var(--text-secondary);">' + esc(styles.join(', ') || '--') + '</span></div>'
+ + '<div><span style="color:var(--text-muted);">Patterns:</span> <span style="color:var(--text-secondary);">' + esc(patterns.join(', ') || '--') + '</span></div>'
+ + '<div><span style="color:var(--text-muted);">Material:</span> <span style="color:var(--text-secondary);">' + esc(p.ai_material || '--') + '</span></div>'
+ + '<div><span style="color:var(--text-muted);">Audience:</span> <span style="color:var(--text-secondary);">' + esc(p.ai_audience || '--') + '</span></div>'
+ + (p.ai_is_mural ? '<div><span style="color:var(--orange);font-weight:700;">MURAL</span></div>' : '')
+ + '</div>'
+ + (p.ai_description ? '<div style="margin-top:6px;font-size:11px;color:var(--text-muted);font-style:italic;">' + esc(p.ai_description) + '</div>' : '');
+ body.appendChild(card);
+ }
+ }).catch(function(err) {
+ showToast('Preview error: ' + err.message, 'error');
+ });
+}
+
+// ─── New Launches ──────────────────────────────────────────
+var nlLoaded = false;
+document.getElementById('nlDateRange').addEventListener('change', function() { loadNewLaunches(true); });
+
+function loadNewLaunches(force) {
+ if (nlLoaded && !force) return;
+ var days = document.getElementById('nlDateRange').value;
+ var countEl = document.getElementById('nlCount');
+ var loadingEl = document.getElementById('nlLoading');
+ var content = document.getElementById('nlContent');
+
+ loadingEl.style.display = 'inline';
+ countEl.textContent = '';
+
+ fetch('/api/new-launches?days=' + days)
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ loadingEl.style.display = 'none';
+ nlLoaded = true;
+ if (!data.success) { countEl.textContent = 'Error'; return; }
+ countEl.textContent = data.total + ' products';
+
+ // Build DOM with safe methods
+ while (content.firstChild) content.removeChild(content.firstChild);
+
+ if (data.total === 0) {
+ var empty = document.createElement('div');
+ empty.style.cssText = 'text-align:center;padding:60px;color:var(--text-muted);font-size:14px;';
+ empty.textContent = 'No new products in the last ' + days + ' days';
+ content.appendChild(empty);
+ return;
+ }
+
+ var dates = Object.keys(data.grouped);
+ for (var d = 0; d < dates.length; d++) {
+ var dateKey = dates[d];
+ var items = data.grouped[dateKey];
+
+ // Date header
+ var dateHeader = document.createElement('div');
+ dateHeader.style.cssText = 'display:flex;align-items:center;gap:12px;margin:20px 0 12px;';
+ var dateLabel = document.createElement('div');
+ dateLabel.style.cssText = 'font-size:14px;font-weight:700;color:var(--accent);white-space:nowrap;';
+ dateLabel.textContent = dateKey;
+ var dateBadge = document.createElement('span');
+ dateBadge.style.cssText = 'font-size:11px;background:var(--accent-light);color:var(--accent);padding:2px 8px;border-radius:10px;font-weight:600;';
+ dateBadge.textContent = items.length + ' items';
+ var dateLine = document.createElement('div');
+ dateLine.style.cssText = 'flex:1;height:1px;background:var(--border);';
+ dateHeader.appendChild(dateLabel);
+ dateHeader.appendChild(dateBadge);
+ dateHeader.appendChild(dateLine);
+ content.appendChild(dateHeader);
+
+ // Thumbnail grid
+ var grid = document.createElement('div');
+ grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;';
+
+ for (var i = 0; i < items.length; i++) {
+ (function(p) {
+ var card = document.createElement('div');
+ card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:transform 0.15s,box-shadow 0.15s;cursor:pointer;';
+ card.addEventListener('mouseenter', function() { this.style.transform='translateY(-3px)'; this.style.boxShadow='0 6px 20px rgba(0,0,0,0.4)'; });
+ card.addEventListener('mouseleave', function() { this.style.transform=''; this.style.boxShadow=''; });
+ card.addEventListener('click', function() {
+ window.open('https://www.designerwallcoverings.com/products/' + encodeURIComponent(p.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/,'')), '_blank');
+ });
+
+ // Image
+ var imgWrap = document.createElement('div');
+ imgWrap.style.cssText = 'width:100%;aspect-ratio:1;overflow:hidden;background:var(--bg-input);position:relative;';
+ if (p.image) {
+ var img = document.createElement('img');
+ img.src = p.image;
+ img.alt = p.title;
+ img.loading = 'lazy';
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
+ img.onerror = function() { this.parentNode.style.display = 'flex'; this.parentNode.style.alignItems = 'center'; this.parentNode.style.justifyContent = 'center'; this.style.display = 'none'; var t = document.createElement('span'); t.textContent = 'No Image'; t.style.cssText = 'color:var(--text-muted);font-size:11px;'; this.parentNode.appendChild(t); };
+ imgWrap.appendChild(img);
+ } else {
+ var noImg = document.createElement('div');
+ noImg.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:11px;';
+ noImg.textContent = 'No Image';
+ imgWrap.appendChild(noImg);
+ }
+ card.appendChild(imgWrap);
+
+ // Info
+ var info = document.createElement('div');
+ info.style.cssText = 'padding:8px 10px;';
+
+ var sku = document.createElement('div');
+ sku.style.cssText = 'font-size:10px;font-family:monospace;color:var(--accent);margin-bottom:2px;';
+ sku.textContent = p.sku;
+ info.appendChild(sku);
+
+ var title = document.createElement('div');
+ title.style.cssText = 'font-size:12px;font-weight:600;color:var(--text-primary);line-height:1.3;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;';
+ title.textContent = p.title;
+ info.appendChild(title);
+
+ var meta = document.createElement('div');
+ meta.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-top:4px;';
+ var vendor = document.createElement('span');
+ vendor.style.cssText = 'font-size:10px;color:var(--text-muted);max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ vendor.textContent = p.vendor;
+ var typeBadge = document.createElement('span');
+ typeBadge.style.cssText = 'font-size:9px;padding:1px 6px;border-radius:3px;background:var(--blue-bg);color:var(--blue);font-weight:600;';
+ typeBadge.textContent = p.type || 'Product';
+ meta.appendChild(vendor);
+ meta.appendChild(typeBadge);
+ info.appendChild(meta);
+
+ card.appendChild(info);
+ grid.appendChild(card);
+ })(items[i]);
+ }
+
+ content.appendChild(grid);
+ }
+ })
+ .catch(function(err) {
+ loadingEl.style.display = 'none';
+ countEl.textContent = 'Error: ' + err.message;
+ });
+}
+
+// ============================================================
+// GLOBAL CATALOG SEARCH & VIEW ALL
+// ============================================================
+var srCurrentPage = 1;
+var srTotalPages = 1;
+var srMode = ''; // 'search' or 'viewall'
+var srQuery = '';
+var srVendor = '';
+var srDebounceTimer = null;
+
+// Enter key triggers search
+document.getElementById('globalSearchInput').addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ performGlobalSearch();
+ }
+});
+
+// Debounced live search on typing (400ms)
+document.getElementById('globalSearchInput').addEventListener('input', function() {
+ var val = this.value.trim();
+ if (val.length >= 3) {
+ clearTimeout(srDebounceTimer);
+ srDebounceTimer = setTimeout(function() { performGlobalSearch(); }, 400);
+ } else if (val.length === 0 && srMode === 'search') {
+ closeSearchResults();
+ }
+});
+
+function performGlobalSearch() {
+ var q = document.getElementById('globalSearchInput').value.trim();
+ if (!q) { showToast('Enter a search term', 'info'); return; }
+ srMode = 'search';
+ srQuery = q;
+ srCurrentPage = 1;
+ srVendor = '';
+ document.getElementById('srVendorFilter').style.display = 'none';
+ fetchSearchResults();
+}
+
+function viewAllProducts() {
+ srMode = 'viewall';
+ srQuery = '';
+ srCurrentPage = 1;
+ srVendor = '';
+ document.getElementById('globalSearchInput').value = '';
+ // Build vendor filter dropdown
+ var sel = document.getElementById('srVendorFilter');
+ sel.style.display = 'inline-block';
+ while (sel.options.length > 1) sel.remove(1);
+ if (vendors && vendors.length) {
+ var sorted = vendors.slice().sort(function(a,b) { return a.vendor_name.localeCompare(b.vendor_name); });
+ for (var i = 0; i < sorted.length; i++) {
+ if (sorted[i].catalog_table && sorted[i].catalog_count > 0) {
+ var opt = document.createElement('option');
+ opt.value = sorted[i].vendor_code;
+ opt.textContent = sorted[i].vendor_name + ' (' + (sorted[i].catalog_count || 0) + ')';
+ sel.appendChild(opt);
+ }
+ }
+ }
+ fetchSearchResults();
+}
+
+function filterByVendor() {
+ srVendor = document.getElementById('srVendorFilter').value;
+ srCurrentPage = 1;
+ fetchSearchResults();
+}
+
+function srBuildLoadingRow() {
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 11;
+ var div = document.createElement('div');
+ div.className = 'sr-loading';
+ var spinner = document.createElement('div');
+ spinner.className = 'spinner-ring';
+ div.appendChild(spinner);
+ var txt = document.createTextNode('Searching across all vendor catalogs...');
+ div.appendChild(txt);
+ td.appendChild(div);
+ tr.appendChild(td);
+ return tr;
+}
+
+function srBuildEmptyRow(message) {
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 11;
+ var div = document.createElement('div');
+ div.className = 'sr-empty';
+ div.textContent = message;
+ td.appendChild(div);
+ tr.appendChild(td);
+ return tr;
+}
+
+function fetchSearchResults() {
+ var panel = document.getElementById('searchResultsPanel');
+ var tbody = document.getElementById('srTableBody');
+ var title = document.getElementById('srTitle');
+ var count = document.getElementById('srCount');
+ var pagination = document.getElementById('srPagination');
+
+ panel.classList.add('active');
+
+ // Show loading
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+ tbody.appendChild(srBuildLoadingRow());
+ title.textContent = srMode === 'search' ? 'Search Results' : 'All Catalog Items';
+ count.textContent = '';
+ pagination.style.display = 'none';
+
+ var url;
+ if (srMode === 'search') {
+ url = '/api/catalog-search?q=' + encodeURIComponent(srQuery) + '&page=' + srCurrentPage + '&limit=50';
+ } else {
+ url = '/api/catalog-all?page=' + srCurrentPage + '&limit=50';
+ if (srVendor) url += '&vendor=' + encodeURIComponent(srVendor);
+ }
+
+ apiCall(url).then(function(res) {
+ if (!res.success) {
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+ tbody.appendChild(srBuildEmptyRow('Error: ' + (res.error || 'Unknown')));
+ return;
+ }
+
+ var data = res.data || [];
+ srTotalPages = res.pages || 1;
+ var total = res.total || 0;
+
+ if (srMode === 'search') {
+ title.textContent = 'Search Results for "' + srQuery + '"';
+ } else {
+ title.textContent = srVendor ? 'All Items — ' + (data.length > 0 ? data[0].vendor_name : srVendor) : 'All Catalog Items';
+ }
+ count.textContent = total.toLocaleString() + ' products found';
+
+ // Clear tbody
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+
+ if (data.length === 0) {
+ tbody.appendChild(srBuildEmptyRow('No products found' + (srMode === 'search' ? ' matching "' + srQuery + '"' : '')));
+ pagination.style.display = 'none';
+ return;
+ }
+
+ // Render results using safe DOM methods
+ for (var i = 0; i < data.length; i++) {
+ var item = data[i];
+ var tr = document.createElement('tr');
+
+ // Image
+ var tdImg = document.createElement('td');
+ if (item.image_url) {
+ var img = document.createElement('img');
+ img.className = 'sr-thumb';
+ img.src = item.image_url;
+ img.alt = (item.pattern_name || '') + ' ' + (item.color_name || '');
+ img.loading = 'lazy';
+ img.onerror = function() {
+ this.style.display = 'none';
+ var ph = document.createElement('div');
+ ph.className = 'sr-thumb-placeholder';
+ ph.textContent = '?';
+ this.parentNode.appendChild(ph);
+ };
+ tdImg.appendChild(img);
+ } else {
+ var placeholder = document.createElement('div');
+ placeholder.className = 'sr-thumb-placeholder';
+ placeholder.textContent = '?';
+ tdImg.appendChild(placeholder);
+ }
+ tr.appendChild(tdImg);
+
+ // Vendor
+ var tdVendor = document.createElement('td');
+ var vendorSpan = document.createElement('span');
+ vendorSpan.className = 'sr-vendor-badge';
+ vendorSpan.textContent = item.vendor_name || item.vendor_code || '';
+ tdVendor.appendChild(vendorSpan);
+ tr.appendChild(tdVendor);
+
+ // Pattern
+ var tdPattern = document.createElement('td');
+ tdPattern.style.fontWeight = '600';
+ tdPattern.style.maxWidth = '200px';
+ tdPattern.style.overflow = 'hidden';
+ tdPattern.style.textOverflow = 'ellipsis';
+ tdPattern.title = item.pattern_name || '';
+ tdPattern.textContent = item.pattern_name || '';
+ tr.appendChild(tdPattern);
+
+ // Color
+ var tdColor = document.createElement('td');
+ tdColor.textContent = item.color_name || '';
+ tdColor.style.maxWidth = '150px';
+ tdColor.style.overflow = 'hidden';
+ tdColor.style.textOverflow = 'ellipsis';
+ tdColor.title = item.color_name || '';
+ tr.appendChild(tdColor);
+
+ // MFR SKU
+ var tdSku = document.createElement('td');
+ tdSku.className = 'sr-sku';
+ tdSku.textContent = item.mfr_sku || '';
+ tr.appendChild(tdSku);
+
+ // DW SKU
+ var tdDwSku = document.createElement('td');
+ tdDwSku.className = 'sr-sku';
+ tdDwSku.style.color = item.dw_sku ? 'var(--accent)' : 'var(--text-muted)';
+ tdDwSku.textContent = item.dw_sku || '';
+ tr.appendChild(tdDwSku);
+
+ // Collection
+ var tdColl = document.createElement('td');
+ tdColl.textContent = item.collection || '';
+ tdColl.style.fontSize = '11px';
+ tdColl.style.color = 'var(--text-secondary)';
+ tdColl.style.maxWidth = '150px';
+ tdColl.style.overflow = 'hidden';
+ tdColl.style.textOverflow = 'ellipsis';
+ tdColl.title = item.collection || '';
+ tr.appendChild(tdColl);
+
+ // Product Type
+ var tdType = document.createElement('td');
+ tdType.textContent = item.product_type || '';
+ tdType.style.fontSize = '11px';
+ tdType.style.color = 'var(--text-secondary)';
+ tr.appendChild(tdType);
+
+ // Width
+ var tdWidth = document.createElement('td');
+ tdWidth.textContent = item.width || '';
+ tdWidth.style.fontFamily = 'monospace';
+ tdWidth.style.fontSize = '11px';
+ tr.appendChild(tdWidth);
+
+ // FULLPRODUCT Gate Status
+ var tdGate = document.createElement('td');
+ tdGate.style.textAlign = 'center';
+ var gateBar = document.createElement('div');
+ gateBar.className = 'gate-bar';
+
+ // Define gate checks: [label, value, isHard]
+ var gateChecks = [
+ ['Image', item.image_url, true],
+ ['Width', item.width, true],
+ ['MFR SKU', item.mfr_sku, true],
+ ['Color', item.color_name, true],
+ ['Repeat', item.repeat_v || item.repeat_h, false],
+ ['Material', item.material, false],
+ ['All Images', item.all_images, false],
+ ['Spec Sheet', item.spec_sheet, false],
+ ['About Vendor', item.about_vendor, false]
+ ];
+
+ var hardPass = 0, hardTotal = 0, softPass = 0, softTotal = 0;
+ for (var g = 0; g < gateChecks.length; g++) {
+ var gc = gateChecks[g];
+ var hasVal = gc[1] && String(gc[1]).trim() !== '' && gc[1] !== 'null';
+ var gdot = document.createElement('span');
+ gdot.className = 'gate-dot ' + (hasVal ? 'pass' : (gc[2] ? 'fail-hard' : 'fail-soft'));
+ gdot.title = gc[0] + ': ' + (hasVal ? 'OK' : 'MISSING') + (gc[2] ? ' (REQUIRED)' : '');
+ gateBar.appendChild(gdot);
+ if (gc[2]) { hardTotal++; if (hasVal) hardPass++; }
+ else { softTotal++; if (hasVal) softPass++; }
+ }
+
+ var gateScore = document.createElement('span');
+ var totalPass = hardPass + softPass;
+ var totalChecks = hardTotal + softTotal;
+ gateScore.className = 'gate-score ' + (hardPass < hardTotal ? 'blocked' : (totalPass === totalChecks ? 'ready' : 'partial'));
+ gateScore.textContent = totalPass + '/' + totalChecks;
+ gateScore.title = hardPass < hardTotal
+ ? 'BLOCKED: Missing ' + (hardTotal - hardPass) + ' required field(s)'
+ : (totalPass === totalChecks ? 'Shopify Ready' : totalPass + ' of ' + totalChecks + ' fields populated');
+ gateBar.appendChild(gateScore);
+
+ tdGate.appendChild(gateBar);
+ tr.appendChild(tdGate);
+
+ // Shopify Status
+ var tdShopify = document.createElement('td');
+ tdShopify.style.textAlign = 'center';
+ var dot = document.createElement('span');
+ dot.className = 'sr-shopify-dot ' + (item.shopify_product_id ? 'on-shopify' : 'not-on-shopify');
+ dot.title = item.shopify_product_id ? 'On Shopify (ID: ' + item.shopify_product_id + ')' : 'Not on Shopify';
+ tdShopify.appendChild(dot);
+ tr.appendChild(tdShopify);
+
+ // Click row to open product URL if available
+ if (item.product_url) {
+ tr.style.cursor = 'pointer';
+ tr.title = 'Click to open vendor page';
+ tr.addEventListener('click', (function(purl) {
+ return function() { window.open(purl, '_blank'); };
+ })(item.product_url));
+ }
+
+ tbody.appendChild(tr);
+ }
+
+ // Pagination
+ if (srTotalPages > 1) {
+ pagination.style.display = 'flex';
+ document.getElementById('srPrevBtn').disabled = srCurrentPage <= 1;
+ document.getElementById('srNextBtn').disabled = srCurrentPage >= srTotalPages;
+ document.getElementById('srPageInfo').textContent = 'Page ' + srCurrentPage + ' of ' + srTotalPages.toLocaleString();
+ } else {
+ pagination.style.display = 'none';
+ }
+ }).catch(function(err) {
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
+ tbody.appendChild(srBuildEmptyRow('Error: ' + err.message));
+ });
+}
+
+function srPrevPage() {
+ if (srCurrentPage > 1) {
+ srCurrentPage--;
+ fetchSearchResults();
+ document.getElementById('searchResultsPanel').scrollTop = 0;
+ }
+}
+
+function srNextPage() {
+ if (srCurrentPage < srTotalPages) {
+ srCurrentPage++;
+ fetchSearchResults();
+ document.getElementById('searchResultsPanel').scrollTop = 0;
+ }
+}
+
+function closeSearchResults() {
+ document.getElementById('searchResultsPanel').classList.remove('active');
+ srMode = '';
+}
+
+// ═══════════════════════════════════════════════════════════════
+// Product Actions Tab
+// ═══════════════════════════════════════════════════════════════
+var paProducts = [];
+var paSelected = {};
+var paCursor = null;
+var paHasMore = false;
+
+function paSearch() {
+ var q = document.getElementById('paSearchInput').value.trim();
+ if (!q) return;
+ var status = document.getElementById('paStatusFilter').value;
+ document.getElementById('paSearching').style.display = 'inline';
+ document.getElementById('paTableBody').textContent = '';
+ paProducts = [];
+ paSelected = {};
+ paCursor = null;
+ paUpdateBulkBar();
+
+ var query = q;
+ if (status) query += ' status:' + status.toLowerCase();
+
+ fetch('/api/product-actions/search?q=' + encodeURIComponent(query), {headers:{'Authorization':'' /* auth via httpOnly vcc_session cookie — no embedded credential */}})
+ .then(function(r){return r.json()})
+ .then(function(data){
+ document.getElementById('paSearching').style.display = 'none';
+ paProducts = data.products || [];
+ paCursor = data.cursor || null;
+ paHasMore = data.hasMore || false;
+ document.getElementById('paResultCount').textContent = paProducts.length + ' products' + (paHasMore ? ' (more available)' : '');
+ paRenderTable();
+ })
+ .catch(function(e){
+ document.getElementById('paSearching').style.display = 'none';
+ document.getElementById('paResultCount').textContent = 'Error: ' + e.message;
+ });
+}
+
+function paLoadMore() {
+ if (!paCursor) return;
+ var q = document.getElementById('paSearchInput').value.trim();
+ var status = document.getElementById('paStatusFilter').value;
+ var query = q;
+ if (status) query += ' status:' + status.toLowerCase();
+
+ fetch('/api/product-actions/search?q=' + encodeURIComponent(query) + '&cursor=' + encodeURIComponent(paCursor), {headers:{'Authorization':'' /* auth via httpOnly vcc_session cookie — no embedded credential */}})
+ .then(function(r){return r.json()})
+ .then(function(data){
+ paProducts = paProducts.concat(data.products || []);
+ paCursor = data.cursor || null;
+ paHasMore = data.hasMore || false;
+ document.getElementById('paResultCount').textContent = paProducts.length + ' products' + (paHasMore ? ' (more available)' : '');
+ paRenderTable();
+ });
+}
+
+function paCreateCell(text, styles) {
+ var td = document.createElement('td');
+ td.textContent = text || '';
+ td.style.cssText = styles || 'padding:6px 8px;';
+ return td;
+}
+
+function paRenderTable() {
+ var tbody = document.getElementById('paTableBody');
+ tbody.textContent = '';
+ for (var i = 0; i < paProducts.length; i++) {
+ var p = paProducts[i];
+ var tr = document.createElement('tr');
+ tr.style.borderBottom = '1px solid var(--border)';
+ if (paSelected[p.id]) tr.style.background = 'rgba(88,166,255,0.06)';
+
+ var statusColor = p.status === 'ACTIVE' ? '#3fb950' : p.status === 'DRAFT' ? '#d29922' : '#8b949e';
+ var statusBg = p.status === 'ACTIVE' ? 'rgba(63,185,80,0.12)' : p.status === 'DRAFT' ? 'rgba(210,153,34,0.12)' : 'rgba(139,148,158,0.12)';
+
+ // Checkbox cell
+ var cbTd = document.createElement('td');
+ cbTd.style.cssText = 'padding:6px 8px;';
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.dataset.pid = p.id;
+ cb.checked = !!paSelected[p.id];
+ cb.setAttribute('onchange', "paToggleOne('"+p.id+"',this.checked)");
+ cbTd.appendChild(cb);
+ tr.appendChild(cbTd);
+
+ // Image cell
+ var imgTd = document.createElement('td');
+ imgTd.style.cssText = 'padding:6px 8px;';
+ if (p.image) {
+ var img = document.createElement('img');
+ img.src = p.image;
+ img.style.cssText = 'width:40px;height:40px;object-fit:cover;border-radius:4px;';
+ imgTd.appendChild(img);
+ } else {
+ var placeholder = document.createElement('div');
+ placeholder.style.cssText = 'width:40px;height:40px;background:var(--border);border-radius:4px;';
+ imgTd.appendChild(placeholder);
+ }
+ tr.appendChild(imgTd);
+
+ // Title cell with link
+ var titleTd = document.createElement('td');
+ titleTd.style.cssText = 'padding:6px 8px;max-width:350px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ titleTd.title = p.title;
+ var link = document.createElement('a');
+ link.href = 'https://admin.shopify.com/store/designer-laboratory-sandbox/products/' + p.id;
+ link.target = '_blank';
+ link.style.cssText = 'color:var(--accent);text-decoration:none;';
+ link.textContent = p.title;
+ titleTd.appendChild(link);
+ tr.appendChild(titleTd);
+
+ // Vendor, Status, SKU, Type, Updated cells
+ tr.appendChild(paCreateCell(p.vendor, 'padding:6px 8px;color:var(--text-muted);font-size:12px;'));
+
+ var statusTd = document.createElement('td');
+ statusTd.style.cssText = 'padding:6px 8px;';
+ var badge = document.createElement('span');
+ badge.style.cssText = 'padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;background:'+statusBg+';color:'+statusColor+';';
+ badge.textContent = p.status;
+ statusTd.appendChild(badge);
+ tr.appendChild(statusTd);
+
+ tr.appendChild(paCreateCell(p.sku, 'padding:6px 8px;font-family:monospace;font-size:11px;color:var(--text-muted);'));
+ tr.appendChild(paCreateCell(p.type, 'padding:6px 8px;font-size:12px;color:var(--text-muted);'));
+ tr.appendChild(paCreateCell(p.updated, 'padding:6px 8px;font-size:11px;color:var(--text-muted);'));
+
+ tbody.appendChild(tr);
+ }
+ var pag = document.getElementById('paPagination');
+ pag.textContent = '';
+ if (paHasMore) {
+ var btn = document.createElement('button');
+ btn.textContent = 'Load More';
+ btn.style.cssText = 'padding:8px 24px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:600;';
+ btn.onclick = paLoadMore;
+ pag.appendChild(btn);
+ }
+}
+
+function paToggleOne(pid, checked) {
+ if (checked) paSelected[pid] = true;
+ else delete paSelected[pid];
+ paUpdateBulkBar();
+}
+
+function paToggleAll(checked) {
+ for (var i = 0; i < paProducts.length; i++) {
+ if (checked) paSelected[paProducts[i].id] = true;
+ else delete paSelected[paProducts[i].id];
+ }
+ paRenderTable();
+ paUpdateBulkBar();
+}
+
+function paUpdateBulkBar() {
+ var count = Object.keys(paSelected).length;
+ var bar = document.getElementById('paBulkBar');
+ bar.style.display = count > 0 ? 'flex' : 'none';
+ document.getElementById('paSelectedCount').textContent = count + ' selected';
+}
+
+function paBulkStatus(newStatus) {
+ var ids = Object.keys(paSelected);
+ if (!ids.length) return;
+ if (!confirm('Set ' + ids.length + ' products to ' + newStatus.toUpperCase() + '?')) return;
+
+ document.getElementById('paBulkProgress').textContent = 'Processing...';
+ fetch('/api/product-actions/bulk-status', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json','Authorization':'' /* auth via httpOnly vcc_session cookie — no embedded credential */},
+ body: JSON.stringify({ids: ids, status: newStatus})
+ })
+ .then(function(r){return r.json()})
+ .then(function(data){
+ document.getElementById('paBulkProgress').textContent = 'Done: ' + data.success + ' updated, ' + data.errors + ' errors';
+ for (var i = 0; i < paProducts.length; i++) {
+ if (paSelected[paProducts[i].id]) paProducts[i].status = newStatus.toUpperCase();
+ }
+ paSelected = {};
+ paUpdateBulkBar();
+ paRenderTable();
+ document.getElementById('paSelectAll').checked = false;
+ })
+ .catch(function(e){
+ document.getElementById('paBulkProgress').textContent = 'Error: ' + e.message;
+ });
+}
+
+function paBulkRedirect() {
+ var ids = Object.keys(paSelected);
+ var target = document.getElementById('paRedirectTarget').value.trim() || '/';
+ if (!ids.length) return;
+ if (!confirm('Create redirects for ' + ids.length + ' products to "' + target + '"?')) return;
+
+ var handles = [];
+ for (var i = 0; i < paProducts.length; i++) {
+ if (paSelected[paProducts[i].id]) handles.push(paProducts[i].handle);
+ }
+
+ document.getElementById('paBulkProgress').textContent = 'Creating redirects...';
+ fetch('/api/product-actions/bulk-redirect', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json','Authorization':'' /* auth via httpOnly vcc_session cookie — no embedded credential */},
+ body: JSON.stringify({handles: handles, target: target})
+ })
+ .then(function(r){return r.json()})
+ .then(function(data){
+ document.getElementById('paBulkProgress').textContent = 'Redirects: ' + data.created + ' created, ' + data.skipped + ' skipped, ' + data.errors + ' errors';
+ })
+ .catch(function(e){
+ document.getElementById('paBulkProgress').textContent = 'Error: ' + e.message;
+ });
+}
+
+</script>
+
+<!-- Vendor Trade Portal Credentials Panel -->
+<div id="credentialsPanel" style="margin:32px 24px 0 24px;">
+ <div id="credentialsPanelHeader" onclick="toggleCredentialsPanel()" style="
+ background:#13152a;border:1px solid #2a2d45;border-radius:10px;
+ padding:16px 20px;display:flex;align-items:center;gap:12px;
+ cursor:pointer;user-select:none;transition:background 0.2s;
+ " onmouseover="this.style.background='#1a1d35'" onmouseout="this.style.background='#13152a'">
+ <span style="font-size:18px;">🔐</span>
+ <span style="color:#e8e8f0;font-size:15px;font-weight:600;flex:1;">Trade Portal Credentials <span id="credCountBadge" style="color:#7c3aed;font-size:13px;font-weight:400;">(loading...)</span></span>
+ <span id="credChevron" style="color:#8888a8;font-size:18px;transition:transform 0.2s;">▼</span>
+ </div>
+ <div id="credentialsBody" style="display:none;background:#0f0f1a;border:1px solid #2a2d45;border-top:none;border-radius:0 0 10px 10px;overflow:hidden;">
+ <div style="padding:12px 20px;border-bottom:1px solid #1e2140;">
+ <span style="color:#8888a8;font-size:13px;">Trade portal logins for vendor websites. Changes save instantly to the database.</span>
+ </div>
+ <div style="overflow-x:auto;">
+ <table id="credentialsTable" style="width:100%;border-collapse:collapse;">
+ <thead>
+ <tr style="background:#13152a;">
+ <th style="padding:10px 16px;text-align:left;color:#8888a8;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;min-width:180px;">Vendor</th>
+ <th style="padding:10px 16px;text-align:left;color:#8888a8;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;min-width:200px;">Portal URL</th>
+ <th style="padding:10px 16px;text-align:left;color:#8888a8;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;min-width:180px;">Username</th>
+ <th style="padding:10px 16px;text-align:left;color:#8888a8;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;min-width:180px;">Password</th>
+ <th style="padding:10px 16px;text-align:center;color:#8888a8;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;width:80px;">Save</th>
+ </tr>
+ </thead>
+ <tbody id="credentialsTableBody">
+ <tr id="cred-loading-row"><td colspan="5" style="padding:24px;text-align:center;color:#8888a8;font-size:13px;">Loading credentials...</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+
+<script>
+var credPanelOpen = false;
+var credVendors = [];
+
+function toggleCredentialsPanel() {
+ credPanelOpen = !credPanelOpen;
+ document.getElementById('credentialsBody').style.display = credPanelOpen ? 'block' : 'none';
+ document.getElementById('credChevron').style.transform = credPanelOpen ? 'rotate(180deg)' : '';
+ if (credPanelOpen && credVendors.length === 0) {
+ loadCredentials();
+ }
+}
+
+function loadCredentials() {
+ apiCall('/api/vendor-credentials').then(function(data) {
+ if (!data.ok) { return; }
+ credVendors = data.vendors;
+ document.getElementById('credCountBadge').textContent = '(' + data.vendors.length + ' vendors)';
+ renderCredentialsTable(data.vendors);
+ }).catch(function(e) {
+ var tbody = document.getElementById('credentialsTableBody');
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 5;
+ td.style.cssText = 'padding:24px;text-align:center;color:#ef4444;font-size:13px;';
+ td.textContent = 'Error loading credentials: ' + e.message;
+ tr.appendChild(td);
+ tbody.replaceChildren(tr);
+ });
+}
+
+function renderCredentialsTable(vendors) {
+ var tbody = document.getElementById('credentialsTableBody');
+ tbody.replaceChildren();
+
+ if (!vendors || vendors.length === 0) {
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ td.colSpan = 5;
+ td.style.cssText = 'padding:24px;text-align:center;color:#8888a8;font-size:13px;';
+ td.textContent = 'No vendors with credentials found.';
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ return;
+ }
+
+ vendors.forEach(function(v, i) {
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'background:' + (i % 2 === 0 ? '#0f0f1a' : '#111120') + ';border-bottom:1px solid #1e2140;';
+ tr.id = 'cred-row-' + i;
+
+ // Vendor name cell
+ var tdName = document.createElement('td');
+ tdName.style.cssText = 'padding:10px 16px;color:#e8e8f0;font-size:13px;font-weight:500;';
+ tdName.textContent = v.vendor_name;
+ tr.appendChild(tdName);
+
+ // Portal URL cell
+ var tdUrl = document.createElement('td');
+ tdUrl.style.cssText = 'padding:10px 16px;';
+ var portalUrl = v.login_url || v.website_url || '';
+ if (portalUrl) {
+ var link = document.createElement('a');
+ link.href = portalUrl;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.style.cssText = 'color:#7c3aed;text-decoration:none;font-size:13px;';
+ link.title = portalUrl;
+ link.textContent = truncateUrl(portalUrl);
+ tdUrl.appendChild(link);
+ } else {
+ tdUrl.style.color = '#55557a';
+ tdUrl.textContent = '\u2014';
+ }
+ tr.appendChild(tdUrl);
+
+ // Username cell
+ var tdUser = document.createElement('td');
+ tdUser.style.cssText = 'padding:10px 16px;';
+ var userInput = document.createElement('input');
+ userInput.type = 'text';
+ userInput.id = 'cred-user-' + i;
+ userInput.value = v.trade_username || '';
+ userInput.placeholder = 'username';
+ userInput.autocomplete = 'off';
+ userInput.style.cssText = 'background:#1e2140;border:1px solid #2a2d45;border-radius:6px;color:#e8e8f0;font-size:13px;padding:6px 10px;width:100%;outline:none;';
+ userInput.addEventListener('focus', function(){ this.style.borderColor='#7c3aed'; });
+ userInput.addEventListener('blur', function(){ this.style.borderColor='#2a2d45'; });
+ tdUser.appendChild(userInput);
+ tr.appendChild(tdUser);
+
+ // Password cell
+ var tdPass = document.createElement('td');
+ tdPass.style.cssText = 'padding:10px 16px;';
+ var passInput = document.createElement('input');
+ passInput.type = 'password';
+ passInput.id = 'cred-pass-' + i;
+ passInput.value = v.trade_password || '';
+ passInput.placeholder = 'password';
+ passInput.autocomplete = 'new-password';
+ passInput.style.cssText = 'background:#1e2140;border:1px solid #2a2d45;border-radius:6px;color:#e8e8f0;font-size:13px;padding:6px 10px;width:100%;outline:none;';
+ passInput.addEventListener('focus', function(){ this.style.borderColor='#7c3aed'; });
+ passInput.addEventListener('blur', function(){ this.style.borderColor='#2a2d45'; });
+ tdPass.appendChild(passInput);
+ tr.appendChild(tdPass);
+
+ // Save cell
+ var tdSave = document.createElement('td');
+ tdSave.style.cssText = 'padding:10px 16px;text-align:center;';
+ var btn = document.createElement('button');
+ btn.id = 'cred-btn-' + i;
+ btn.textContent = 'Save';
+ btn.style.cssText = 'background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer;';
+ btn.addEventListener('mouseover', function(){ this.style.background='#6d28d9'; });
+ btn.addEventListener('mouseout', function(){ this.style.background='#7c3aed'; });
+ (function(vendorName, idx){ btn.addEventListener('click', function(){ saveCredential(vendorName, idx); }); })(v.vendor_name, i);
+ tdSave.appendChild(btn);
+
+ var okMark = document.createElement('span');
+ okMark.id = 'cred-ok-' + i;
+ okMark.style.cssText = 'display:none;color:#22c55e;font-size:16px;margin-left:6px;';
+ okMark.textContent = '\u2713';
+ tdSave.appendChild(okMark);
+ tr.appendChild(tdSave);
+
+ tbody.appendChild(tr);
+ });
+}
+
+function saveCredential(vendorName, idx) {
+ var userEl = document.getElementById('cred-user-' + idx);
+ var passEl = document.getElementById('cred-pass-' + idx);
+ var btn = document.getElementById('cred-btn-' + idx);
+ var ok = document.getElementById('cred-ok-' + idx);
+ if (!userEl || !passEl) return;
+ btn.disabled = true;
+ btn.textContent = '...';
+ apiCall('/api/vendor-credentials', {
+ method: 'POST',
+ body: JSON.stringify({ vendor_name: vendorName, trade_username: userEl.value, trade_password: passEl.value })
+ }).then(function(data) {
+ btn.disabled = false;
+ btn.textContent = 'Save';
+ if (data.ok) {
+ ok.style.display = 'inline';
+ setTimeout(function() { ok.style.display = 'none'; }, 2500);
+ } else {
+ alert('Error saving: ' + (data.error || 'unknown error'));
+ }
+ }).catch(function(e) {
+ btn.disabled = false;
+ btn.textContent = 'Save';
+ alert('Network error: ' + e.message);
+ });
+}
+
+function truncateUrl(url) {
+ try {
+ var u = new URL(url);
+ var path = u.pathname !== '/' ? u.pathname.slice(0, 20) + (u.pathname.length > 20 ? '\u2026' : '') : '';
+ return u.hostname + path;
+ } catch(e) {
+ return url.slice(0, 30) + (url.length > 30 ? '\u2026' : '');
+ }
+}
+
+// Load badge count on page load (even when panel is collapsed)
+apiCall('/api/vendor-credentials').then(function(data) {
+ if (data.ok) {
+ document.getElementById('credCountBadge').textContent = '(' + data.vendors.length + ' vendors)';
+ credVendors = data.vendors;
+ }
+}).catch(function(){});
+</script>
+
+<!-- Color Enrichment Progress Panel -->
+<div id="colorEnrichPanel" style="margin:20px 24px 0 24px;">
+ <div id="colorEnrichHeader" onclick="toggleColorEnrichPanel()" style="
+ background:#13152a;border:1px solid #2a2d45;border-radius:10px;
+ padding:16px 20px;display:flex;align-items:center;gap:12px;
+ cursor:pointer;user-select:none;transition:background 0.2s;
+ " onmouseover="this.style.background='#1a1d35'" onmouseout="this.style.background='#13152a'">
+ <span style="font-size:18px;">🎨</span>
+ <span style="color:#e8e8f0;font-size:15px;font-weight:600;flex:1;">Color Enrichment Progress <span id="colorEnrichBadge" style="color:#7c3aed;font-size:13px;font-weight:400;">(loading...)</span></span>
+ <span id="colorEnrichChevron" style="color:#8888a8;font-size:18px;transition:transform 0.2s;">▼</span>
+ </div>
+ <div id="colorEnrichBody" style="display:none;background:#0f0f1a;border:1px solid #2a2d45;border-top:none;border-radius:0 0 10px 10px;overflow:hidden;padding:20px;">
+ <div id="colorEnrichLoading" style="color:#8888a8;text-align:center;padding:20px;">Loading color enrichment data...</div>
+ <div id="colorEnrichContent" style="display:none;">
+ <div id="colorEnrichSummary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:20px;"></div>
+ <div id="colorEnrichBarWrap" style="margin-bottom:20px;">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
+ <span style="color:#e8e8f0;font-size:13px;font-weight:600;">Overall Progress</span>
+ <span id="colorEnrichPctLabel" style="color:#7c3aed;font-size:14px;font-weight:700;">0%</span>
+ </div>
+ <div style="background:#1e2140;border-radius:8px;height:20px;overflow:hidden;position:relative;">
+ <div id="colorEnrichBar" style="height:100%;border-radius:8px;background:linear-gradient(90deg,#7c3aed,#22c55e);transition:width 0.6s ease;width:0%;"></div>
+ </div>
+ <div id="colorEnrichEta" style="color:#8888a8;font-size:11px;margin-top:4px;"></div>
+ </div>
+ <div style="overflow-x:auto;max-height:400px;overflow-y:auto;">
+ <table style="width:100%;border-collapse:collapse;">
+ <thead style="position:sticky;top:0;z-index:1;">
+ <tr style="background:#13152a;">
+ <th style="padding:8px 12px;text-align:left;color:#8888a8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;">Vendor</th>
+ <th style="padding:8px 12px;text-align:right;color:#8888a8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;">Total</th>
+ <th style="padding:8px 12px;text-align:right;color:#8888a8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;">Enriched</th>
+ <th style="padding:8px 12px;text-align:right;color:#8888a8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;">Remaining</th>
+ <th style="padding:8px 12px;text-align:left;color:#8888a8;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #2a2d45;min-width:180px;">Progress</th>
+ </tr>
+ </thead>
+ <tbody id="colorEnrichTableBody"></tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+var colorEnrichOpen = false;
+var colorEnrichLoaded = false;
+
+function toggleColorEnrichPanel() {
+ colorEnrichOpen = !colorEnrichOpen;
+ document.getElementById('colorEnrichBody').style.display = colorEnrichOpen ? 'block' : 'none';
+ document.getElementById('colorEnrichChevron').style.transform = colorEnrichOpen ? 'rotate(180deg)' : '';
+ if (colorEnrichOpen && !colorEnrichLoaded) {
+ loadColorEnrichment();
+ }
+}
+
+function loadColorEnrichment() {
+ apiCall('/api/enrichment/color-progress').then(function(data) {
+ if (!data.success) {
+ document.getElementById('colorEnrichLoading').textContent = 'Error: ' + (data.error || 'unknown');
+ return;
+ }
+ colorEnrichLoaded = true;
+ document.getElementById('colorEnrichLoading').style.display = 'none';
+ document.getElementById('colorEnrichContent').style.display = 'block';
+
+ var s = data.summary;
+
+ // Update badge
+ document.getElementById('colorEnrichBadge').textContent = '(' + s.pct + '% complete)';
+
+ // Summary cards
+ var summaryEl = document.getElementById('colorEnrichSummary');
+ summaryEl.replaceChildren();
+ var cards = [
+ { label: 'Vendors', value: s.total_vendors, color: '#7c3aed' },
+ { label: 'Active SKUs', value: s.total_active.toLocaleString(), color: '#3b82f6' },
+ { label: 'Enriched', value: s.enriched.toLocaleString(), color: '#22c55e' },
+ { label: 'Remaining', value: s.remaining.toLocaleString(), color: s.remaining > 0 ? '#f59e0b' : '#22c55e' },
+ { label: 'ETA (@ 900/hr)', value: s.eta_hours > 0 ? s.eta_hours + 'h' : 'Done', color: s.eta_hours > 0 ? '#06b6d4' : '#22c55e' }
+ ];
+ for (var ci = 0; ci < cards.length; ci++) {
+ var card = document.createElement('div');
+ card.style.cssText = 'background:#1e2140;border:1px solid #2a2d45;border-radius:8px;padding:10px 14px;text-align:center;';
+ var valSpan = document.createElement('div');
+ valSpan.style.cssText = 'font-size:22px;font-weight:700;color:' + cards[ci].color + ';';
+ valSpan.textContent = cards[ci].value;
+ var lblSpan = document.createElement('div');
+ lblSpan.style.cssText = 'font-size:11px;color:#8888a8;text-transform:uppercase;letter-spacing:0.5px;margin-top:2px;';
+ lblSpan.textContent = cards[ci].label;
+ card.appendChild(valSpan);
+ card.appendChild(lblSpan);
+ summaryEl.appendChild(card);
+ }
+
+ // Overall progress bar
+ document.getElementById('colorEnrichPctLabel').textContent = s.pct + '%';
+ document.getElementById('colorEnrichBar').style.width = s.pct + '%';
+ if (s.eta_hours > 0) {
+ document.getElementById('colorEnrichEta').textContent = s.remaining.toLocaleString() + ' products remaining. ETA: ~' + s.eta_hours + ' hours at 900 products/hour.';
+ } else {
+ document.getElementById('colorEnrichEta').textContent = 'All active products with images have been enriched.';
+ }
+
+ // Vendor table
+ var tbody = document.getElementById('colorEnrichTableBody');
+ tbody.replaceChildren();
+ var vendors = data.vendors;
+ for (var vi = 0; vi < vendors.length; vi++) {
+ var v = vendors[vi];
+ var tr = document.createElement('tr');
+ tr.style.cssText = 'border-bottom:1px solid #1e2140;' + (vi % 2 === 0 ? 'background:#0f0f1a;' : 'background:#111120;');
+
+ // Vendor name
+ var tdName = document.createElement('td');
+ tdName.style.cssText = 'padding:6px 12px;color:#e8e8f0;font-size:12px;font-weight:500;white-space:nowrap;';
+ tdName.textContent = v.vendor;
+ tr.appendChild(tdName);
+
+ // Total
+ var tdTotal = document.createElement('td');
+ tdTotal.style.cssText = 'padding:6px 12px;text-align:right;color:#e8e8f0;font-size:12px;font-family:monospace;';
+ tdTotal.textContent = v.total.toLocaleString();
+ tr.appendChild(tdTotal);
+
+ // Enriched
+ var tdEnr = document.createElement('td');
+ tdEnr.style.cssText = 'padding:6px 12px;text-align:right;font-size:12px;font-family:monospace;color:' + (v.enriched === v.total ? '#22c55e' : '#e8e8f0') + ';';
+ tdEnr.textContent = v.enriched.toLocaleString();
+ tr.appendChild(tdEnr);
+
+ // Remaining
+ var tdRem = document.createElement('td');
+ tdRem.style.cssText = 'padding:6px 12px;text-align:right;font-size:12px;font-family:monospace;color:' + (v.remaining > 0 ? '#f59e0b' : '#22c55e') + ';';
+ tdRem.textContent = v.remaining.toLocaleString();
+ tr.appendChild(tdRem);
+
+ // Progress bar
+ var tdProg = document.createElement('td');
+ tdProg.style.cssText = 'padding:6px 12px;';
+ var barWrap = document.createElement('div');
+ barWrap.style.cssText = 'display:flex;align-items:center;gap:8px;';
+ var barOuter = document.createElement('div');
+ barOuter.style.cssText = 'flex:1;background:#1e2140;border-radius:4px;height:12px;overflow:hidden;';
+ var barInner = document.createElement('div');
+ var barColor = v.pct >= 100 ? '#22c55e' : v.pct >= 50 ? '#7c3aed' : '#f59e0b';
+ barInner.style.cssText = 'height:100%;border-radius:4px;background:' + barColor + ';width:' + v.pct + '%;transition:width 0.3s;';
+ barOuter.appendChild(barInner);
+ var pctLabel = document.createElement('span');
+ pctLabel.style.cssText = 'font-size:11px;font-weight:600;color:' + barColor + ';min-width:40px;text-align:right;';
+ pctLabel.textContent = v.pct + '%';
+ barWrap.appendChild(barOuter);
+ barWrap.appendChild(pctLabel);
+ tdProg.appendChild(barWrap);
+ tr.appendChild(tdProg);
+
+ tbody.appendChild(tr);
+ }
+ }).catch(function(e) {
+ document.getElementById('colorEnrichLoading').textContent = 'Error loading: ' + e.message;
+ document.getElementById('colorEnrichLoading').style.color = '#ef4444';
+ });
+}
+
+// Load badge count on page load (even when panel is collapsed)
+apiCall('/api/enrichment/color-progress').then(function(data) {
+ if (data.success) {
+ document.getElementById('colorEnrichBadge').textContent = '(' + data.summary.pct + '% complete)';
+ }
+}).catch(function(){});
+</script>
+
+<!-- Live Activity Ticker Bar -->
+<div class="live-ticker">
+ <div class="ticker-pulse"></div>
+ <span class="ticker-label">LIVE</span>
+ <div class="ticker-scroll">
+ <div class="ticker-entries" id="tickerEntries">
+ <span class="ticker-entry"><span class="ticker-time">--:--:--</span> <span class="ticker-type info">[INFO]</span> Loading activity feed...</span>
+ </div>
+ </div>
+ <span class="ticker-count" id="tickerCount">--</span>
+</div>
+</body>
+</html>`;
+}
+
+// --- Enrichment Progress API Endpoints ---
+
+// GET /api/enrichment/progress — Per-vendor phase completion percentages
+app.get('/api/enrichment/progress', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT et.vendor_code as vendor, COUNT(*) as total,
+ COUNT(et.phase2_specs_at) as specs, COUNT(et.phase3_ai_at) as ai,
+ COUNT(et.phase6_rooms_at) as rooms, COUNT(et.phase8_shopify_at) as shopify,
+ COUNT(*) - COUNT(et.phase3_ai_at) as gap
+ FROM enrichment_tracking et
+ JOIN vendor_registry vr ON vr.vendor_code = et.vendor_code
+ WHERE COALESCE(vr.skip_shopify, false) = false
+ GROUP BY et.vendor_code ORDER BY total DESC
+ `);
+ res.json(result.rows.map(r => ({
+ vendor: r.vendor,
+ total: parseInt(r.total),
+ specs: parseInt(r.specs),
+ ai: parseInt(r.ai),
+ rooms: parseInt(r.rooms),
+ shopify: parseInt(r.shopify),
+ gap: parseInt(r.gap)
+ })));
+ } catch (err) {
+ console.error('[Victor] enrichment/progress error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// GET /api/enrichment/progress/:vendor — Detailed breakdown for one vendor
+app.get('/api/enrichment/progress/:vendor', async (req, res) => {
+ try {
+ const { vendor } = req.params;
+ const result = await pool.query(`
+ SELECT COUNT(*) as total,
+ COUNT(phase1_scraped_at) as phase1,
+ COUNT(phase2_specs_at) as phase2,
+ COUNT(phase3_ai_at) as phase3,
+ COUNT(phase4_silas_at) as phase4,
+ COUNT(phase5_vcc_at) as phase5,
+ COUNT(phase6_rooms_at) as phase6,
+ COUNT(phase7_spin_at) as phase7,
+ COUNT(phase8_shopify_at) as phase8
+ FROM enrichment_tracking
+ WHERE vendor_code = $1
+ `, [vendor]);
+ const r = result.rows[0];
+ res.json({
+ vendor: vendor,
+ total: parseInt(r.total),
+ phase1: parseInt(r.phase1),
+ phase2: parseInt(r.phase2),
+ phase3: parseInt(r.phase3),
+ phase4: parseInt(r.phase4),
+ phase5: parseInt(r.phase5),
+ phase6: parseInt(r.phase6),
+ phase7: parseInt(r.phase7),
+ phase8: parseInt(r.phase8)
+ });
+ } catch (err) {
+ console.error('[Victor] enrichment/progress/:vendor error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// GET /api/enrichment/cost — Gemini cost estimate for pending phases
+app.get('/api/enrichment/cost', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) FILTER (WHERE phase3_ai_at IS NULL) as phase3_pending,
+ COUNT(*) FILTER (WHERE phase6_rooms_at IS NULL) as phase6_pending
+ FROM enrichment_tracking
+ `);
+ const r = result.rows[0];
+ const p3 = parseInt(r.phase3_pending);
+ const p6 = parseInt(r.phase6_pending);
+ // Phase 3 (AI tags): ~$0.0006/product (Gemini Flash, ~600 tokens avg)
+ const p3Cost = Math.round(p3 * 0.0006);
+ // Phase 6 (Room renders): ~$0.024/product (Gemini image generation)
+ const p6Cost = Math.round(p6 * 0.024);
+ const totalCost = p3Cost + p6Cost;
+ res.json({
+ phase3_pending: p3,
+ phase3_cost: '$' + p3Cost,
+ phase6_pending: p6,
+ phase6_cost: '$' + p6Cost,
+ total_estimate: '$' + totalCost
+ });
+ } catch (err) {
+ console.error('[Victor] enrichment/cost error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// GET /api/enrichment/global — System-wide enrichment totals
+app.get('/api/enrichment/global', async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) as total,
+ COUNT(phase1_scraped_at) as phase1,
+ COUNT(phase2_specs_at) as phase2,
+ COUNT(phase3_ai_at) as phase3,
+ COUNT(phase4_silas_at) as phase4,
+ COUNT(phase5_vcc_at) as phase5,
+ COUNT(phase6_rooms_at) as phase6,
+ COUNT(phase7_spin_at) as phase7,
+ COUNT(phase8_shopify_at) as phase8,
+ COUNT(DISTINCT vendor_code) as vendors,
+ COUNT(*) - COUNT(phase3_ai_at) as ai_gap,
+ COUNT(*) - COUNT(phase8_shopify_at) as shopify_gap
+ FROM enrichment_tracking
+ `);
+ const r = result.rows[0];
+ res.json({
+ total: parseInt(r.total),
+ vendors: parseInt(r.vendors),
+ phase1: parseInt(r.phase1),
+ phase2: parseInt(r.phase2),
+ phase3: parseInt(r.phase3),
+ phase4: parseInt(r.phase4),
+ phase5: parseInt(r.phase5),
+ phase6: parseInt(r.phase6),
+ phase7: parseInt(r.phase7),
+ phase8: parseInt(r.phase8),
+ ai_gap: parseInt(r.ai_gap),
+ shopify_gap: parseInt(r.shopify_gap)
+ });
+ } catch (err) {
+ console.error('[Victor] enrichment/global error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── NEW: Vendor Registry v2 Info ─────────────────────────────────────────
+// GET /api/registry/overview — System-wide agent registry stats
+app.get('/api/registry/overview', async (req, res) => {
+ try {
+ const vendorCount = await pool.query('SELECT COUNT(*) FROM vendor_registry');
+ const v2Count = await pool.query("SELECT COUNT(*) FROM vendor_registry WHERE agent_port IS NOT NULL");
+ const restrictions = {
+ never_touch: ['cowtan_tout', 'colefax_fowler'],
+ no_ai_tags: ['phillip_jeffries'],
+ no_new_products: ['york_wallcoverings'],
+ fabrics_only_skip: ['scalamandre'],
+ private_label: ['bespoke', 'phillipe_romano', 'architectural_wallcoverings'],
+ };
+ const killed = [
+ 'CEO Dashboard (7120)', 'CFO Dashboard (7121)', 'COO Dashboard (7122)',
+ 'CTO Dashboard (7124)', 'VP Ops Dashboard (7125)', 'Boardroom (9815)',
+ 'Boardroom 3D (7681)', 'Web Uptime (9813)', 'Server Agent (9814)',
+ 'Pierce Guardian (9630)', 'Legal Compliance (9878)', 'Accounting (9882)',
+ 'Purchasing (9880)', 'Sample Vault (9879)', 'Trend Research (9883)',
+ 'Competitive Intel (9886)', '38 vendor v1 agents',
+ ];
+ res.json({
+ total_vendors: parseInt(vendorCount.rows[0].count),
+ v2_agents_deployed: parseInt(v2Count.rows[0].count),
+ vendor_restrictions: restrictions,
+ killed_agents: killed,
+ architecture: 'YOLO + Full Monty core. Central Shopify API queue. All v1 agents killed session #111b.',
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── NEW: Live Enrichment Dashboard ──────────────────────────────────────
+// GET /api/enrichment/live — Active enrichment processes + recent completions
+app.get('/api/enrichment/live', async (req, res) => {
+ try {
+ // Recent Phase 3 completions (last hour)
+ const recent = await pool.query(`
+ SELECT vendor_code, COUNT(*) as completed,
+ MAX(phase3_ai_at) as last_enriched,
+ AVG(phase3_tokens) as avg_tokens
+ FROM enrichment_tracking
+ WHERE phase3_ai_at > NOW() - INTERVAL '1 hour'
+ GROUP BY vendor_code ORDER BY completed DESC
+ `);
+
+ // Top 20 vendors by gap (most work remaining)
+ const gaps = await pool.query(`
+ SELECT vendor_code, COUNT(*) as total,
+ COUNT(phase3_ai_at) as done,
+ COUNT(*) - COUNT(phase3_ai_at) as remaining,
+ ROUND(COUNT(phase3_ai_at)::numeric / NULLIF(COUNT(*), 0) * 100, 1) as pct
+ FROM enrichment_tracking
+ GROUP BY vendor_code
+ HAVING COUNT(*) - COUNT(phase3_ai_at) > 0
+ ORDER BY COUNT(*) - COUNT(phase3_ai_at) DESC
+ LIMIT 20
+ `);
+
+ // Global totals
+ const global = await pool.query(`
+ SELECT COUNT(*) as total,
+ COUNT(phase3_ai_at) as enriched,
+ COUNT(*) - COUNT(phase3_ai_at) as remaining,
+ ROUND(COUNT(phase3_ai_at)::numeric / NULLIF(COUNT(*), 0) * 100, 1) as pct
+ FROM enrichment_tracking
+ `);
+
+ // Phase breakdown (all 8 phases)
+ const phases = await pool.query(`
+ SELECT
+ COUNT(phase1_scraped_at) as p1_scraped,
+ COUNT(phase2_specs_at) as p2_specs,
+ COUNT(phase3_ai_at) as p3_ai,
+ COUNT(phase4_silas_at) as p4_silas,
+ COUNT(phase5_vcc_at) as p5_vcc,
+ COUNT(phase6_rooms_at) as p6_rooms,
+ COUNT(phase7_spin_at) as p7_spin,
+ COUNT(phase8_shopify_at) as p8_shopify
+ FROM enrichment_tracking
+ `);
+
+ res.json({
+ global: {
+ total: parseInt(global.rows[0].total),
+ enriched: parseInt(global.rows[0].enriched),
+ remaining: parseInt(global.rows[0].remaining),
+ pct: parseFloat(global.rows[0].pct),
+ },
+ phases: {
+ p1_scraped: parseInt(phases.rows[0].p1_scraped),
+ p2_specs: parseInt(phases.rows[0].p2_specs),
+ p3_ai: parseInt(phases.rows[0].p3_ai),
+ p4_silas: parseInt(phases.rows[0].p4_silas),
+ p5_vcc: parseInt(phases.rows[0].p5_vcc),
+ p6_rooms: parseInt(phases.rows[0].p6_rooms),
+ p7_spin: parseInt(phases.rows[0].p7_spin),
+ p8_shopify: parseInt(phases.rows[0].p8_shopify),
+ },
+ recent_activity: recent.rows.map(r => ({
+ vendor: r.vendor_code,
+ completed_last_hour: parseInt(r.completed),
+ last_enriched: r.last_enriched,
+ avg_tokens: Math.round(parseFloat(r.avg_tokens || 0)),
+ })),
+ top_gaps: gaps.rows.map(r => ({
+ vendor: r.vendor_code,
+ total: parseInt(r.total),
+ done: parseInt(r.done),
+ remaining: parseInt(r.remaining),
+ pct: parseFloat(r.pct),
+ })),
+ });
+ } catch (err) {
+ console.error('[Victor] enrichment/live error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── NEW: Cost Tracking Panel ────────────────────────────────────────────
+// GET /api/costs — Gemini cost tracker state + Claude session cost log
+app.get('/api/costs', async (req, res) => {
+ try {
+ const fs = require('fs');
+ // Gemini costs
+ let gemini = { totalCost: 0, totalCalls: 0, alertsSent: 0, startDate: null };
+ try {
+ gemini = JSON.parse(fs.readFileSync('/root/DW-Agents/logs/gemini-cost-state.json', 'utf8'));
+ } catch (e) { /* no state yet */ }
+
+ // Recent Gemini log lines
+ let recentGemini = [];
+ try {
+ const lines = fs.readFileSync('/root/DW-Agents/logs/gemini-costs.log', 'utf8').trim().split('\n');
+ recentGemini = lines.slice(-20).reverse().map(line => {
+ const parts = line.split(' | ');
+ return { time: parts[0], model: parts[1], tokens: parts[2], cost: parts[3], total: parts[4], task: parts[5] };
+ });
+ } catch (e) { /* no log yet */ }
+
+ // Claude session costs (if log exists)
+ let recentClaude = [];
+ try {
+ const lines = fs.readFileSync('/root/DW-Agents/logs/ai-costs.log', 'utf8').trim().split('\n');
+ recentClaude = lines.slice(-10).reverse();
+ } catch (e) { /* no log yet */ }
+
+ // Cost projections
+ const pendingResult = await pool.query(`
+ SELECT
+ COUNT(*) FILTER (WHERE phase3_ai_at IS NULL) as phase3_pending,
+ COUNT(*) FILTER (WHERE phase6_rooms_at IS NULL) as phase6_pending
+ FROM enrichment_tracking
+ `);
+ const p3Pending = parseInt(pendingResult.rows[0].phase3_pending);
+ const p6Pending = parseInt(pendingResult.rows[0].phase6_pending);
+
+ res.json({
+ gemini: {
+ total_cost: gemini.totalCost,
+ total_calls: gemini.totalCalls,
+ alerts_sent: gemini.alertsSent,
+ alert_threshold: 50,
+ next_alert_at: (Math.floor(gemini.totalCost / 50) + 1) * 50,
+ tracking_since: gemini.startDate,
+ },
+ projections: {
+ phase3_pending: p3Pending,
+ phase3_estimated_cost: Math.round(p3Pending * 0.0004 * 100) / 100,
+ phase6_pending: p6Pending,
+ phase6_estimated_cost: Math.round(p6Pending * 0.85 * 0.012 * 100) / 100, // 3 rooms, ~85% need rooms (15% already have MFR images)
+ total_estimated: Math.round((p3Pending * 0.0004 + p6Pending * 0.85 * 0.012) * 100) / 100, // 3 rooms, 85% eligible
+ },
+ recent_gemini_calls: recentGemini,
+ recent_claude_sessions: recentClaude,
+ });
+ } catch (err) {
+ console.error('[Victor] costs error:', err.message);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ─── NEW: Vendor Restrictions ────────────────────────────────────────────
+// GET /api/restrictions — Vendor-specific rules and exclusions
+app.get('/api/restrictions', (req, res) => {
+ res.json({
+ never_touch: [
+ { vendor: 'cowtan_tout', rule: 'NEVER. No scraping, no push, no enrichment, no AI tags, no images.' },
+ { vendor: 'colefax_fowler', rule: 'NEVER. Same as Cowtan (Cowtan company).' },
+ ],
+ no_ai_tags: [
+ { vendor: 'phillip_jeffries', rule: 'NO AI TAGS. Browse-hidden, search-only visibility.' },
+ ],
+ private_label: [
+ { vendor: 'bespoke', rule: 'DW own brand. Uses DIG- SKUs, not DWWC-. Special handling.' },
+ { vendor: 'phillipe_romano', rule: 'Private label. Never expose real MFR in public content.' },
+ { vendor: 'architectural_wallcoverings', rule: 'Private label. DW brand.' },
+ ],
+ special_handling: [
+ { vendor: 'scalamandre', rule: 'Fabrics are NOT wallcoverings. Only wallcovering SKUs valid.' },
+ { vendor: 'york_wallcoverings', rule: '4,540 images in DB but no products on Shopify. No-new-products directive.' },
+ ],
+ });
+});
+
+// --- Vendor Trade Portal Credentials ---
+
+// GET /api/vendor-credentials — Return all vendors with has_credentials=true
+app.get('/api/vendor-credentials', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT vendor_name, has_credentials, login_url, website_url, trade_username, trade_password
+ FROM vendor_registry
+ WHERE has_credentials = true
+ ORDER BY vendor_name
+ `);
+ res.json({ ok: true, vendors: rows });
+ } catch (err) {
+ console.error('[Victor] vendor-credentials GET error:', err.message);
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
+// POST /api/vendor-credentials — Save username/password for a vendor
+app.post('/api/vendor-credentials', async (req, res) => {
+ try {
+ const { vendor_name, trade_username, trade_password } = req.body;
+ if (!vendor_name) return res.json({ ok: false, error: 'vendor_name required' });
+ await pool.query(`
+ UPDATE vendor_registry SET trade_username = $1, trade_password = $2
+ WHERE vendor_name = $3
+ `, [trade_username || null, trade_password || null, vendor_name]);
+ res.json({ ok: true, vendor: vendor_name });
+ } catch (err) {
+ console.error('[Victor] vendor-credentials POST error:', err.message);
+ res.status(500).json({ ok: false, error: err.message });
+ }
+});
+
+// GET /api/enrichment/color-progress — Color enrichment progress (shopify_color_enrichment vs shopify_products)
+app.get('/api/enrichment/color-progress', async (req, res) => {
+ try {
+ // Overall summary
+ const summaryResult = await pool.query(`
+ SELECT
+ COUNT(DISTINCT sp.vendor) as total_vendors,
+ COUNT(*) as total_active,
+ COUNT(*) FILTER (WHERE sce.status = 'enriched') as enriched,
+ COUNT(*) - COUNT(*) FILTER (WHERE sce.status = 'enriched') as remaining,
+ ROUND(100.0 * COUNT(*) FILTER (WHERE sce.status = 'enriched') / NULLIF(COUNT(*), 0), 1) as pct
+ FROM shopify_products sp
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE UPPER(sp.status) = 'ACTIVE' AND sp.image_url IS NOT NULL
+ `);
+ const summary = summaryResult.rows[0];
+
+ // Per-vendor breakdown
+ const vendorResult = await pool.query(`
+ SELECT sp.vendor, COUNT(*) as total,
+ COUNT(*) FILTER (WHERE sce.status = 'enriched') as enriched
+ FROM shopify_products sp
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE UPPER(sp.status) = 'ACTIVE' AND sp.image_url IS NOT NULL
+ GROUP BY sp.vendor ORDER BY total DESC
+ `);
+
+ const vendors = vendorResult.rows.map(r => ({
+ vendor: r.vendor,
+ total: parseInt(r.total),
+ enriched: parseInt(r.enriched),
+ remaining: parseInt(r.total) - parseInt(r.enriched),
+ pct: r.total > 0 ? Math.round(1000 * parseInt(r.enriched) / parseInt(r.total)) / 10 : 0
+ }));
+
+ const totalActive = parseInt(summary.total_active) || 0;
+ const enriched = parseInt(summary.enriched) || 0;
+ const remaining = parseInt(summary.remaining) || 0;
+
+ res.json({
+ success: true,
+ summary: {
+ total_vendors: parseInt(summary.total_vendors) || 0,
+ total_active: totalActive,
+ enriched: enriched,
+ remaining: remaining,
+ pct: parseFloat(summary.pct) || 0,
+ eta_hours: remaining > 0 ? Math.round(10 * remaining / 900) / 10 : 0
+ },
+ vendors: vendors,
+ ts: new Date().toISOString()
+ });
+ } catch (err) {
+ console.error('[Victor] enrichment/color-progress error:', err.message);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+// --- Start server ---
+app.listen(PORT, '0.0.0.0', function() {
+ console.log('[Victor] Vendor Command Center running on port ' + PORT);
+ console.log('Dashboard: http://45.61.58.125:' + PORT + '/');
+ console.log('Health: http://45.61.58.125:' + PORT + '/health');
+});
← 08e84a2c Fix vendor_registry total_products rollup: filter shared cat
·
back to Designer Wallcoverings
·
VCC: fix DB fallback connection string (dw_admin@127.0.0.1, 9ca4e85a →