← 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()">×</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 ║
║ ║
╚══════════════════════════════════════════════════════════════╝
`);
});