← back to Designer Wallcoverings

DW-Agents/dw-agents/agent-thibaut-updater/thibaut-updater-agent.ts

1879 lines

/**
 * DW-Agents: Thibaut Updater Agent
 *
 * Web UI for updating Thibaut (DWTT) products with:
 * 1. Batch size options (1, 5, 10, 20, 100, 250, 500, 1000, all)
 * 2. Real-time progress with product thumbnails
 * 3. AI-powered color/style analysis (Gemini 3.0)
 * 4. Interior designer tagging
 * 5. Shopify metafield updates
 *
 * Display Title: Pattern Color | Thibaut Wallcoverings
 * SEO Title: Pattern Color (T#####) | Architectural Wallcoverings
 *
 * Port: 9902
 */

import express from 'express';
import { requireGlobalAuth } from '../shared-global-auth';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import pg from 'pg';
const { Pool } = pg;

// PostgreSQL connection for tracking updates
const pool = new Pool({
  connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified')
});

// Current batch ID for tracking
let currentBatchId: string | null = null;

// ES Module __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Load environment variables
dotenv.config({ path: '/root/Projects/Designer-Wallcoverings/.env' });
dotenv.config();

const app = express();
const PORT = 9902;

// Gemini 3.0 Configuration (MANDATORY - per CLAUDE.md)
const GEMINI_API_KEY = '${GOOGLE_API_KEY}';
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent';

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(session({
  secret: 'thibaut-updater-secret-2024',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }
}));

// Apply global auth
app.use(requireGlobalAuth);

// Shopify configuration
const SHOPIFY_CONFIG = {
  shop: 'designer-laboratory-sandbox.myshopify.com',
  accessToken: process.env.SHOPIFY_ACCESS_TOKEN || '',
  apiVersion: '2024-01'
};

// Update specifications
const UPDATE_SPECS = {
  displaySuffix: 'Thibaut Wallcoverings',
  seoSuffix: 'Architectural Wallcoverings',
  samplePrice: 4.25,
  category: 'Building Materials',
  productType: 'Architectural Wallcoverings'
};

// Interior Design Vocabulary (from ThibautTagger)
const INTERIOR_DESIGN_VOCABULARY = {
  patterns: ['Geometric', 'Floral', 'Stripe', 'Damask', 'Toile', 'Ikat', 'Chevron', 'Trellis', 'Lattice', 'Abstract', 'Botanical', 'Chinoiserie', 'Arabesque', 'Ogee', 'Medallion', 'Paisley', 'Plaid', 'Gingham', 'Houndstooth', 'Herringbone', 'Greek Key', 'Quatrefoil', 'Fretwork', 'Scroll', 'Acanthus', 'Palmette', 'Diamond', 'Hexagon', 'Solid', 'Textured'],
  styles: ['Modern', 'Traditional', 'Contemporary', 'Transitional', 'Mid-Century Modern', 'Art Deco', 'Bohemian', 'Coastal', 'Farmhouse', 'Industrial', 'Minimalist', 'Maximalist', 'Scandinavian', 'Mediterranean', 'Asian', 'Colonial', 'Victorian', 'French Country', 'Eclectic', 'Hollywood Regency', 'Preppy', 'Classic', 'Timeless', 'Elegant', 'Sophisticated'],
  colorFamilies: ['Neutral', 'Earth Tones', 'Warm', 'Cool', 'Bright', 'Muted', 'Pastel', 'Jewel Tones', 'Monochromatic', 'Multi-Color'],
  materials: ['Vinyl', 'Non-Woven', 'Paper', 'Fabric-Backed', 'Grasscloth', 'Sisal', 'Cork', 'Foil', 'Flocked', 'Embossed'],
  finishes: ['Matte', 'Satin', 'Metallic', 'Pearl', 'Glitter', 'Textured', 'Smooth'],
  rooms: ['Living Room', 'Bedroom', 'Dining Room', 'Bathroom', 'Office', 'Entryway', 'Hallway', 'Nursery', 'Kitchen']
};

// Process state
interface ProcessState {
  running: boolean;
  paused: boolean;
  batchSize: number;
  currentIndex: number;
  totalProducts: number;
  processed: number;
  errors: Array<{ id: string; error: string }>;
  products: Array<{ id: string; title: string; image: string | null; handle?: string; tags?: string; variants?: any[]; images?: any[] }>;
  startTime: Date | null;
  logs: string[];
  productUpdates: Map<string, {
    status: 'pending' | 'updating' | 'completed' | 'error';
    oldTitle: string;
    newTitle?: string;
    sku?: string;
    timestamp?: Date;
    error?: string;
    pattern?: string;
    color?: string;
    mfrSku?: string;
    tags?: string[];
    colorAnalysis?: any;
  }>;
}

let processState: ProcessState = {
  running: false,
  paused: false,
  batchSize: 10,
  currentIndex: 0,
  totalProducts: 0,
  processed: 0,
  errors: [],
  products: [],
  startTime: null,
  logs: [],
  productUpdates: new Map()
};

// Process history
const historyFile = path.join(__dirname, 'data', 'thibaut-update-history.json');
const updatedSkusFile = path.join(__dirname, 'data', 'thibaut-updated-skus.json');

function loadHistory(): Array<any> {
  try {
    if (fs.existsSync(historyFile)) {
      return JSON.parse(fs.readFileSync(historyFile, 'utf8'));
    }
  } catch (e) {}
  return [];
}

function saveHistoryEntry(entry: any): void {
  const dir = path.dirname(historyFile);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  const history = loadHistory();
  history.unshift(entry);
  if (history.length > 50) history.pop();
  fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
}

interface UpdatedSkuRecord {
  sku: string;
  productId: string;
  title: string;
  pattern?: string;
  color?: string;
  mfrSku?: string;
  updatedAt: string;
}

function loadUpdatedSkus(): Map<string, UpdatedSkuRecord> {
  try {
    if (fs.existsSync(updatedSkusFile)) {
      const data = JSON.parse(fs.readFileSync(updatedSkusFile, 'utf8'));
      return new Map(Object.entries(data));
    }
  } catch (e) {}
  return new Map();
}

function saveUpdatedSku(record: UpdatedSkuRecord): void {
  const dir = path.dirname(updatedSkusFile);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  const skus = loadUpdatedSkus();
  skus.set(record.sku, record);
  const obj: Record<string, UpdatedSkuRecord> = {};
  skus.forEach((v, k) => { obj[k] = v; });
  fs.writeFileSync(updatedSkusFile, JSON.stringify(obj, null, 2));
}

// Save update to PostgreSQL
async function saveToPostgres(productId: string, shopifySku: string, mfrSku: string, oldTitle: string, newTitle: string, pattern: string, color: string, status: string): Promise<void> {
  try {
    await pool.query(`
      INSERT INTO thibaut_product_updates
        (product_id, shopify_sku, manufacturer_sku, old_title, new_title, pattern, color, status, updated_at)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
      ON CONFLICT (id) DO NOTHING
    `, [productId, shopifySku, mfrSku, oldTitle, newTitle, pattern, color, status]);
  } catch (err: any) {
    console.error('PostgreSQL save error:', err.message);
  }
}

function isSkuPreviouslyUpdated(sku: string): UpdatedSkuRecord | null {
  const skus = loadUpdatedSkus();
  return skus.get(sku) || null;
}

// Activity log
function logActivity(type: 'update' | 'error' | 'info' | 'complete', message: string) {
  processState.logs.push(`[${new Date().toLocaleTimeString()}] ${message}`);
  if (processState.logs.length > 50) processState.logs.shift();
  console.log(`[${type.toUpperCase()}] ${message}`);
}

/**
 * Convert string to Title Case
 * "hello world" -> "Hello World"
 * Handles ALL CAPS, hyphenated words, and special cases
 */
function toTitleCase(str: string): string {
  if (!str) return '';

  // List of small words that should remain lowercase (unless first/last)
  const smallWords = ['a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor', 'on', 'at', 'to', 'by', 'of', 'in'];

  return str
    .toLowerCase()
    .split(/\s+/)
    .map((word, index, array) => {
      // Always capitalize first and last words
      if (index === 0 || index === array.length - 1) {
        return word.charAt(0).toUpperCase() + word.slice(1);
      }
      // Keep small words lowercase
      if (smallWords.includes(word)) {
        return word;
      }
      // Capitalize other words
      return word.charAt(0).toUpperCase() + word.slice(1);
    })
    .join(' ');
}

/**
 * Extract T-series SKU from product data
 * Format: T##### (e.g., T13172, T10600)
 * Priority: manufacturerSku metafield > title > handle > tags > variants
 */
function extractThibautSku(product: any): string | null {
  // FIRST: Use pre-loaded manufacturer_sku if available
  if (product.manufacturerSku) {
    return product.manufacturerSku.toUpperCase();
  }

  const handle = product.handle || '';
  const title = product.title || '';
  const tags = product.tags || '';

  // Pattern for Thibaut SKUs: T followed by 4-5 digits
  const skuPattern = /\b(T\d{4,5})\b/i;

  // Check title first (usually has the SKU in parentheses)
  const titleMatch = title.match(/\((T\d{4,5})\)/i);
  if (titleMatch) return titleMatch[1].toUpperCase();

  // Check handle
  const handleMatch = handle.match(skuPattern);
  if (handleMatch) return handleMatch[1].toUpperCase();

  // Check tags
  const tagsMatch = tags.match(skuPattern);
  if (tagsMatch) return tagsMatch[1].toUpperCase();

  // Check variant SKUs
  if (product.variants?.length > 0) {
    for (const variant of product.variants) {
      if (variant.sku) {
        const variantMatch = variant.sku.match(skuPattern);
        if (variantMatch) return variantMatch[1].toUpperCase();
      }
    }
  }

  return null;
}

/**
 * Parse product title/handle to extract pattern and color
 * Priority: Handle (more reliable) > Title
 */
function parseProductForPatternAndColor(product: any): { pattern: string | null; color: string | null } {
  const title = product.title || '';
  const handle = product.handle || '';

  // FIRST: Try to extract from handle (more reliable for Thibaut)
  // Handle format: "travelers-palm-wallpaper-navy-and-white" or "pattern-name-color-wallpaper"
  if (handle) {
    // Remove common suffixes from handle
    let cleanHandle = handle
      .replace(/-wallpaper$/i, '')
      .replace(/-wallcovering$/i, '')
      .replace(/-sample$/i, '')
      .replace(/-t\d{4,5}$/i, '');  // Remove SKU suffix if present

    // Split by -wallpaper- or just - to find pattern and color sections
    const wallpaperSplit = cleanHandle.split(/-wallpaper-/i);
    if (wallpaperSplit.length === 2) {
      // Pattern is before -wallpaper-, color is after
      const patternPart = wallpaperSplit[0];
      const colorPart = wallpaperSplit[1];

      const pattern = patternPart.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
      const color = colorPart.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');

      if (pattern && pattern.length > 1 && !pattern.toLowerCase().includes('wallcovering')) {
        return { pattern, color: color || null };
      }
    }

    // Fallback: Try to parse handle without -wallpaper- marker
    // Look for color keywords at the end
    const colorKeywords = ['white', 'black', 'blue', 'navy', 'red', 'green', 'grey', 'gray', 'beige', 'cream', 'gold', 'silver', 'pink', 'coral', 'teal', 'brown', 'tan', 'ivory', 'sage'];
    const parts = cleanHandle.split('-').filter(p => p.length > 0);

    if (parts.length >= 2) {
      // Find where color starts (look for color keywords from the end)
      let colorStartIndex = parts.length;
      for (let i = parts.length - 1; i >= Math.max(1, parts.length - 3); i--) {
        if (colorKeywords.some(c => parts[i].toLowerCase().includes(c))) {
          colorStartIndex = i;
          break;
        }
      }

      if (colorStartIndex < parts.length) {
        const patternParts = parts.slice(0, colorStartIndex);
        const colorParts = parts.slice(colorStartIndex);

        const pattern = patternParts.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
        const color = colorParts.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');

        if (pattern && pattern.length > 1) {
          return { pattern, color: color || null };
        }
      }
    }
  }

  // SECOND: Try to extract from title
  // Remove common suffixes
  let cleanTitle = title
    .replace(/\s*\|\s*.*$/i, '')           // Remove "| Vendor" suffix
    .replace(/\s*-\s*wallpaper$/i, '')     // Remove "- Wallpaper" suffix
    .replace(/\s*wallpaper$/i, '')         // Remove "Wallpaper" suffix
    .replace(/\s*wallcoverings?$/i, '')    // Remove "Wallcovering(s)" suffix
    .replace(/\s*\(T\d{4,5}\)/i, '')       // Remove (T#####) SKU
    .trim();

  // Skip if title is too generic
  const badPatterns = ['wallcoverings', 'wallcovering', 'wallpaper', 'thibaut', 'sample'];
  if (badPatterns.some(bp => cleanTitle.toLowerCase() === bp)) {
    // Title is useless, return nulls (will be handled by scraper/AI)
    return { pattern: null, color: null };
  }

  // Split into words
  const words = cleanTitle.split(/\s+/).filter(w => w.length > 0);

  if (words.length >= 2) {
    // Last word is usually the color
    const color = words[words.length - 1];
    const pattern = words.slice(0, -1).join(' ');

    // Validate pattern isn't garbage
    if (pattern && !badPatterns.includes(pattern.toLowerCase())) {
      return {
        pattern: pattern.charAt(0).toUpperCase() + pattern.slice(1),
        color: color.charAt(0).toUpperCase() + color.slice(1)
      };
    }
  } else if (words.length === 1 && !badPatterns.includes(words[0].toLowerCase())) {
    return { pattern: words[0], color: null };
  }

  return { pattern: null, color: null };
}

/**
 * Format display title: Pattern Color | Thibaut Wallcoverings
 * ALL TITLES IN TITLE CASE
 */
function formatDisplayTitle(pattern: string, color: string | null): string {
  const patternTitleCase = toTitleCase(pattern);
  const colorTitleCase = color ? toTitleCase(color) : null;
  const basePart = colorTitleCase ? `${patternTitleCase} ${colorTitleCase}`.trim() : patternTitleCase.trim();
  return `${basePart} | ${UPDATE_SPECS.displaySuffix}`;
}

/**
 * Format SEO title: Pattern Color (T#####) | Architectural Wallcoverings
 * ALL TITLES IN TITLE CASE
 */
function formatSeoTitle(pattern: string, color: string | null, mfrSku: string | null): string {
  const patternTitleCase = toTitleCase(pattern);
  const colorTitleCase = color ? toTitleCase(color) : null;
  const basePart = colorTitleCase ? `${patternTitleCase} ${colorTitleCase}`.trim() : patternTitleCase.trim();
  if (mfrSku) {
    return `${basePart} (${mfrSku}) | ${UPDATE_SPECS.seoSuffix}`;
  }
  return `${basePart} | ${UPDATE_SPECS.seoSuffix}`;
}

/**
 * Analyze product image with Gemini 3.0 (MANDATORY - per CLAUDE.md)
 */
async function analyzeImageWithGemini(imageUrl: string, productTitle: string): Promise<any> {
  try {
    // Fetch image and convert to base64
    const imgResponse = await fetch(imageUrl);
    const buffer = await imgResponse.arrayBuffer();
    const base64 = Buffer.from(buffer).toString('base64');
    const mimeType = imgResponse.headers.get('content-type') || 'image/jpeg';

    const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{
          parts: [
            { inlineData: { mimeType: mimeType.split(';')[0], data: base64 } },
            { text: `As an expert interior designer, analyze this wallcovering "${productTitle}".

Provide comprehensive tagging data for e-commerce. Return ONLY valid JSON:
{
  "backgroundColor": "the dominant background color (single color name)",
  "colorTags": ["list 3-5 main colors using designer names like Navy, Coral, Sage, Ivory"],
  "styleTags": ["1-2 from: Modern, Traditional, Contemporary, Art Deco, Coastal, Bohemian, Victorian, Transitional"],
  "patternTags": ["1-2 from: Geometric, Floral, Damask, Toile, Botanical, Abstract, Stripe, Trellis"],
  "roomTags": ["2-3 suitable rooms: Living Room, Bedroom, Dining Room, Office, Entryway"],
  "materialTags": ["Vinyl, Non-Woven, Paper, Grasscloth if identifiable"],
  "mood": ["2-3 mood keywords: Calm, Energetic, Sophisticated, Playful, Serene, Bold"],
  "scale": "Small Scale, Medium Scale, Large Scale, or Extra Large Scale",
  "hasMetallic": false,
  "hasFlorals": false,
  "hasAnimals": false,
  "description": "2-3 sentence professional interior designer description for commercial applications"
}` }
          ]
        }],
        generationConfig: { temperature: 0.1, maxOutputTokens: 800, thinkingConfig: { thinkingBudget: 0 } }
      })
    });

    const result = await response.json() as any;

    if (result.candidates?.[0]?.content?.parts?.[0]?.text) {
      let text = result.candidates[0].content.parts[0].text;
      // Remove markdown code blocks if present
      text = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
      return JSON.parse(text);
    }

    return null;
  } catch (e: any) {
    console.error('Gemini analysis error:', e.message);
    return null;
  }
}

/**
 * Build tags from AI analysis and product data
 */
function buildTags(colorAnalysis: any, mfrSku: string | null, pattern: string | null, color: string | null): string[] {
  const tags = new Set<string>();

  // Always add vendor tag
  tags.add('Thibaut Wallcoverings');
  tags.add('Thibaut');
  tags.add('DWTT');

  // Add MFR SKU
  if (mfrSku) tags.add(mfrSku);

  // Add pattern and color
  if (pattern) tags.add(pattern);
  if (color) tags.add(color);

  // Add AI-analyzed data
  if (colorAnalysis) {
    if (colorAnalysis.backgroundColor) {
      tags.add(`Background Color ${colorAnalysis.backgroundColor}`);
    }
    colorAnalysis.colorTags?.forEach((c: string) => tags.add(c));
    colorAnalysis.styleTags?.forEach((s: string) => tags.add(s));
    colorAnalysis.patternTags?.forEach((p: string) => tags.add(p));
    colorAnalysis.roomTags?.forEach((r: string) => tags.add(r));
    colorAnalysis.materialTags?.forEach((m: string) => tags.add(m));
    colorAnalysis.mood?.forEach((m: string) => tags.add(m));
    if (colorAnalysis.scale) tags.add(colorAnalysis.scale);
    if (colorAnalysis.hasMetallic) tags.add('Metallic');
    if (colorAnalysis.hasFlorals) tags.add('Floral');
    if (colorAnalysis.hasAnimals) tags.add('Animal');
  }

  return Array.from(tags);
}

/**
 * Scrape Thibaut website for product specifications
 */
async function scrapeThibautSpecs(mfrSku: string): Promise<any> {
  const url = `https://www.thibautdesign.com/products/wallcoverings/${mfrSku}`;

  try {
    const browser = await chromium.launch({ headless: true });
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await page.waitForTimeout(3000);

    // Extract specifications
    const specs = await page.evaluate((sku) => {
      const data: any = {
        patternName: null,
        colorName: null,
        collection: null,
        width: null,
        repeat: null,
        content: null,
        images: []
      };

      // PRIORITY 1: Extract from <title> tag - most reliable!
      // Format: "TRAVELERS PALM Wallcoverings (T10127) – Green | Thibaut"
      const pageTitle = document.querySelector('title')?.textContent?.trim();
      if (pageTitle) {
        // Parse: "PATTERN Wallcoverings (SKU) – COLOR | Thibaut"
        const titleMatch = pageTitle.match(/^(.+?)\s+Wallcoverings\s*\([^)]+\)\s*[–-]\s*(.+?)\s*\|\s*Thibaut$/i);
        if (titleMatch) {
          data.patternName = titleMatch[1]?.trim();
          data.colorName = titleMatch[2]?.trim();
        }
      }

      // PRIORITY 2: Extract from og:title meta tag
      if (!data.patternName || !data.colorName) {
        const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content');
        if (ogTitle) {
          const ogMatch = ogTitle.match(/^(.+?)\s+Wallcoverings\s*\([^)]+\)\s*[–-]\s*(.+?)\s*\|\s*Thibaut$/i);
          if (ogMatch) {
            if (!data.patternName) data.patternName = ogMatch[1]?.trim();
            if (!data.colorName) data.colorName = ogMatch[2]?.trim();
          }
        }
      }

      // PRIORITY 3: Extract from Colorway label span
      if (!data.colorName) {
        const colorwayLabel = document.querySelector('span.label');
        if (colorwayLabel?.textContent?.includes('Colorway')) {
          const colorwayValue = colorwayLabel.nextElementSibling?.textContent?.trim() ||
                               colorwayLabel.parentElement?.textContent?.replace('Colorway:', '').trim();
          if (colorwayValue) {
            data.colorName = colorwayValue;
          }
        }
      }

      // FALLBACK: Pattern/Color from h1 title
      if (!data.patternName || !data.colorName) {
        const h1Title = document.querySelector('h1')?.textContent?.trim();
        if (h1Title) {
          const parts = h1Title.split(/\s*-\s*/);
          if (!data.patternName) data.patternName = parts[0]?.trim();
          if (!data.colorName && parts[1]) data.colorName = parts[1]?.trim();
        }
      }

      // Specifications
      const specRows = document.querySelectorAll('.product-specs tr, .specifications tr, .details tr');
      specRows.forEach(row => {
        const label = row.querySelector('th, td:first-child')?.textContent?.toLowerCase().trim();
        const value = row.querySelector('td:last-child')?.textContent?.trim();
        if (label && value) {
          if (label.includes('width')) data.width = value;
          if (label.includes('repeat')) data.repeat = value;
          if (label.includes('collection')) data.collection = value;
          if (label.includes('content') || label.includes('material')) data.content = value;
        }
      });

      // Images
      const images = document.querySelectorAll('.product-images img, .gallery img, .carousel img');
      images.forEach(img => {
        const src = img.getAttribute('src') || img.getAttribute('data-src');
        if (src && !src.includes('placeholder')) {
          data.images.push(src.startsWith('//') ? 'https:' + src : src);
        }
      });

      return data;
    }, mfrSku);

    await browser.close();
    return specs;
  } catch (e: any) {
    console.error(`Scrape error for ${mfrSku}:`, e.message);
    return null;
  }
}

/**
 * Push metafields to Shopify via GraphQL
 * Populates ALL custom metafields with comprehensive product data
 */
async function pushMetafieldsToShopify(
  productId: string | number,
  mfrSku: string | null,
  pattern: string | null,
  color: string | null,
  seoTitle: string,
  colorAnalysis: any,
  description: string,
  thibautSpecs?: any  // Scraped specs from Thibaut website
): Promise<{ success: boolean; errors: string[] }> {
  const metafields: any[] = [];
  const ownerId = `gid://shopify/Product/${productId}`;

  // ============ MANUFACTURER / SKU FIELDS ============
  // Manufacturer SKU (CRITICAL - per CLAUDE.md)
  if (mfrSku) {
    metafields.push({
      key: 'manufacturer_sku',
      namespace: 'custom',
      ownerId,
      type: 'single_line_text_field',
      value: mfrSku
    });
    metafields.push({
      key: 'manufacturer_sku',
      namespace: 'dwc',
      ownerId,
      type: 'single_line_text_field',
      value: mfrSku
    });
    // Also add to global namespace for compatibility
    metafields.push({
      key: 'mfr-pattern-number',
      namespace: 'global',
      ownerId,
      type: 'single_line_text_field',
      value: mfrSku
    });
  }

  // ============ PATTERN / COLOR / BRAND FIELDS ============
  if (pattern) {
    metafields.push({
      key: 'pattern_name',
      namespace: 'dwc',
      ownerId,
      type: 'single_line_text_field',
      value: pattern
    });
    // Also add to global namespace
    metafields.push({
      key: 'title',
      namespace: 'global',
      ownerId,
      type: 'single_line_text_field',
      value: pattern
    });
  }

  if (color) {
    metafields.push({
      key: 'color',
      namespace: 'dwc',
      ownerId,
      type: 'single_line_text_field',
      value: color
    });
    metafields.push({
      key: 'Color-Way',
      namespace: 'global',
      ownerId,
      type: 'single_line_text_field',
      value: color
    });
  }

  // Brand fields
  metafields.push({
    key: 'Brand',
    namespace: 'global',
    ownerId,
    type: 'single_line_text_field',
    value: 'Thibaut'
  });
  metafields.push({
    key: 'real_vendor',
    namespace: 'dwc',
    ownerId,
    type: 'single_line_text_field',
    value: 'Thibaut Design'
  });

  // ============ SEO FIELDS ============
  metafields.push({
    key: 'title_tag',
    namespace: 'global',
    ownerId,
    type: 'single_line_text_field',
    value: seoTitle
  });

  // Description tag
  const descriptionTag = description || `${pattern || 'Thibaut'} ${color || ''} wallcovering from Thibaut Design. Premium quality for residential and commercial interiors.`.trim();
  metafields.push({
    key: 'description_tag',
    namespace: 'global',
    ownerId,
    type: 'single_line_text_field',
    value: descriptionTag.substring(0, 320)  // Max 320 chars for meta description
  });

  // ============ SPECIFICATIONS FROM THIBAUT SCRAPER ============
  if (thibautSpecs) {
    if (thibautSpecs.width) {
      metafields.push({
        key: 'width',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.width
      });
    }
    if (thibautSpecs.repeat) {
      metafields.push({
        key: 'repeat',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.repeat
      });
      metafields.push({
        key: 'Vert-Rpt',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.repeat
      });
    }
    if (thibautSpecs.collection) {
      metafields.push({
        key: 'Collection',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.collection
      });
      metafields.push({
        key: 'Book_Name',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.collection
      });
    }
    if (thibautSpecs.content) {
      metafields.push({
        key: 'Contents',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.content
      });
      metafields.push({
        key: 'Content',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: thibautSpecs.content
      });
    }
  }

  // ============ AI-GENERATED DATA ============
  if (colorAnalysis) {
    if (colorAnalysis.backgroundColor) {
      metafields.push({
        key: 'background_color',
        namespace: 'dwc',
        ownerId,
        type: 'single_line_text_field',
        value: colorAnalysis.backgroundColor
      });
      metafields.push({
        key: 'Color-of-Pattern',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: colorAnalysis.backgroundColor
      });
    }

    const aiColors = colorAnalysis.colorTags?.slice(0, 5) || [];
    if (aiColors.length > 0) {
      metafields.push({
        key: 'ai_generated_colors',
        namespace: 'dwc',
        ownerId,
        type: 'list.single_line_text_field',
        value: JSON.stringify(aiColors)
      });
      // Also set individual color fields
      if (aiColors[0]) {
        metafields.push({ key: 'Color-1', namespace: 'global', ownerId, type: 'single_line_text_field', value: aiColors[0] });
      }
      if (aiColors[1]) {
        metafields.push({ key: 'Color-2', namespace: 'global', ownerId, type: 'single_line_text_field', value: aiColors[1] });
      }
      if (aiColors[2]) {
        metafields.push({ key: 'Color-3', namespace: 'global', ownerId, type: 'single_line_text_field', value: aiColors[2] });
      }
    }

    // Style tags
    const styleTags = colorAnalysis.styleTags || [];
    if (styleTags.length > 0) {
      metafields.push({
        key: 'Style',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: styleTags[0]
      });
      if (styleTags[1]) {
        metafields.push({ key: 'Style1', namespace: 'global', ownerId, type: 'single_line_text_field', value: styleTags[1] });
      }
    }

    // Pattern type
    const patternTags = colorAnalysis.patternTags || [];
    if (patternTags.length > 0) {
      metafields.push({
        key: 'Type',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: patternTags[0]
      });
    }

    // Material
    const materialTags = colorAnalysis.materialTags || [];
    if (materialTags.length > 0) {
      metafields.push({
        key: 'Construction',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: materialTags[0]
      });
    }

    // Room suitability
    const roomTags = colorAnalysis.roomTags || [];
    if (roomTags.length > 0) {
      metafields.push({
        key: 'Usage',
        namespace: 'global',
        ownerId,
        type: 'single_line_text_field',
        value: roomTags.join(', ')
      });
    }

    // All AI tags combined
    const allAiTags = [
      ...(colorAnalysis.styleTags || []),
      ...(colorAnalysis.patternTags || []),
      ...(colorAnalysis.roomTags || []),
      ...(colorAnalysis.mood || [])
    ].slice(0, 15);
    if (allAiTags.length > 0) {
      metafields.push({
        key: 'ai_generated_tags',
        namespace: 'dwc',
        ownerId,
        type: 'list.single_line_text_field',
        value: JSON.stringify(allAiTags)
      });
    }

    if (colorAnalysis.description) {
      metafields.push({
        key: 'ai_generated_description',
        namespace: 'dwc',
        ownerId,
        type: 'multi_line_text_field',
        value: colorAnalysis.description
      });
    }
  }

  if (metafields.length === 0) {
    return { success: true, errors: [] };
  }

  const mutation = `
    mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
      metafieldsSet(metafields: $metafields) {
        metafields { id key namespace }
        userErrors { field message }
      }
    }
  `;

  try {
    const response = await fetch(
      `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`,
      {
        method: 'POST',
        headers: {
          'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ query: mutation, variables: { metafields } })
      }
    );

    const result = await response.json() as any;
    const errors = result.data?.metafieldsSet?.userErrors?.map((e: any) => e.message) || [];

    return { success: errors.length === 0, errors };
  } catch (e: any) {
    return { success: false, errors: [e.message] };
  }
}

/**
 * Update a single Thibaut product
 */
async function updateThibautProduct(product: any): Promise<any> {
  const mfrSku = extractThibautSku(product);
  const { pattern, color } = parseProductForPatternAndColor(product);

  // Get image for AI analysis
  const imageUrl = product.images?.[0]?.src || product.image?.src;

  // Analyze with Gemini 3.0
  let colorAnalysis = null;
  if (imageUrl) {
    logActivity('info', `Analyzing image with Gemini 3.0 for ${mfrSku || product.title}...`);
    colorAnalysis = await analyzeImageWithGemini(imageUrl, product.title);
  }

  // Try to scrape Thibaut website for additional specs
  let thibautSpecs = null;
  if (mfrSku) {
    logActivity('info', `Scraping thibautdesign.com for ${mfrSku}...`);
    thibautSpecs = await scrapeThibautSpecs(mfrSku);
  }

  // Use scraped data if available, otherwise use parsed data
  const finalPattern = thibautSpecs?.patternName || pattern || 'Thibaut';
  const finalColor = thibautSpecs?.colorName || color || (colorAnalysis?.backgroundColor);

  // Format titles
  const displayTitle = formatDisplayTitle(finalPattern, finalColor);
  const seoTitle = formatSeoTitle(finalPattern, finalColor, mfrSku);

  // Build tags
  const newTags = buildTags(colorAnalysis, mfrSku, finalPattern, finalColor);

  // Update product in Shopify
  const updateData = {
    product: {
      id: product.id,
      title: displayTitle,
      tags: newTags.join(', '),
      product_type: UPDATE_SPECS.productType
    }
  };

  const url = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/products/${product.id}.json`;

  const response = await fetch(url, {
    method: 'PUT',
    headers: {
      'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(updateData)
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Shopify API error: ${response.status} - ${error}`);
  }

  // Push metafields (including scraped Thibaut specs)
  const metafieldResult = await pushMetafieldsToShopify(
    product.id,
    mfrSku,
    finalPattern,
    finalColor,
    seoTitle,
    colorAnalysis,
    colorAnalysis?.description || '',
    thibautSpecs  // Pass scraped specs for width, repeat, collection, content
  );

  // Save to updated SKUs tracking
  if (mfrSku) {
    saveUpdatedSku({
      sku: mfrSku,
      productId: String(product.id),
      title: displayTitle,
      pattern: finalPattern,
      color: finalColor,
      mfrSku,
      updatedAt: new Date().toISOString()
    });

    // Save to PostgreSQL
    await saveToPostgres(
      String(product.id),
      product.variants?.[0]?.sku || '',
      mfrSku,
      product.title,
      displayTitle,
      finalPattern,
      finalColor,
      'completed'
    );
  }

  return {
    success: true,
    productId: product.id,
    oldTitle: product.title,
    newTitle: displayTitle,
    seoTitle,
    pattern: finalPattern,
    color: finalColor,
    mfrSku,
    tags: newTags,
    colorAnalysis,
    metafieldsUpdated: metafieldResult.success,
    metafieldErrors: metafieldResult.errors
  };
}

/**
 * Fetch Thibaut products from Shopify with PAGINATION support
 * ONLY returns products that have manufacturer_sku metafield set
 * Skips products that are already updated
 */
async function fetchThibautProducts(limit: number = 10, offset: number = 0): Promise<{ products: any[]; total: number }> {
  const alreadyUpdatedSkus = loadUpdatedSkus();
  const allMatchingProducts: any[] = [];
  let cursor: string | null = null;
  let totalFetched = 0;
  const maxPages = limit > 500 ? 20 : 5;  // More pages for "all"
  let pageCount = 0;

  logActivity('info', `Fetching Thibaut products (need ${limit}, skipping ${alreadyUpdatedSkus.size} already updated)...`);

  // Paginate until we have enough products OR run out of pages
  while (allMatchingProducts.length < limit && pageCount < maxPages) {
    pageCount++;
    const cursorArg = cursor ? `, after: "${cursor}"` : '';
    const query = `
      {
        products(first: 250${cursorArg}, query: "vendor:Thibaut* OR vendor:DWTT*") {
          edges {
            node {
              id
              title
              handle
              tags
              productType
              vendor
              images(first: 5) {
                edges {
                  node {
                    src
                    altText
                  }
                }
              }
              variants(first: 5) {
                edges {
                  node {
                    id
                    sku
                    price
                  }
                }
              }
              metafields(first: 10, keys: ["custom.manufacturer_sku", "dwc.manufacturer_sku"]) {
                edges {
                  node {
                    namespace
                    key
                    value
                  }
                }
              }
            }
            cursor
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `;

    try {
      const response = await fetch(
        `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`,
        {
          method: 'POST',
          headers: {
            'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ query })
        }
      );

      const result = await response.json() as any;
      const edges = result.data?.products?.edges || [];
      const pageInfo = result.data?.products?.pageInfo;
      totalFetched += edges.length;

      // Transform and filter this page's products
      for (const edge of edges) {
        const node = edge.node;
        const id = node.id.replace('gid://shopify/Product/', '');
        const metafields = node.metafields?.edges?.map((m: any) => m.node) || [];
        const mfrSku = metafields.find((m: any) =>
          (m.key === 'manufacturer_sku' || m.key === 'custom.manufacturer_sku' || m.key === 'dwc.manufacturer_sku') &&
          (m.namespace === 'custom' || m.namespace === 'dwc')
        )?.value;

        // Skip if no manufacturer_sku
        if (!mfrSku || mfrSku.length === 0) continue;

        // Skip if already updated
        if (alreadyUpdatedSkus.has(mfrSku)) continue;

        // Add to results
        allMatchingProducts.push({
          id,
          title: node.title,
          handle: node.handle,
          tags: node.tags.join(', '),
          product_type: node.productType,
          vendor: node.vendor,
          images: node.images.edges.map((ie: any) => ({ src: ie.node.src, alt: ie.node.altText })),
          variants: node.variants.edges.map((ve: any) => ({
            id: ve.node.id.replace('gid://shopify/ProductVariant/', ''),
            sku: ve.node.sku,
            price: ve.node.price
          })),
          manufacturerSku: mfrSku
        });

        // Stop if we have enough
        if (allMatchingProducts.length >= limit) break;
      }

      // Update cursor for next page
      if (pageInfo?.hasNextPage && pageInfo?.endCursor) {
        cursor = pageInfo.endCursor;
      } else {
        break;  // No more pages
      }

      // Rate limit between pages
      if (allMatchingProducts.length < limit) {
        await new Promise(r => setTimeout(r, 200));
      }

    } catch (e: any) {
      console.error('Error fetching products page:', e.message);
      break;
    }
  }  // End pagination while loop

  const products = allMatchingProducts.slice(0, limit);
  logActivity('info', `Fetched ${totalFetched} products across ${pageCount} pages, ${alreadyUpdatedSkus.size} skipped (already updated), returning ${products.length} to process`);

  return { products, total: products.length };
}

// API Routes
app.get('/api/status', (req, res) => {
  res.json({
    running: processState.running,
    paused: processState.paused,
    processed: processState.processed,
    total: processState.totalProducts,
    errors: processState.errors.length,
    logs: processState.logs.slice(-20),
    products: Array.from(processState.productUpdates.entries()).map(([id, data]) => ({
      id,
      ...data
    }))
  });
});

app.get('/api/preview', async (req, res) => {
  const limit = parseInt(req.query.limit as string) || 10;
  const offset = parseInt(req.query.offset as string) || 0;

  try {
    const { products, total } = await fetchThibautProducts(limit, offset);

    // Add preview data
    const productsWithPreview = products.map(p => {
      const mfrSku = extractThibautSku(p);
      const { pattern, color } = parseProductForPatternAndColor(p);
      const previouslyUpdated = mfrSku ? isSkuPreviouslyUpdated(mfrSku) : null;

      return {
        ...p,
        preview: {
          mfrSku,
          pattern,
          color,
          newTitle: formatDisplayTitle(pattern || 'Thibaut', color),
          seoTitle: formatSeoTitle(pattern || 'Thibaut', color, mfrSku)
        },
        previouslyUpdated: !!previouslyUpdated,
        previouslyUpdatedAt: previouslyUpdated?.updatedAt
      };
    });

    res.json({ products: productsWithPreview, total });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.post('/api/start', async (req, res) => {
  if (processState.running) {
    return res.status(400).json({ error: 'Process already running' });
  }

  const { productIds, batchSize } = req.body;

  // Convert "all" to a large number, otherwise parse as int
  const parsedBatchSize = batchSize === 'all' ? 10000 : (parseInt(batchSize) || 10);

  processState.running = true;
  processState.paused = false;
  processState.batchSize = parsedBatchSize;
  processState.currentIndex = 0;
  processState.processed = 0;
  processState.errors = [];
  processState.logs = [];
  processState.productUpdates.clear();
  processState.startTime = new Date();

  logActivity('info', `Starting Thibaut update process for ${productIds?.length || (parsedBatchSize >= 10000 ? 'all' : parsedBatchSize)} products...`);

  // Run in background
  (async () => {
    try {
      const { products } = await fetchThibautProducts(processState.batchSize);
      processState.products = products;
      processState.totalProducts = products.length;

      // Initialize product states
      products.forEach(p => {
        processState.productUpdates.set(String(p.id), {
          status: 'pending',
          oldTitle: p.title
        });
      });

      for (let i = 0; i < products.length; i++) {
        if (!processState.running) break;
        while (processState.paused) {
          await new Promise(r => setTimeout(r, 1000));
        }

        const product = products[i];
        const productId = String(product.id);

        processState.currentIndex = i;
        processState.productUpdates.set(productId, {
          ...processState.productUpdates.get(productId)!,
          status: 'updating'
        });

        try {
          logActivity('info', `Processing [${i + 1}/${products.length}]: ${product.title}`);
          const result = await updateThibautProduct(product);

          // DUPLICATE TITLE CHECK - Pause if same title as previous product
          const completedUpdates = Array.from(processState.productUpdates.values())
            .filter(u => u.status === 'completed' && u.newTitle);
          const duplicateTitle = completedUpdates.find(u => u.newTitle === result.newTitle);

          if (duplicateTitle) {
            // YOLO MODE: Just log duplicates, don't pause
            logActivity('warn', `⚠️ DUPLICATE TITLE: "${result.newTitle}" - MFR: ${result.mfrSku} matches MFR: ${duplicateTitle.mfrSku} (continuing...)`);
          }

          processState.productUpdates.set(productId, {
            status: 'completed',
            oldTitle: product.title,
            newTitle: result.newTitle,
            sku: result.mfrSku,
            pattern: result.pattern,
            color: result.color,
            mfrSku: result.mfrSku,
            tags: result.tags,
            colorAnalysis: result.colorAnalysis,
            timestamp: new Date()
          });

          logActivity('update', `Updated: ${product.title} → ${result.newTitle}`);
          processState.processed++;
        } catch (e: any) {
          processState.productUpdates.set(productId, {
            ...processState.productUpdates.get(productId)!,
            status: 'error',
            error: e.message
          });
          processState.errors.push({ id: productId, error: e.message });
          logActivity('error', `Error updating ${product.title}: ${e.message}`);
        }

        // Rate limit
        await new Promise(r => setTimeout(r, 1500));
      }

      // Save history
      saveHistoryEntry({
        date: new Date().toISOString(),
        status: 'completed',
        processed: processState.processed,
        total: processState.totalProducts,
        errors: processState.errors.length,
        products: Array.from(processState.productUpdates.entries()).map(([id, data]) => ({
          id,
          ...data
        }))
      });

      logActivity('complete', `Process completed: ${processState.processed}/${processState.totalProducts} updated`);
    } catch (e: any) {
      logActivity('error', `Process failed: ${e.message}`);
    } finally {
      processState.running = false;
    }
  })();

  res.json({ success: true, message: 'Process started' });
});

app.post('/api/pause', (req, res) => {
  processState.paused = true;
  logActivity('info', 'Process paused');
  res.json({ success: true });
});

app.post('/api/resume', (req, res) => {
  processState.paused = false;
  logActivity('info', 'Process resumed');
  res.json({ success: true });
});

app.post('/api/stop', (req, res) => {
  processState.running = false;
  processState.paused = false;
  logActivity('info', 'Process stopped');
  res.json({ success: true });
});

app.get('/api/history', (req, res) => {
  res.json({ history: loadHistory() });
});

// Get updates from PostgreSQL with pagination
app.get('/api/db-history', async (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 50;
  const offset = (page - 1) * limit;

  try {
    const countResult = await pool.query('SELECT COUNT(*) FROM thibaut_product_updates');
    const total = parseInt(countResult.rows[0].count);

    const result = await pool.query(`
      SELECT * FROM thibaut_product_updates
      ORDER BY updated_at DESC
      LIMIT $1 OFFSET $2
    `, [limit, offset]);

    res.json({
      total,
      page,
      pages: Math.ceil(total / limit),
      updates: result.rows
    });
  } catch (err: any) {
    res.status(500).json({ error: err.message });
  }
});

// Dashboard HTML
function getDashboardHTML(): string {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Thibaut Updater | DW-Agents</title>
  <style>
    :root {
      --bg-primary: #1a1a2e;
      --bg-secondary: #16213e;
      --text-primary: #eaeaea;
      --text-secondary: #a0a0a0;
      --accent: #00d9ff;
      --accent2: #10b981;
      --border: #2a2a4a;
      --surface: #1e1e3f;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; }
    .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
    .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
    .header h1 { color: var(--accent); font-size: 24px; }
    .status-badge { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; }
    .status-idle { background: var(--bg-secondary); color: var(--text-secondary); }
    .status-running { background: var(--accent2); color: white; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
    @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
    .card { background: var(--bg-secondary); border-radius: 12px; padding: 20px; border: 1px solid var(--border); }
    .card h2 { color: var(--accent); margin-bottom: 16px; font-size: 16px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
    .form-group { margin-bottom: 16px; }
    label { display: block; margin-bottom: 6px; color: var(--text-secondary); font-size: 14px; }
    select { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); font-size: 16px; }
    select:focus { outline: none; border-color: var(--accent); }
    .btn-group { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
    .btn { padding: 12px 24px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; }
    .btn-primary { background: var(--accent); color: var(--bg-primary); }
    .btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
    .btn-warning { background: #f39c12; color: #1a1a2e; }
    .btn-danger { background: #e74c3c; color: white; }
    .btn-secondary { background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border); }
    .progress-bar { height: 24px; background: var(--bg-primary); border-radius: 12px; overflow: hidden; margin: 15px 0; }
    .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.3s; display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--bg-primary); font-size: 12px; }
    .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-top: 15px; }
    .stat { background: var(--bg-primary); padding: 15px; border-radius: 8px; text-align: center; }
    .stat-value { font-size: 24px; font-weight: 700; color: var(--accent); }
    .stat-label { font-size: 11px; color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; }
    .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 15px; max-height: 60vh; overflow-y: auto; margin-top: 15px; padding: 10px 5px; }
    .sku-card { background: var(--bg-secondary); border-radius: 12px; padding: 15px; border: 2px solid var(--border); transition: all 0.3s ease; }
    .sku-card.pending { opacity: 0.7; }
    .sku-card.updating { border-color: #f39c12; animation: pulse 1.5s infinite; }
    .sku-card.completed { border-color: var(--accent2); background: linear-gradient(135deg, #0a3d2e, #1a1a2e); }
    .sku-card.error { border-color: #e74c3c; }
    @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.8; } }
    .sku-card-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
    .sku-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; }
    .sku-card-status { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 10px; font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
    .sku-card-status.pending { background: #95a5a6; color: white; }
    .sku-card-status.updating { background: #f39c12; color: white; }
    .sku-card-status.completed { background: var(--accent2); color: white; }
    .sku-card-status.error { background: #e74c3c; color: white; }
    .sku-card-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .sku-card-sku { font-size: 11px; color: var(--accent); font-family: monospace; }
    .log-box { background: #0d0d1a; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 11px; max-height: 200px; overflow-y: auto; margin-top: 15px; }
    .log-entry.error { color: #e74c3c; }
    .log-entry.success { color: var(--accent); }
    .log-entry.info { color: #f39c12; }

    /* Approval Modal */
    .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 10000; overflow-y: auto; }
    .modal-overlay.open { display: flex; justify-content: center; padding: 20px; align-items: flex-start; }
    .modal-content { background: #ffffff; border-radius: 16px; padding: 30px; width: 100%; max-width: 1200px; margin: 20px auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
    .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e5e7eb; }
    .modal-header h2 { color: #1f2937; font-size: 24px; }
    .modal-close { background: #f3f4f6; border: none; color: #6b7280; font-size: 28px; cursor: pointer; padding: 5px 15px; border-radius: 8px; }
    .select-all-bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; background: #f8fafc; padding: 16px 20px; border-radius: 12px; margin-bottom: 20px; }
    .batch-sizes { display: flex; gap: 10px; flex-wrap: wrap; }
    .batch-btn { padding: 10px 20px; border: 2px solid #e5e7eb; background: #ffffff; color: #374151; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
    .batch-btn:hover { border-color: var(--accent2); color: var(--accent2); }
    .approval-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 16px; max-height: 60vh; overflow-y: auto; padding: 20px; background: #f9fafb; border-radius: 12px; }
    .approval-item { background: #ffffff; border-radius: 12px; padding: 15px; border: 2px solid #e5e7eb; transition: all 0.2s; cursor: pointer; display: flex; gap: 15px; }
    .approval-item:hover { border-color: var(--accent2); }
    .approval-item.selected { border-color: var(--accent2); background: #ecfdf5; }
    .approval-item img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; }
    .approval-item .info-panel { flex: 1; }
    .approval-item .section { padding: 8px; border-radius: 6px; margin-bottom: 8px; }
    .approval-item .before { background: #fef2f2; border-left: 3px solid #ef4444; }
    .approval-item .after { background: #ecfdf5; border-left: 3px solid #10b981; }
    .approval-item .section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #6b7280; margin-bottom: 4px; }
    .approval-item .title { font-size: 12px; color: #1f2937; font-weight: 600; }
    .modal-footer { display: flex; justify-content: space-between; margin-top: 24px; padding-top: 20px; border-top: 2px solid #e5e7eb; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <h1>Thibaut Updater</h1>
      <span id="statusBadge" class="status-badge status-idle">IDLE</span>
    </div>

    <div class="grid">
      <div>
        <div class="card">
          <h2>Configuration</h2>
          <div class="form-group">
            <label>Batch Size</label>
            <select id="batchSize">
              <option value="1">1 product</option>
              <option value="5">5 products</option>
              <option value="10" selected>10 products</option>
              <option value="20">20 products</option>
              <option value="100">100 products</option>
              <option value="250">250 products</option>
              <option value="500">500 products</option>
              <option value="1000">1000 products</option>
              <option value="all">All products</option>
            </select>
          </div>

          <div style="background: var(--bg-primary); padding: 15px; border-radius: 8px; font-size: 12px; margin-top: 15px;">
            <strong>Update Format:</strong><br><br>
            <strong>Display:</strong> <code style="color: var(--accent);">Pattern Color | Thibaut Wallcoverings</code><br>
            <strong>SEO:</strong> <code style="color: var(--accent);">Pattern Color (T#####) | Architectural Wallcoverings</code><br>
            <strong>AI:</strong> <code style="color: var(--accent2);">Gemini 3.0 color/style analysis</code>
          </div>

          <div class="btn-group">
            <button class="btn btn-secondary" onclick="showPreviewModal()">Preview & Approve</button>
            <button id="btnStart" class="btn btn-primary" onclick="startProcess()">Start Update</button>
            <button id="btnPause" class="btn btn-warning" onclick="pauseProcess()" disabled>Pause</button>
            <button id="btnResume" class="btn btn-primary" onclick="resumeProcess()" disabled>Resume</button>
            <button id="btnStop" class="btn btn-danger" onclick="stopProcess()" disabled>Stop</button>
          </div>
        </div>
      </div>

      <div>
        <div class="card">
          <h2>Progress</h2>
          <div class="progress-bar">
            <div id="progressFill" class="progress-fill" style="width: 0%;">0%</div>
          </div>
          <div class="stats">
            <div class="stat">
              <div id="statProcessed" class="stat-value">0</div>
              <div class="stat-label">Processed</div>
            </div>
            <div class="stat">
              <div id="statTotal" class="stat-value">0</div>
              <div class="stat-label">Total</div>
            </div>
            <div class="stat">
              <div id="statErrors" class="stat-value">0</div>
              <div class="stat-label">Errors</div>
            </div>
            <div class="stat">
              <div id="statElapsed" class="stat-value">0:00</div>
              <div class="stat-label">Elapsed</div>
            </div>
          </div>
          <div id="productsPreview" class="products-grid"></div>
          <div id="log" class="log-box"></div>
        </div>
      </div>
    </div>
  </div>

  <!-- Approval Modal -->
  <div id="approvalModal" class="modal-overlay">
    <div class="modal-content">
      <div class="modal-header">
        <h2>Preview Thibaut Products</h2>
        <button class="modal-close" onclick="closeModal()">&times;</button>
      </div>
      <div class="select-all-bar">
        <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; color: #1f2937;">
          <input type="checkbox" id="selectAll" onchange="toggleSelectAll()" style="width: 20px; height: 20px;">
          Select All
        </label>
        <div class="batch-sizes">
          <button class="batch-btn" onclick="selectBatch(5)">5</button>
          <button class="batch-btn" onclick="selectBatch(10)">10</button>
          <button class="batch-btn" onclick="selectBatch(20)">20</button>
          <button class="batch-btn" onclick="selectBatch(100)">100</button>
          <button class="batch-btn" onclick="selectBatch(250)">250</button>
          <button class="batch-btn" onclick="selectBatch(500)">500</button>
          <button class="batch-btn" onclick="selectBatch(1000)">1000</button>
        </div>
        <span style="color: #10b981; font-weight: 700;"><span id="selectedCount">0</span> / <span id="totalCount">0</span> selected</span>
      </div>
      <div id="approvalGrid" class="approval-grid"></div>
      <div class="modal-footer">
        <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
        <button class="btn btn-primary" onclick="approveAndStart()" style="background: #10b981; color: white;">Approve & Start Update</button>
      </div>
    </div>
  </div>

  <script>
    let pollInterval = null;
    let startTime = null;
    let previewProducts = [];
    let selectedProductIds = new Set();

    window.onload = function() {
      pollStatus();
    };

    async function showPreviewModal() {
      const batchSize = document.getElementById('batchSize').value;
      const limit = batchSize === 'all' ? 1000 : parseInt(batchSize);

      try {
        const res = await fetch('/api/preview?limit=' + limit);
        const data = await res.json();
        previewProducts = data.products || [];
        selectedProductIds = new Set(previewProducts.map(p => p.id));

        document.getElementById('totalCount').textContent = previewProducts.length;
        document.getElementById('selectedCount').textContent = selectedProductIds.size;
        document.getElementById('selectAll').checked = true;

        const grid = document.getElementById('approvalGrid');
        grid.innerHTML = previewProducts.map(function(p) {
          const img = p.images?.[0]?.src || 'https://via.placeholder.com/80?text=No+Image';
          const oldSku = p.variants?.[0]?.sku || 'N/A';
          const newTitle = p.preview?.newTitle || 'Processing...';
          const mfrSku = p.preview?.mfrSku || '—';
          const prevBadge = p.previouslyUpdated ? '<span style="background:#3b82f6;color:white;padding:2px 6px;border-radius:4px;font-size:9px;margin-left:8px;">Previously Updated</span>' : '';

          return '<div class="approval-item selected" data-id="' + p.id + '" onclick="toggleProduct(' + p.id + ')">' +
            '<img src="' + img + '" alt="" onerror="this.src=\\'https://via.placeholder.com/80?text=No+Image\\'">' +
            '<div class="info-panel">' +
              '<div class="section before">' +
                '<div class="section-label">CURRENT' + prevBadge + '</div>' +
                '<div class="title">' + (p.title || 'Untitled') + '</div>' +
                '<div style="font-size:10px;color:#6b7280;">SKU: ' + oldSku + '</div>' +
              '</div>' +
              '<div class="section after">' +
                '<div class="section-label">PROPOSED</div>' +
                '<div class="title">' + newTitle + '</div>' +
                '<div style="font-size:10px;color:#10b981;">MFR#: ' + mfrSku + '</div>' +
              '</div>' +
            '</div>' +
          '</div>';
        }).join('');

        document.getElementById('approvalModal').classList.add('open');
      } catch (e) {
        alert('Failed to load preview: ' + e.message);
      }
    }

    function closeModal() {
      document.getElementById('approvalModal').classList.remove('open');
    }

    function toggleProduct(id) {
      const item = document.querySelector('.approval-item[data-id="' + id + '"]');
      if (selectedProductIds.has(id)) {
        selectedProductIds.delete(id);
        item?.classList.remove('selected');
      } else {
        selectedProductIds.add(id);
        item?.classList.add('selected');
      }
      document.getElementById('selectedCount').textContent = selectedProductIds.size;
    }

    function toggleSelectAll() {
      const checked = document.getElementById('selectAll').checked;
      document.querySelectorAll('.approval-item').forEach(item => {
        const id = parseInt(item.dataset.id);
        if (checked) {
          selectedProductIds.add(id);
          item.classList.add('selected');
        } else {
          selectedProductIds.delete(id);
          item.classList.remove('selected');
        }
      });
      document.getElementById('selectedCount').textContent = selectedProductIds.size;
    }

    function selectBatch(count) {
      selectedProductIds.clear();
      document.querySelectorAll('.approval-item').forEach((item, i) => {
        const id = parseInt(item.dataset.id);
        if (i < count) {
          selectedProductIds.add(id);
          item.classList.add('selected');
        } else {
          item.classList.remove('selected');
        }
      });
      document.getElementById('selectedCount').textContent = selectedProductIds.size;
      document.getElementById('selectAll').checked = selectedProductIds.size === previewProducts.length;
    }

    async function approveAndStart() {
      if (selectedProductIds.size === 0) {
        alert('Please select at least one product');
        return;
      }
      closeModal();
      startProcess();
    }

    async function startProcess() {
      const batchSize = document.getElementById('batchSize').value;
      document.getElementById('btnStart').disabled = true;
      document.getElementById('btnPause').disabled = false;
      document.getElementById('btnStop').disabled = false;
      document.getElementById('statusBadge').textContent = 'RUNNING';
      document.getElementById('statusBadge').className = 'status-badge status-running';
      startTime = Date.now();

      try {
        await fetch('/api/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            batchSize: batchSize === 'all' ? 1000 : parseInt(batchSize),
            productIds: Array.from(selectedProductIds)
          })
        });

        if (pollInterval) clearInterval(pollInterval);
        pollInterval = setInterval(pollStatus, 2000);
      } catch (e) {
        alert('Failed to start: ' + e.message);
      }
    }

    async function pauseProcess() {
      await fetch('/api/pause', { method: 'POST' });
      document.getElementById('btnPause').disabled = true;
      document.getElementById('btnResume').disabled = false;
      document.getElementById('statusBadge').textContent = 'PAUSED';
      document.getElementById('statusBadge').className = 'status-badge status-idle';
    }

    async function resumeProcess() {
      await fetch('/api/resume', { method: 'POST' });
      document.getElementById('btnPause').disabled = false;
      document.getElementById('btnResume').disabled = true;
      document.getElementById('statusBadge').textContent = 'RUNNING';
      document.getElementById('statusBadge').className = 'status-badge status-running';
    }

    async function stopProcess() {
      await fetch('/api/stop', { method: 'POST' });
      resetButtons();
    }

    function resetButtons() {
      document.getElementById('btnStart').disabled = false;
      document.getElementById('btnPause').disabled = true;
      document.getElementById('btnResume').disabled = true;
      document.getElementById('btnStop').disabled = true;
      document.getElementById('statusBadge').textContent = 'IDLE';
      document.getElementById('statusBadge').className = 'status-badge status-idle';
      if (pollInterval) {
        clearInterval(pollInterval);
        pollInterval = null;
      }
    }

    async function pollStatus() {
      try {
        const res = await fetch('/api/status');
        const data = await res.json();

        const pct = data.total > 0 ? Math.round((data.processed / data.total) * 100) : 0;
        document.getElementById('progressFill').style.width = pct + '%';
        document.getElementById('progressFill').textContent = pct + '%';

        document.getElementById('statProcessed').textContent = data.processed;
        document.getElementById('statTotal').textContent = data.total;
        document.getElementById('statErrors').textContent = data.errors;

        if (startTime) {
          const elapsed = Math.floor((Date.now() - startTime) / 1000);
          const mins = Math.floor(elapsed / 60);
          const secs = elapsed % 60;
          document.getElementById('statElapsed').textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
        }

        // Update product cards
        const grid = document.getElementById('productsPreview');
        if (data.products && data.products.length > 0) {
          grid.innerHTML = data.products.map(p => {
            const statusClass = p.status || 'pending';
            return '<div class="sku-card ' + statusClass + '">' +
              '<div class="sku-card-header">' +
                '<div class="sku-card-status ' + statusClass + '">' + statusClass + '</div>' +
              '</div>' +
              '<div class="sku-card-title">' + (p.newTitle || p.oldTitle || 'Unknown') + '</div>' +
              '<div class="sku-card-sku">' + (p.mfrSku || p.sku || '—') + '</div>' +
            '</div>';
          }).join('');
        }

        // Update log
        const logBox = document.getElementById('log');
        if (data.logs && data.logs.length > 0) {
          logBox.innerHTML = data.logs.map(l => {
            const cls = l.includes('Error') ? 'error' : l.includes('Updated') ? 'success' : 'info';
            return '<div class="log-entry ' + cls + '">' + l + '</div>';
          }).join('');
          logBox.scrollTop = logBox.scrollHeight;
        }

        if (!data.running && pollInterval) {
          resetButtons();
        }
      } catch (e) {
        console.error('Poll error:', e);
      }
    }
  </script>
</body>
</html>
`;
}

// Main route
app.get('/', (req, res) => {
  res.send(getDashboardHTML());
});

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', agent: 'thibaut-updater', port: PORT });
});

// Start server
app.listen(PORT, () => {
  console.log(`
╔══════════════════════════════════════════════════════════════╗
║                                                              ║
║   🎨 THIBAUT UPDATER AGENT                                   ║
║   Port: ${PORT}                                                 ║
║   URL: http://45.61.58.125:${PORT}                              ║
║   Auth: admin / <password>                                ║
║                                                              ║
║   Features:                                                  ║
║   • Batch sizes: 1, 5, 10, 20, 100, 250, 500, 1000, all     ║
║   • Gemini 3.0 AI color/style analysis                       ║
║   • Interior designer tagging                                ║
║   • Thibaut website scraping                                 ║
║   • Shopify metafield updates                                ║
║                                                              ║
╚══════════════════════════════════════════════════════════════╝
  `);
});