← back to Designer Wallcoverings
DW-Agents/dw-agents/architectural-update-agent.ts
4701 lines
/**
* DW-Agents: Architectural Update Agent
*
* Web UI for updating commercial vendors to Architectural Wallcoverings branding:
* 1. Vendor dropdown selector (loads from Shopify)
* 2. Batch size options (1,2,5,10,20,30,50,100,250,500,all)
* 3. Start/Stop/Resume controls
* 4. Real-time progress with product thumbnails
* 5. Process history (collapsed view)
*
* Title Format: Pattern Name Color (Real mfr#) | Architectural Wallcoverings
* No MFR#: Pattern Name Color | Architectural Wallcoverings
*
* Uses:
* - Agent-SKU skill for manufacturer specifications
* - Interior Designer color tag skill for AI color analysis
*
* Port: 9897
*/
import Anthropic from '@anthropic-ai/sdk';
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 { AgentMemory } from './shared-memory-system';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import pg from 'pg';
const { Pool } = pg;
import crypto from 'crypto';
// 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);
// Import shared UI components
import { getUniversalHeader, getThemedPageWrapper, getThemeStyles } from './shared-ui-components.js';
// Koroseal SKU to Collection mappings (from koroseal-spec-scraper)
const KOROSEAL_SKU_COLLECTIONS: Record<string, string> = {
'C921': 'chateau', 'C923': 'chateau',
'RA21': 'arco', 'SPR1': 'spire',
'AL21': 'adalyn-weave', 'AS21': 'astra',
'DK-WT': 'digital', 'DK-ST': 'digital', 'DK-': 'digital',
'MB0': 'mandy-budan', 'SRD-': 'sarah-rowland',
'2R15': 'rememberance-lite', '2R': 'rememberance',
'TAK-': 'specialty',
};
// Koroseal SKU to Color Name mappings
const KOROSEAL_SKU_COLORS: Record<string, string> = {
'C921-01': 'Whitewashed', 'C921-02': 'Bleached', 'C921-03': 'Weathered',
'C921-04': 'Beach', 'C921-05': 'Tuscan', 'C921-06': 'Clove',
'C921-07': 'Pining', 'C921-08': 'Drift', 'C921-09': 'Ancient Oak',
'C921-10': 'Aged Walnut', 'C921-11': 'Espresso', 'C921-12': 'Smoked',
'AL21-01': 'Moonlight', 'AL21-02': 'Hope', 'AL21-03': 'Sunshine',
'AL21-04': 'Playful', 'AL21-05': 'Cloud', 'AL21-06': 'Future',
'AL21-07': 'Confident', 'AL21-08': 'Lagoon', 'AL21-09': 'Mindful',
'AL21-10': 'Earthen', 'AL21-11': 'Mood', 'AL21-12': 'Forest',
'AL21-13': 'Compassion', 'AL21-14': 'Peaceful', 'AL21-15': 'Gratitude',
'SPR1-01': 'Hermitage', 'SPR1-02': 'Versailles', 'SPR1-03': 'Windsor',
};
// Collection to Pattern Name mappings
const KOROSEAL_PATTERN_NAMES: Record<string, string> = {
'chateau': 'Chateau',
'adalyn-weave': 'Adalyn Weave',
'astra': 'Astra',
'arco': 'Arco',
'spire': 'Spire',
'rememberance-lite': 'Rememberance Lite',
'rememberance': 'Rememberance',
'digital': 'Digital Print',
'mandy-budan': 'Mandy Budan',
'sarah-rowland': 'Sarah Rowland',
};
// Load environment variables from multiple locations
dotenv.config({ path: '/root/Projects/Designer-Wallcoverings/.env' });
dotenv.config();
const app = express();
const PORT = 9897;
// Initialize agent memory system
const agentMemory = new AgentMemory('architectural-update');
console.log('📚 Memory system initialized for architectural-update');
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(session({
secret: 'architectural-update-secret-2024',
resave: false,
saveUninitialized: false,
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }
}));
// Apply global auth
app.use(requireGlobalAuth);
// Shopify configuration - LIVE STORE (designer-laboratory-sandbox = designerwallcoverings.com)
// NEVER use designerwallcoverings.myshopify.com - it doesn't work!
const SHOPIFY_CONFIG = {
shop: 'designer-laboratory-sandbox.myshopify.com',
accessToken: (process.env.SHOPIFY_ADMIN_TOKEN || ''),
apiVersion: '2024-01'
};
// Anthropic Claude
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Update specifications - supports both Architectural and Residential modes
const UPDATE_SPECS_ARCHITECTURAL = {
suffix: 'Architectural Wallcoverings',
seoDescription: 'Architectural wallcoverings for commercial and high-end interiors. Specification-grade materials from your one stop resource for over 20 years.',
samplePrice: 4.25,
category: 'Building Materials',
productType: 'Architectural Wallcoverings'
};
const UPDATE_SPECS_RESIDENTIAL = {
suffix: 'Designer Wallcoverings',
seoDescription: 'Premium designer wallcoverings for residential and boutique interiors. Curated selection from your trusted source for over 20 years.',
samplePrice: 4.25,
category: 'Home & Garden',
productType: 'Designer Wallcoverings'
};
// Default to Architectural (can be changed via UI)
let currentSkill: 'architectural' | 'residential' = 'architectural';
// Legacy reference for backwards compatibility
const UPDATE_SPECS = UPDATE_SPECS_ARCHITECTURAL;
function getCurrentUpdateSpecs() {
return currentSkill === 'residential' ? UPDATE_SPECS_RESIDENTIAL : UPDATE_SPECS_ARCHITECTURAL;
}
// Process state
interface ProcessState {
running: boolean;
paused: boolean;
vendor: string | null;
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[];
syncImages: boolean; // Whether to sync images from manufacturer
overrides: Record<string, { title?: string; pattern?: string; color?: string; mfrSku?: string; description?: string }>; // User overrides for products
productUpdates: Map<string, {
status: 'pending' | 'updating' | 'completed' | 'error';
oldTitle: string;
newTitle?: string;
sku?: string;
timestamp?: Date;
error?: string;
imagesUpdated?: boolean;
imageCount?: number;
// Extended details for full display
pattern?: string;
color?: string;
mfrSku?: string;
tags?: string[];
oldTags?: string[];
description?: string;
productType?: string;
category?: string;
colorAnalysis?: any;
mfrData?: any;
}>; // Track individual product updates with full details
}
let processState: ProcessState = {
running: false,
paused: false,
vendor: null,
batchSize: 10,
currentIndex: 0,
totalProducts: 0,
processed: 0,
errors: [],
products: [],
startTime: null,
logs: [],
syncImages: true, // Default to enabled
overrides: {}, // User overrides for products
productUpdates: new Map() // Track individual product updates
};
// Process history
const historyFile = path.join(__dirname, 'data', 'architectural-update-history.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));
}
// Persistent store for updated SKUs - tracks all successfully updated products
const updatedSkusFile = path.join(__dirname, 'data', 'architectural-updated-skus.json');
interface UpdatedSkuRecord {
sku: string;
productId: string;
title: string;
pattern?: string;
color?: string;
mfrSku?: string;
updatedAt: string;
vendor?: 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) {
console.error('[Updated SKUs] Error loading:', 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));
}
function isSkuPreviouslyUpdated(sku: string): UpdatedSkuRecord | null {
const skus = loadUpdatedSkus();
return skus.get(sku) || null;
}
// Activity log
const activityLog: Array<{
timestamp: Date;
type: 'update' | 'error' | 'info' | 'complete';
message: string;
}> = [];
function logActivity(type: 'update' | 'error' | 'info' | 'complete', message: string) {
activityLog.unshift({ timestamp: new Date(), type, message });
if (activityLog.length > 100) activityLog.pop();
processState.logs.push(`[${new Date().toLocaleTimeString()}] ${message}`);
if (processState.logs.length > 50) processState.logs.shift();
}
// Statistics
let stats = {
totalUpdated: 0,
totalErrors: 0,
lastVendor: null as string | null,
lastRunTime: null as Date | null,
vendorsProcessed: [] as string[]
};
/**
* Format title according to spec
* For artworks (Mandy Budan, Sarah Rowland): "Artwork Name (SKU) | Artist Name | Suffix"
* For regular products: "Pattern Color (SKU) | Suffix"
*/
function formatTitle(patternName: string, color: string | null, mfrNumber: string | null, options?: { artist?: string | null, isArtwork?: boolean }): string {
const currentSpecs = getCurrentUpdateSpecs();
// For artwork collections, include artist name in title
if (options?.isArtwork && options?.artist) {
const artworkName = patternName.trim();
if (mfrNumber && mfrNumber.trim()) {
const cleanSku = mfrNumber.trim().replace(/-SAMPLE$/i, '');
return `${artworkName} (${cleanSku}) | ${options.artist} | ${currentSpecs.suffix}`;
}
return `${artworkName} | ${options.artist} | ${currentSpecs.suffix}`;
}
// Standard format: Pattern Color (SKU) | Suffix
const basePart = color ? `${patternName} ${color}`.trim() : patternName.trim();
if (mfrNumber && mfrNumber.trim()) {
// Remove -SAMPLE suffix from SKU (not needed in titles)
const cleanSku = mfrNumber.trim().replace(/-SAMPLE$/i, '');
return `${basePart} (${cleanSku}) | ${currentSpecs.suffix}`;
}
return `${basePart} | ${currentSpecs.suffix}`;
}
/**
* Analyze product image for color tags using Claude AI
*/
async function analyzeColorsWithAI(imageUrl: string, productTitle: string): Promise<any> {
try {
const response = await anthropic.messages.create({
model: 'claude-opus-4-8',
max_tokens: 1024,
messages: [{
role: 'user',
content: [
{
type: 'image',
source: { type: 'url', url: imageUrl }
},
{
type: 'text',
text: `Analyze this wallcovering image for color tagging. Product: "${productTitle}"
Using interior design terminology, provide:
1. Background Color (the dominant color)
2. Up to 3 color names per color family present
3. Style/Period tags (1-2: Victorian, Art Deco, Mid-Century Modern, Contemporary, Traditional, etc.)
4. Application tags for architectural/commercial use (Hospitality, Healthcare, Corporate, Residential, Retail)
5. Material type if identifiable (Vinyl, Grasscloth, Paper, Non-woven, etc.)
IMPORTANT: Natural materials (Grasscloth, Sisal, Jute, Paper, Silk) are NEVER Type II. Only Vinyl/Synthetic can be Type II Commercial.
Return as JSON:
{
"backgroundColor": "string",
"colorTags": ["color1", "color2", "color3"],
"styleTags": ["style1"],
"applicationTags": ["Hospitality", "Corporate"],
"materialTags": ["Vinyl"],
"isTypeII": boolean
}`
}
]
}]
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
return null;
} catch (e: any) {
console.error('AI color analysis error:', e.message);
return null;
}
}
/**
* Interface for manufacturer data
*/
interface ManufacturerData {
mfrSku: string | null; // Real manufacturer SKU (e.g., "2R15-21", "AL21-15")
patternName: string | null; // Pattern name (e.g., "Rememberance Lite", "Adalyn Weave")
colorName: string | null; // Color name (e.g., "Canfu", "Gratitude")
artist?: string | null; // Artist name for artwork collections (e.g., "Mandy Budan", "Sarah Rowland")
isArtwork?: boolean; // True if this is an artwork/artist collection (don't split pattern/color)
material?: string | null;
backing?: string | null;
fireRating?: string | null;
korosealUrl?: string | null;
}
/**
* Extract real manufacturer SKU from various product data sources
* Live site handle format: dwk-30865-koroseal-koroseal-type-2-vinyl-2r15-21-rememberance-lite-canfu
*/
function extractRealMfrSku(product: any): string | null {
const handle = product.handle || '';
// SKU patterns to match: 2R15-21, AL21-15, C921-10, TAK-DA01-01
const skuPatterns = [
/\b(2r\d{1,2}-\d{1,2})\b/i, // 2R15-21 (Rememberance)
/\b([a-z]{2}\d{2}-\d{1,2})\b/i, // AL21-15, C921-10
/\b(tak-[a-z]{2}\d{2}-\d{2})\b/i, // TAK-DA01-01
/\b(spr\d-\d{2})\b/i, // SPR1-01
/\b(ra\d{2}-\d{1,2})\b/i, // RA21-01 (Arco)
/\b(as\d{2}-\d{1,2})\b/i, // AS21-01 (Astra)
/\b(dk-[a-z]{2}-\d+)\b/i, // DK-WT-001 (Digital)
];
for (const pattern of skuPatterns) {
const match = handle.match(pattern);
if (match) {
return match[1].toUpperCase();
}
}
// Try to find in title (if properly formatted)
const titleSkuMatch = product.title?.match(/\(([A-Z0-9-]+)\)/i);
if (titleSkuMatch) {
const sku = titleSkuMatch[1].toUpperCase();
if (!sku.startsWith('DWK-')) {
return sku;
}
}
// Check variant SKUs (not DWK prefixed)
if (product.variants?.length > 0) {
for (const variant of product.variants) {
if (variant.sku && !variant.sku.startsWith('DWK-')) {
return variant.sku.toUpperCase();
}
}
}
return null;
}
/**
* Parse handle to extract pattern and color
* Supports TWO formats:
* 1. LIVE: dwk-30865-koroseal-koroseal-type-2-vinyl-2r15-21-rememberance-lite-canfu
* → SKU before pattern-color
* 2. SANDBOX: adalyn-weave-cloud-wallcovering-al21-05-sample-architectural-wallcoverings
* → pattern-color before SKU
*/
function parseHandleForPatternAndColor(handle: string, mfrSku: string | null): { pattern: string | null; color: string | null } {
if (!handle) return { pattern: null, color: null };
// FALLBACK: If no SKU or SKU not in handle, try to parse common handle formats
// Format: pattern-color-wallpaper-wallcovering-vendor (e.g., bird-sonnet-blush-wallpaper-wallcovering-1838-wallcoverings)
if (!mfrSku || handle.toLowerCase().indexOf(mfrSku.toLowerCase()) === -1) {
// Try to extract pattern and color from handle
const wallpaperIndex = handle.indexOf('-wallpaper');
const wallcoveringIndex = handle.indexOf('-wallcovering');
const cutoffIndex = wallpaperIndex !== -1 ? wallpaperIndex : wallcoveringIndex;
if (cutoffIndex > 0) {
const beforeWallpaper = handle.substring(0, cutoffIndex);
const parts = beforeWallpaper.split('-').filter(p => p && p.length > 1 && !p.match(/^dwk?$/i));
if (parts.length >= 2) {
// Last part is color, rest is pattern
const color = parts[parts.length - 1];
const patternParts = parts.slice(0, -1);
const pattern = patternParts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
return {
pattern: pattern,
color: color.charAt(0).toUpperCase() + color.slice(1)
};
} else if (parts.length === 1 && parts[0].length > 2) {
// Single-word pattern (e.g., "damier" from handle "damier-wallcovering-innovationsusa")
const pattern = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
return { pattern, color: null };
}
}
return { pattern: null, color: null };
}
const skuLower = mfrSku.toLowerCase();
const skuIndex = handle.toLowerCase().indexOf(skuLower);
// Check if this is SANDBOX format (pattern-color BEFORE sku)
// Indicated by "-wallcovering-" appearing after sku
const afterSku = handle.substring(skuIndex + skuLower.length);
const beforeSku = handle.substring(0, skuIndex);
if (beforeSku.includes('wallcovering') || afterSku.startsWith('-sample')) {
// SANDBOX FORMAT: adalyn-weave-cloud-wallcovering-al21-05-sample-...
// Pattern and color come BEFORE the SKU
const beforeWallcovering = handle.split('-wallcovering')[0];
const parts = beforeWallcovering.split('-').filter(p => p && p.length > 0);
if (parts.length >= 2) {
// Last part is color, rest is pattern
const color = parts[parts.length - 1];
const patternParts = parts.slice(0, -1);
const pattern = patternParts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
return { pattern, color: color.charAt(0).toUpperCase() + color.slice(1) };
}
} else {
// LIVE FORMAT: dwk-30865-...-2r15-21-rememberance-lite-canfu
// Pattern and color come AFTER the SKU
const parts = afterSku.split('-').filter(p => p && p.length > 0);
if (parts.length >= 2) {
// Last part is color, rest is pattern
const color = parts[parts.length - 1];
const patternParts = parts.slice(0, -1);
const pattern = patternParts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
return { pattern, color: color.charAt(0).toUpperCase() + color.slice(1) };
} else if (parts.length === 1) {
// Only color, no pattern
return { pattern: null, color: parts[0].charAt(0).toUpperCase() + parts[0].slice(1) };
}
}
return { pattern: null, color: null };
}
/**
* Get pattern name from SKU or handle
*/
function getPatternFromSkuOrHandle(mfrSku: string | null, handle: string): string | null {
// Try from SKU prefix first
if (mfrSku) {
for (const [prefix, collection] of Object.entries(KOROSEAL_SKU_COLLECTIONS)) {
if (mfrSku.toUpperCase().startsWith(prefix)) {
return KOROSEAL_PATTERN_NAMES[collection] || collection.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
}
}
// Try to extract from handle
// Format: ...-patternname-colorname
const handleParts = handle.split('-');
// Look for known pattern names in handle
for (const [key, name] of Object.entries(KOROSEAL_PATTERN_NAMES)) {
const slugified = key.replace(/-/g, '');
if (handle.toLowerCase().includes(key) || handle.toLowerCase().includes(slugified)) {
return name;
}
}
// Try to extract pattern name from end of handle (before color)
if (handleParts.length > 5) {
// Skip first parts (dwk-id-koroseal-...) and try to find pattern/color
const lastParts = handleParts.slice(-3);
// Assume pattern-name-color format
if (lastParts.length >= 2) {
const potentialPattern = lastParts.slice(0, -1).join(' ')
.split(' ')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return potentialPattern;
}
}
return null;
}
/**
* Get color name from SKU or handle
*/
function getColorFromSkuOrHandle(mfrSku: string | null, handle: string): string | null {
// Try exact SKU match first
if (mfrSku) {
const upperSku = mfrSku.toUpperCase();
if (KOROSEAL_SKU_COLORS[upperSku]) {
return KOROSEAL_SKU_COLORS[upperSku];
}
}
// Extract from handle (last part is usually color)
const handleParts = handle.split('-');
if (handleParts.length > 3) {
const colorSlug = handleParts[handleParts.length - 1];
// Capitalize
return colorSlug.charAt(0).toUpperCase() + colorSlug.slice(1);
}
return null;
}
/**
* Extended manufacturer data with images
*/
interface ManufacturerDataWithImages extends ManufacturerData {
images?: string[]; // Array of image URLs from manufacturer website
}
/**
* Scrape Koroseal website for product data AND images
*/
async function scrapeKorosealData(mfrSku: string): Promise<Partial<ManufacturerDataWithImages>> {
try {
// Build potential URL
let url: string | null = null;
for (const [prefix, collection] of Object.entries(KOROSEAL_SKU_COLLECTIONS)) {
if (mfrSku.toUpperCase().startsWith(prefix)) {
const color = KOROSEAL_SKU_COLORS[mfrSku.toUpperCase()];
if (color) {
url = `https://koroseal.com/products/wallcoverings/${collection}/${color.toLowerCase().replace(/\s+/g, '-')}`;
} else {
url = `https://koroseal.com/products/wallcoverings/${collection}`;
}
break;
}
}
if (!url) {
console.log(` No URL mapping for SKU: ${mfrSku}`);
return {};
}
console.log(` Scraping: ${url}`);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForTimeout(2000);
const data = await page.evaluate(() => {
const result: any = { images: [] };
const text = document.body.innerText;
// Extract specs
const patterns: [string, RegExp][] = [
['material', /Material\s*:\s*([^\n]+)/i],
['backing', /Backing\s*:\s*([^\n]+)/i],
['fireRating', /Fire Rating\s*:\s*([^\n]+)/i],
];
for (const [key, regex] of patterns) {
const match = text.match(regex);
if (match) result[key] = match[1].trim();
}
// Get pattern/color from page title or header
const h1 = document.querySelector('h1');
if (h1) result.pageTitle = h1.textContent?.trim();
// Extract product images
// Main product image
const mainImg = document.querySelector('.productcontain img, .pbpic img, .product-image img') as HTMLImageElement;
if (mainImg?.src) {
result.images.push(mainImg.src);
}
// Additional images from gallery/thumbnails
document.querySelectorAll('.thumbscontain img, .gallery img, .product-gallery img').forEach((img: Element) => {
const imgEl = img as HTMLImageElement;
if (imgEl.src && !result.images.includes(imgEl.src)) {
// Get full-size version if available (remove thumb suffix)
let fullSrc = imgEl.src.replace(/_thumb|_small|_medium/gi, '');
result.images.push(fullSrc);
}
});
// Also check for background-image style (common in Koroseal)
document.querySelectorAll('.tppic, .thumbprev span[style*="background"]').forEach((el: Element) => {
const style = (el as HTMLElement).style.backgroundImage;
const match = style?.match(/url\(['"]?([^'"]+)['"]?\)/);
if (match && match[1] && !result.images.includes(match[1])) {
result.images.push(match[1]);
}
});
return result;
});
await browser.close();
return {
material: data.material,
backing: data.backing,
fireRating: data.fireRating,
korosealUrl: url,
images: data.images || []
};
} catch (e: any) {
console.log(` Scrape error: ${e.message}`);
return {};
}
}
/**
* Scrape product images directly from Koroseal collection page by SKU
*/
async function scrapeKorosealImages(mfrSku: string, handle: string): Promise<string[]> {
try {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// First, try to find collection URL from SKU
let collectionUrl: string | null = null;
for (const [prefix, collection] of Object.entries(KOROSEAL_SKU_COLLECTIONS)) {
if (mfrSku.toUpperCase().startsWith(prefix)) {
collectionUrl = `https://koroseal.com/products/wallcoverings/${collection}`;
break;
}
}
if (!collectionUrl) {
await browser.close();
return [];
}
console.log(` Fetching images from: ${collectionUrl}`);
await page.goto(collectionUrl, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForTimeout(2000);
// Find the specific colorway by SKU
const images = await page.evaluate((sku) => {
const results: string[] = [];
// Look for colorway thumbnails with matching SKU
document.querySelectorAll('.thumbscontain a.thumbprev, .colorway-thumb').forEach((el) => {
const skuText = el.querySelector('.tptext')?.textContent?.trim() || '';
if (skuText.toUpperCase().includes(sku.toUpperCase())) {
// Found matching colorway - get its image
const picEl = el.querySelector('.tppic, span[style*="background"]') as HTMLElement;
if (picEl) {
const bgStyle = picEl.style.backgroundImage;
const match = bgStyle?.match(/url\(['"]?([^'"]+)['"]?\)/);
if (match && match[1]) {
results.push(match[1]);
}
}
// Also check for img tag
const img = el.querySelector('img') as HTMLImageElement;
if (img?.src && !results.includes(img.src)) {
results.push(img.src);
}
}
});
// If no match found, get the main product image
if (results.length === 0) {
const mainImg = document.querySelector('.pbpic img, .product-main img') as HTMLImageElement;
if (mainImg?.src) {
results.push(mainImg.src);
}
}
return results;
}, mfrSku);
await browser.close();
return images;
} catch (e: any) {
console.log(` Image scrape error: ${e.message}`);
return [];
}
}
/**
* Extract manufacturer data from product TAGS
* Tags contain: Pattern Name, Real SKU (AL21-15, C921-10, 1469311), Color Name, Background Color, etc.
*/
function extractDataFromTags(tags: string): { mfrSku: string | null; patternName: string | null; colorName: string | null } {
if (!tags) return { mfrSku: null, patternName: null, colorName: null };
// Split tags into array (comma-separated)
const tagList = tags.split(',').map(t => t.trim()).filter(t => t);
let mfrSku: string | null = null;
let patternName: string | null = null;
let colorName: string | null = null;
// Known pattern names (two-word names that appear in tags)
const knownPatterns = [
'Adalyn Weave', 'Chateau Wood', 'Rememberance Lite', 'Mandy Budan', 'Sarah Rowland',
'Digital Print', 'Astra', 'Arco', 'Spire', 'Chateau', 'Ajijic', 'Cancun',
'Tulum', 'Isla Mujeres', 'Puerto Vallarta', 'Cozumel', 'Playa del Carmen'
];
// SKU patterns: Various manufacturer SKU formats found in tags
const skuPatterns = [
/^DW[A-Z]{0,2}-\d{5,6}(-SAMPLE)?$/i, // DWWC-505590-SAMPLE, DWK-31959
/^[A-Z]{2,4}\d{1,2}-\d{1,2}$/i, // AL21-15, C921-10, 2R15-21
/^[A-Z]{3}-[A-Z]{2}\d{2}-\d{2}$/i, // TAK-DA01-01
/^\d{6,8}$/, // 1469311 (numeric)
/^[A-Z]{2}\d{2}[A-Z]?-\d{1,2}$/i, // Another SKU format
/^[A-Z]{1,3}\d{3,6}[A-Z]?$/i, // W12345, AB123456
/^[A-Z]{2,4}-\d{4,6}$/i, // WXYZ-12345
];
// Look for SKU first (usually first number or SKU-pattern tag)
for (const tag of tagList) {
// Check against SKU patterns
for (const pattern of skuPatterns) {
if (pattern.test(tag)) {
mfrSku = tag.toUpperCase();
break;
}
}
if (mfrSku) break;
}
// Look for known pattern names
for (const tag of tagList) {
for (const known of knownPatterns) {
if (tag.toLowerCase() === known.toLowerCase()) {
patternName = known;
break;
}
}
if (patternName) break;
}
// If no known pattern found, look for two-word names that could be patterns
if (!patternName) {
for (const tag of tagList) {
// Skip if it's an SKU, a color tag, or other system tags
if (skuPatterns.some(p => p.test(tag))) continue;
if (tag.startsWith('Background Color')) continue;
if (tag === 'Wallcovering' || tag === 'Architectural' || tag === 'Koroseal') continue;
if (tag === 'Type II' || tag === 'Commercial Grade') continue;
// Two-word pattern names
const words = tag.split(' ');
if (words.length === 2 && words[0].length > 2 && words[1].length > 2) {
// Could be a pattern name like "Adalyn Weave"
patternName = tag;
break;
}
}
}
// Look for color name (single word, not a system tag, not SKU)
// Color names are usually capitalized single words like "Moonlight", "Gratitude", "Canfu"
for (const tag of tagList) {
if (mfrSku && tag === mfrSku) continue;
if (patternName && tag === patternName) continue;
if (tag.startsWith('Background Color')) continue;
if (tag === 'Wallcovering' || tag === 'Architectural' || tag === 'Koroseal') continue;
if (tag === 'Type II' || tag === 'Commercial Grade') continue;
if (skuPatterns.some(p => p.test(tag))) continue;
// Single word capitalized - likely a color name
if (tag.split(' ').length === 1 && /^[A-Z][a-z]+$/.test(tag)) {
// Check against known Koroseal colors
const colorValues = Object.values(KOROSEAL_SKU_COLORS);
if (colorValues.includes(tag)) {
colorName = tag;
break;
}
}
}
// If no color found from known list, try to find any single capitalized word
if (!colorName) {
for (const tag of tagList) {
if (mfrSku && tag === mfrSku) continue;
if (patternName && tag === patternName) continue;
if (tag.startsWith('Background Color')) continue;
if (tag === 'Wallcovering' || tag === 'Architectural' || tag === 'Koroseal') continue;
if (tag === 'Type II' || tag === 'Commercial Grade') continue;
if (skuPatterns.some(p => p.test(tag))) continue;
// Single word that looks like a color name (capitalized)
if (tag.split(' ').length === 1 && tag.length > 3 && /^[A-Z]/.test(tag)) {
colorName = tag;
break;
}
}
}
console.log(` Tags parsed: SKU="${mfrSku}", Pattern="${patternName}", Color="${colorName}"`);
return { mfrSku, patternName, colorName };
}
/**
* Parse existing title for pattern, color, SKU, vendor, and artist name
*
* Title formats:
* - "Pattern - Color Wallcovering | Vendor Name (DWWC-123) | Brand"
* → Pattern="Pattern", Color="Color", Vendor="Vendor Name"
* - "Arco Malta (Ra21-03) | Type 2 Vinyl | Architectural Wallcoverings"
* → Pattern="Arco", Color="Malta", MFR SKU="RA21-03"
* - "Along The River (MB00002) | Mandy Budan | Architectural Wallcoverings"
* → Artwork name="Along The River", Artist="Mandy Budan"
*
* IMPORTANT: DWWC- numbers are INTERNAL SKUs, not manufacturer SKUs!
* Real MFR SKUs come from tags or have formats like RA21-03, AL21-15, etc.
*/
function parseExistingTitle(title: string): {
pattern: string | null,
color: string | null,
sku: string | null,
artist: string | null,
vendor: string | null,
isArtwork: boolean
} {
if (!title) return { pattern: null, color: null, sku: null, artist: null, vendor: null, isArtwork: false };
// Known vendor names to detect (separate from artist names)
const knownVendors = ['Andrew Martin', 'Koroseal', 'Arte-International', 'Arte', 'Cowtan & Tout',
'Colefax & Fowler', 'Phillip Jeffries', 'Thibaut', 'York', 'Brewster', 'Graham & Brown',
'Schumacher', 'Kravet', 'JF Fabrics', 'MDC', 'Wolf-Gordon', 'Carnegie', 'Maharam'];
// Artist names (for artwork collections - different handling)
const artistNames = ['Mandy Budan', 'Sarah Rowland'];
let artist: string | null = null;
let vendor: string | null = null;
let isArtwork = false;
// Split title on "|" to separate components
const segments = title.split('|').map(s => s.trim());
// First segment is the product name (before any |)
let productPart = segments[0] || '';
// Check other segments for vendor/artist names
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
// Check for artist names
for (const artistName of artistNames) {
if (seg.includes(artistName)) {
artist = artistName;
isArtwork = true;
break;
}
}
// Check for vendor names
for (const vendorName of knownVendors) {
if (seg.includes(vendorName)) {
vendor = vendorName;
break;
}
}
}
// Extract SKU from parentheses - but ONLY if it's a REAL MFR SKU format
// REAL MFR SKUs: RA21-03, AL21-15, MB00002, TAK-DA01-01, C921-10, 2R15-21
// INTERNAL SKUs (skip): DWWC-500560, DWK-30865
let sku: string | null = null;
const allSkuMatches = title.match(/\(([^)]+)\)/g);
if (allSkuMatches) {
for (const match of allSkuMatches) {
const skuCandidate = match.slice(1, -1); // Remove parentheses
// Skip DWWC- and DWK- internal SKUs
if (skuCandidate.toUpperCase().startsWith('DWWC-') || skuCandidate.toUpperCase().startsWith('DWK-')) {
continue;
}
// Accept real MFR SKU patterns
if (/^[A-Z]{1,4}[-]?[A-Z0-9]{2,6}[-]?[A-Z0-9]{0,4}$/i.test(skuCandidate)) {
sku = skuCandidate.toUpperCase();
break;
}
}
}
// Also detect artwork by SKU prefix
if (!artist && sku) {
if (sku.startsWith('MB0') || sku.match(/^MB\d/)) {
artist = 'Mandy Budan';
isArtwork = true;
} else if (sku.startsWith('SRD')) {
artist = 'Sarah Rowland';
isArtwork = true;
}
}
// Get the product name part (before parentheses and before |)
let productName = productPart.split('(')[0].trim();
// Remove trailing "Wallcovering" or "Wallpaper" if present
productName = productName.replace(/\s+(Wallcovering|Wallpaper)$/i, '').trim();
// Check if it looks like garbage data
const badPatterns = ['Type 2', 'Type II', 'Vinyl', 'Sample', 'Architectural', 'Designer Wallcoverings', 'Commercial', 'PVC'];
const containsBadPattern = badPatterns.some(bad => productName.toLowerCase().includes(bad.toLowerCase()));
if (containsBadPattern) {
return { pattern: null, color: null, sku, artist, vendor, isArtwork };
}
// For artwork/artist collections, the WHOLE thing is the artwork name - don't split!
if (isArtwork) {
console.log(` ✓ ARTWORK DETECTED: "${productName}" by ${artist} (SKU: ${sku})`);
return { pattern: productName, color: null, sku, artist, vendor, isArtwork: true };
}
// Parse "Pattern - Color" format (common for many vendors)
// e.g., "Pear Tree - Plaster" → Pattern="Pear Tree", Color="Plaster"
if (productName.includes(' - ')) {
const dashParts = productName.split(' - ');
if (dashParts.length >= 2) {
const pattern = dashParts[0].trim();
const color = dashParts[dashParts.length - 1].trim();
if (pattern.length >= 2 && color.length >= 2) {
console.log(` ✓ DASH FORMAT: Pattern="${pattern}", Color="${color}", Vendor="${vendor}"`);
return { pattern, color, sku, artist: null, vendor, isArtwork: false };
}
}
}
// Fallback: Split into words - typical format is "Pattern Color" or "Pattern Pattern Color"
const words = productName.split(/\s+/).filter(w => w.length > 0);
// Validate starts with uppercase - including Unicode letters
const startsWithUppercase = (str: string) => {
const firstChar = str.charAt(0);
return firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase();
};
if (words.length >= 2) {
// Last word is usually the color
const color = words[words.length - 1];
// Everything before is the pattern
const pattern = words.slice(0, -1).join(' ');
if (pattern.length >= 3 && color.length >= 2 &&
startsWithUppercase(pattern) && startsWithUppercase(color)) {
return { pattern, color, sku, artist: null, vendor, isArtwork: false };
}
} else if (words.length === 1 && words[0].length > 2 && startsWithUppercase(words[0])) {
// Single word pattern (e.g., "Damier" from "Damier Wallcovering | Innovationsusa")
// Pattern name is often already the first word in the old title
console.log(` ✓ SINGLE-WORD PATTERN: "${words[0]}" (color unknown)`);
return { pattern: words[0], color: null, sku, artist: null, vendor, isArtwork: false };
}
return { pattern: null, color: null, sku, artist, vendor, isArtwork };
}
/**
* Extract complete manufacturer data from product
* Priority: 1. EXISTING TITLE (if it already has valid pattern/color - preserve it!)
* 2. Handle (reliable for live site products)
* 3. Tags (has mfr# per user)
* 4. SKU mappings (fallback)
*/
async function extractManufacturerData(product: any, options: { scrapeWebsite?: boolean } = {}): Promise<ManufacturerData> {
const handle = product.handle || '';
const tags = product.tags || '';
const existingTitle = product.title || '';
console.log(` Existing title: ${existingTitle}`);
console.log(` Parsing handle: ${handle}`);
console.log(` Tags: ${tags.substring(0, 100)}...`);
// PRIORITY 0: Check existing title FIRST - if it already has good data, PRESERVE IT!
const fromTitle = parseExistingTitle(existingTitle);
if (fromTitle.isArtwork && fromTitle.pattern) {
console.log(` ✓ ARTWORK: "${fromTitle.pattern}" by ${fromTitle.artist} (SKU: ${fromTitle.sku})`);
} else if (fromTitle.pattern && fromTitle.color) {
console.log(` ✓ PRESERVING existing title data: Pattern="${fromTitle.pattern}", Color="${fromTitle.color}", SKU="${fromTitle.sku}", Vendor="${fromTitle.vendor}"`);
}
// PRIORITY 1: Extract from TAGS (user confirmed: "the mfr number is in the tags on shopify!!")
// For vendors like Andrew Martin, the REAL MFR SKU is in tags, not DWWC internal SKU from title
const fromTags = extractDataFromTags(tags);
// PRIORITY 2: Extract SKU from handle (reliable for live site products)
// Handle format: dwk-30865-koroseal-koroseal-type-2-vinyl-2r15-21-rememberance-lite-canfu
const mfrSkuFromHandle = extractRealMfrSku(product);
// SKU priority: Tags FIRST (real MFR SKU), then title (only if not DWWC), then handle
// CRITICAL: Skip DWWC- internal SKUs from title
let mfrSku = fromTags.mfrSku || mfrSkuFromHandle;
if (!mfrSku && fromTitle.sku && !fromTitle.sku.startsWith('DWWC')) {
mfrSku = fromTitle.sku;
}
// Parse handle for pattern and color (after SKU in handle)
const fromHandle = parseHandleForPatternAndColor(handle, mfrSku);
// Combine all sources: EXISTING TITLE > handle > tags > mappings
// CRITICAL: Preserve existing good data - don't overwrite!
let patternName = fromTitle.pattern || fromHandle.pattern || fromTags.patternName || getPatternFromSkuOrHandle(mfrSku, handle);
let colorName = fromTitle.color || fromHandle.color || fromTags.colorName || getColorFromSkuOrHandle(mfrSku, handle);
// If we have an SKU but no color, try to get from mapping
if (mfrSku && !colorName) {
colorName = KOROSEAL_SKU_COLORS[mfrSku.toUpperCase()] || null;
}
// If we have SKU but no pattern, try to get from mapping
if (mfrSku && !patternName) {
for (const [prefix, collection] of Object.entries(KOROSEAL_SKU_COLLECTIONS)) {
if (mfrSku.toUpperCase().startsWith(prefix)) {
patternName = KOROSEAL_PATTERN_NAMES[collection] || collection;
break;
}
}
}
// Get artist info from title parsing
const artist = fromTitle.artist || null;
const isArtwork = fromTitle.isArtwork || false;
// For artworks, don't try to find color - the pattern IS the artwork name
if (isArtwork && !colorName) {
colorName = null; // Don't force a color for artworks
}
let data: ManufacturerData = {
mfrSku,
patternName,
colorName,
artist,
isArtwork
};
console.log(` FINAL: SKU="${mfrSku}", Pattern="${patternName}", Color="${colorName}", Artist="${artist}", Artwork=${isArtwork}`);
// Optionally scrape website for additional data
if (options.scrapeWebsite && mfrSku) {
const scraped = await scrapeKorosealData(mfrSku);
data = { ...data, ...scraped };
}
return data;
}
/**
* Build tags array from color analysis AND manufacturer data
* Per user requirement: pattern name, color, and real SKU must be in tags
* IMPORTANT: Preserves existing manufacturer number tags from original product
*/
function buildTags(colorAnalysis: any, mfrData: ManufacturerData, vendor: string, existingTags: string[] = []): string[] {
const tags = new Set<string>();
// FIRST: Preserve existing manufacturer number tags (SKUs, part numbers)
// Patterns like: DWWC-505040-SAMPLE, AL21-15, C921-10, TAK-DA01-01, etc.
const mfrNumberPattern = /^[A-Z0-9]{2,}[-][A-Z0-9]+/i;
existingTags.forEach(tag => {
const trimmed = tag.trim();
if (mfrNumberPattern.test(trimmed)) {
tags.add(trimmed);
}
});
// Add manufacturer pattern name (REQUIRED per user)
if (mfrData.patternName) {
tags.add(mfrData.patternName);
}
// Add manufacturer color name (REQUIRED per user)
if (mfrData.colorName) {
tags.add(mfrData.colorName);
}
// Add real manufacturer SKU (REQUIRED per user)
if (mfrData.mfrSku) {
tags.add(mfrData.mfrSku);
}
// Add AI-analyzed colors from interior designer skill
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.applicationTags?.forEach((a: string) => tags.add(a));
colorAnalysis.materialTags?.forEach((m: string) => tags.add(m));
// NOTE: Type II and Commercial Grade tags removed per user request
// These should only be added from actual product specs, not AI detection
}
// Always add base tags
tags.add('Wallcovering');
tags.add('Architectural');
// Add actual vendor name (not hardcoded)
if (vendor) {
tags.add(vendor);
}
return Array.from(tags);
}
/**
* Metafield push interfaces and function
*/
interface MetafieldInput {
key: string;
namespace: string;
ownerId: string;
type: string;
value: string;
}
interface MetafieldPushResult {
success: boolean;
updatedFields: string[];
errors: string[];
}
/**
* Push AI-generated data to Shopify metafields using GraphQL
* Uses metafieldsSet mutation for efficient bulk updates
*/
async function pushMetafieldsToShopify(
productId: string | number,
mfrData: any,
colorAnalysis: any,
description: string
): Promise<MetafieldPushResult> {
const result: MetafieldPushResult = {
success: false,
updatedFields: [],
errors: []
};
// Build metafield inputs
const metafields: MetafieldInput[] = [];
const ownerId = `gid://shopify/Product/${productId}`;
// dwc.ai_generated_colors - list of color tags (max 2 unique colors)
const uniqueColors = [...new Set(colorAnalysis?.colorTags || [])].slice(0, 2);
if (uniqueColors.length > 0) {
metafields.push({
key: 'ai_generated_colors',
namespace: 'dwc',
ownerId,
type: 'list.single_line_text_field',
value: JSON.stringify(uniqueColors)
});
}
// dwc.ai_generated_tags - combined style/application/material tags
const allTags: string[] = [
...(colorAnalysis?.styleTags || []),
...(colorAnalysis?.applicationTags || []),
...(colorAnalysis?.materialTags || [])
];
if (allTags.length > 0) {
metafields.push({
key: 'ai_generated_tags',
namespace: 'dwc',
ownerId,
type: 'list.single_line_text_field',
value: JSON.stringify(allTags)
});
}
// dwc.ai_generated_description
if (description) {
// Strip HTML tags for clean metafield value
const cleanDescription = description.replace(/<[^>]*>/g, '').trim();
if (cleanDescription) {
metafields.push({
key: 'ai_generated_description',
namespace: 'dwc',
ownerId,
type: 'multi_line_text_field',
value: cleanDescription
});
}
}
// dwc.background_color
if (colorAnalysis?.backgroundColor) {
metafields.push({
key: 'background_color',
namespace: 'dwc',
ownerId,
type: 'single_line_text_field',
value: colorAnalysis.backgroundColor
});
}
// dwc.color_analysis - full JSON for debugging/advanced use
if (colorAnalysis) {
metafields.push({
key: 'color_analysis',
namespace: 'dwc',
ownerId,
type: 'json',
value: JSON.stringify(colorAnalysis)
});
}
// dwc.manufacturer_sku
if (mfrData?.mfrSku) {
metafields.push({
key: 'manufacturer_sku',
namespace: 'dwc',
ownerId,
type: 'single_line_text_field',
value: mfrData.mfrSku
});
}
// dwc.pattern_name
if (mfrData?.patternName) {
metafields.push({
key: 'pattern_name',
namespace: 'dwc',
ownerId,
type: 'single_line_text_field',
value: mfrData.patternName
});
}
// dwc.color (color name)
if (mfrData?.colorName) {
metafields.push({
key: 'color',
namespace: 'dwc',
ownerId,
type: 'single_line_text_field',
value: mfrData.colorName
});
}
// dwc.real_vendor (actual vendor)
if (mfrData?.vendor || processState.vendor) {
metafields.push({
key: 'real_vendor',
namespace: 'dwc',
ownerId,
type: 'single_line_text_field',
value: mfrData?.vendor || processState.vendor
});
}
if (metafields.length === 0) {
result.success = true;
return result;
}
// Execute GraphQL mutation
const mutation = `
mutation MetafieldsSet($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
key
namespace
}
userErrors {
field
message
}
}
}
`;
try {
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: mutation,
variables: { metafields }
})
});
if (!response.ok) {
const errorText = await response.text();
result.errors.push(`HTTP ${response.status}: ${errorText}`);
return result;
}
const data = await response.json() as any;
// Check for GraphQL errors
if (data.errors && data.errors.length > 0) {
result.errors = data.errors.map((e: any) => e.message);
return result;
}
// Check for user errors
const userErrors = data.data?.metafieldsSet?.userErrors || [];
if (userErrors.length > 0) {
result.errors = userErrors.map((e: any) => `${e.field}: ${e.message}`);
return result;
}
// Track updated fields
const updatedMetafields = data.data?.metafieldsSet?.metafields || [];
result.updatedFields = updatedMetafields.map((m: any) => `${m.namespace}.${m.key}`);
result.success = true;
console.log(` 📦 Metafields updated: ${result.updatedFields.length} fields`);
} catch (e: any) {
result.errors.push(e.message);
console.error(' ⚠️ Metafield push error:', e.message);
}
return result;
}
/**
* Update Shopify product images from manufacturer URLs
*/
async function updateProductImages(productId: number, imageUrls: string[]): Promise<boolean> {
if (!imageUrls || imageUrls.length === 0) {
console.log(` No images to update for product ${productId}`);
return false;
}
try {
// First, delete existing images
const getUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/products/${productId}/images.json`;
const existingRes = await fetch(getUrl, {
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
}
});
if (existingRes.ok) {
const existingData: any = await existingRes.json();
// Delete each existing image
for (const img of existingData.images || []) {
const deleteUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/products/${productId}/images/${img.id}.json`;
await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken
}
});
await new Promise(r => setTimeout(r, 200)); // Rate limit
}
}
// Add new images from manufacturer
for (let i = 0; i < imageUrls.length && i < 5; i++) { // Max 5 images
const imageUrl = imageUrls[i];
console.log(` Adding image ${i + 1}: ${imageUrl.substring(0, 60)}...`);
const addUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/products/${productId}/images.json`;
const addRes = await fetch(addUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
image: {
src: imageUrl,
position: i + 1
}
})
});
if (!addRes.ok) {
console.log(` Failed to add image: ${await addRes.text()}`);
}
await new Promise(r => setTimeout(r, 500)); // Rate limit
}
return true;
} catch (e: any) {
console.log(` Image update error: ${e.message}`);
return false;
}
}
/**
* Update a single product using real manufacturer data AND images
*/
async function updateProduct(product: any, options: { syncImages?: boolean } = {}): Promise<any> {
// Check for user overrides for this product
const productId = String(product.id);
const userOverrides = processState.overrides[productId];
if (userOverrides && Object.keys(userOverrides).length > 0) {
console.log(` User overrides found for product ${productId}:`, Object.keys(userOverrides));
}
// Extract REAL manufacturer data (not Shopify internal SKUs)
// Also scrape website if we need images or more data
const mfrData = await extractManufacturerData(product, { scrapeWebsite: options.syncImages });
// Apply user overrides to mfrData if provided
if (userOverrides?.pattern) {
console.log(` Applying pattern override: "${userOverrides.pattern}"`);
mfrData.patternName = userOverrides.pattern;
}
if (userOverrides?.color) {
console.log(` Applying color override: "${userOverrides.color}"`);
mfrData.colorName = userOverrides.color;
}
if (userOverrides?.mfrSku) {
console.log(` Applying SKU override: "${userOverrides.mfrSku}"`);
mfrData.mfrSku = userOverrides.mfrSku;
}
console.log(` Extracted: Pattern="${mfrData.patternName}", Color="${mfrData.colorName}", SKU="${mfrData.mfrSku}"`);
// If syncImages is enabled and we have a real SKU, fetch images from manufacturer
let mfrImages: string[] = [];
if (options.syncImages && mfrData.mfrSku) {
console.log(` Fetching manufacturer images for ${mfrData.mfrSku}...`);
mfrImages = await scrapeKorosealImages(mfrData.mfrSku, product.handle || '');
console.log(` Found ${mfrImages.length} images from manufacturer`);
}
// Analyze colors with AI if image available (use manufacturer image if we have one)
let colorAnalysis = null;
const imageToAnalyze = mfrImages.length > 0 ? mfrImages[0] : (product.images?.[0]?.src || null);
if (imageToAnalyze) {
colorAnalysis = await analyzeColorsWithAI(imageToAnalyze, product.title);
}
// Format new title using REAL manufacturer data (or override if provided)
// For artworks: "Artwork Name (SKU) | Artist Name | Suffix"
// For regular: "Pattern Name Color (Real mfr#) | Suffix"
const fallbackVendorName = product.vendor || 'Wallcovering';
let newTitle = formatTitle(
mfrData.patternName || fallbackVendorName,
mfrData.colorName || '',
mfrData.mfrSku,
{ artist: mfrData.artist, isArtwork: mfrData.isArtwork }
);
// Apply title override if user provided one
if (userOverrides?.title) {
console.log(` Applying title override: "${userOverrides.title}"`);
newTitle = userOverrides.title;
}
// Build rich description with AI content, specs, and manufacturer link
const currentSpecs = getCurrentUpdateSpecs();
// Extract AI description and source URL from product metafields
const metafields = product.metafields || [];
const getMetafield = (key: string) => {
const mf = metafields.find((m: any) => m.key === key);
return mf?.value || null;
};
const aiDescription = getMetafield('ai_generated_description');
const sourceUrl = getMetafield('source_url');
// Start with AI description if available, otherwise use default SEO text
let newDescription = '';
if (aiDescription) {
// Use the AI-generated description (already HTML formatted)
newDescription = aiDescription + '\n\n';
} else {
newDescription = currentSpecs.seoDescription + '\n\n';
}
// Add manufacturer specs if available
const specsToShow = [];
if (mfrData.material) specsToShow.push(`<li><strong>Material:</strong> ${mfrData.material}</li>`);
if (mfrData.backing) specsToShow.push(`<li><strong>Backing:</strong> ${mfrData.backing}</li>`);
if (mfrData.fireRating) specsToShow.push(`<li><strong>Fire Rating:</strong> ${mfrData.fireRating}</li>`);
if (mfrData.mfrSku) specsToShow.push(`<li><strong>Manufacturer SKU:</strong> ${mfrData.mfrSku}</li>`);
if (specsToShow.length > 0) {
newDescription += '<h3>Specifications</h3>\n<ul>\n' + specsToShow.join('\n') + '\n</ul>\n\n';
}
// Add manufacturer link if available
if (sourceUrl) {
newDescription += `<p><a href="${sourceUrl}" target="_blank" rel="noopener">View on Manufacturer Website →</a></p>\n\n`;
}
newDescription += '<p><strong>Resources:</strong> Spec Sheet | Installation Guide</p>';
// Apply description override if user provided one
if (userOverrides?.description) {
console.log(` Applying description override`);
newDescription = userOverrides.description;
}
// Build tags - includes pattern name, color, real SKU, and actual vendor name
// IMPORTANT: Pass existing tags to preserve manufacturer numbers
const existingTags = (product.tags || '').split(',').map((t: string) => t.trim()).filter((t: string) => t);
const newTags = buildTags(colorAnalysis, mfrData, product.vendor || '', existingTags);
// Prepare update payload
const updateData = {
product: {
id: product.id,
title: newTitle,
body_html: newDescription,
product_type: currentSpecs.productType,
tags: newTags.join(', '),
variants: product.variants?.map((v: any) => ({
id: v.id,
price: currentSpecs.samplePrice.toString()
}))
}
};
// Make API call to Shopify
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}`);
}
// Update images if enabled and we have manufacturer images
let imagesUpdated = false;
if (options.syncImages && mfrImages.length > 0) {
console.log(` Syncing ${mfrImages.length} images to Shopify...`);
imagesUpdated = await updateProductImages(product.id, mfrImages);
}
// Push AI data to Shopify metafields (non-blocking - errors don't fail the update)
let metafieldResult: MetafieldPushResult = { success: false, updatedFields: [], errors: [] };
try {
metafieldResult = await pushMetafieldsToShopify(
product.id,
mfrData,
colorAnalysis,
newDescription
);
if (!metafieldResult.success && metafieldResult.errors.length > 0) {
console.warn(` ⚠️ Metafield warnings: ${metafieldResult.errors.join(', ')}`);
}
} catch (e: any) {
console.warn(` ⚠️ Metafield push skipped: ${e.message}`);
}
// Log this update to the database for tracking
const oldTags = (product.tags || '').split(',').map((t: string) => t.trim()).filter((t: string) => t);
await logUpdateToDatabase(
product,
mfrData,
colorAnalysis,
product.title,
newTitle,
oldTags,
newTags,
imagesUpdated,
mfrImages.length,
metafieldResult
);
return {
success: true,
productId: product.id,
oldTitle: product.title,
newTitle,
mfrData,
colorAnalysis,
tags: newTags,
imagesUpdated,
imageCount: mfrImages.length,
metafieldsUpdated: metafieldResult.success,
metafieldsCount: metafieldResult.updatedFields.length,
metafieldErrors: metafieldResult.errors,
metafieldResult // Full result for UI display
};
}
// Dashboard HTML
function getDashboardHTML(): string {
const theme = 'dark';
const themeStyles = getThemeStyles(theme);
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architectural Update Agent | DW-Agents</title>
${themeStyles}
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent: #00d9ff;
--border: #2a2a4a;
--surface: #1e1e3f;
--surface-alt: #252550;
}
* { 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: #27ae60; color: white; }
.status-paused { background: #f39c12; color: #1a1a2e; }
.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, input { 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, input: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), #44a08d); 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: 70vh; 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; position: relative; }
.sku-card.pending { border-color: var(--border); opacity: 0.7; }
.sku-card.updating { border-color: #f39c12; box-shadow: 0 0 10px rgba(243, 156, 18, 0.3); animation: pulse 1.5s infinite; }
.sku-card.completed { border-color: #10b981; background: linear-gradient(135deg, #ecfdf5, #d1fae5); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); }
.sku-card.previously-updated { border-color: #3b82f6; background: linear-gradient(135deg, #eff6ff, #dbeafe); box-shadow: 0 0 8px rgba(59, 130, 246, 0.3); }
.sku-card.error { border-color: #e74c3c; box-shadow: 0 0 10px rgba(231, 76, 60, 0.2); }
.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; border: 1px solid var(--border); }
.sku-card-info { flex: 1; min-width: 0; }
.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: #10b981; color: white; }
.sku-card-status.previously-updated { background: #3b82f6; color: white; }
.sku-card-status.error { background: #e74c3c; color: white; }
.sku-card-details { margin-top: 10px; padding: 10px; background: rgba(0,0,0,0.1); border-radius: 8px; font-size: 10px; display: none; }
.sku-card.completed .sku-card-details { display: block; }
.sku-card-details .detail-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
.sku-card-details .detail-label { color: var(--text-secondary); }
.sku-card-details .detail-value { color: #10b981; font-weight: 500; }
.sku-card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.sku-card-tag { background: #10b981; color: white; padding: 2px 6px; border-radius: 4px; font-size: 9px; }
.sku-card-tag.old { background: #95a5a6; text-decoration: line-through; }
.sku-card-refresh { position: absolute; top: 10px; right: 10px; background: #3498db; color: white; border: none; padding: 4px 8px; border-radius: 4px; font-size: 10px; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
.sku-card:hover .sku-card-refresh { opacity: 1; }
.sku-card-refresh:hover { background: #2980b9; }
.sku-card-title { font-size: 12px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.3; }
.sku-card-sku { font-size: 10px; color: var(--text-secondary); font-family: monospace; margin-bottom: 8px; }
.sku-card-old-title { font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; padding: 6px; background: var(--bg-primary); border-radius: 4px; border-left: 3px solid #95a5a6; }
.sku-card-new-title { font-size: 11px; color: var(--text-primary); font-weight: 500; padding: 6px; background: var(--bg-primary); border-radius: 4px; border-left: 3px solid var(--accent); margin-top: 6px; }
.sku-card-timestamp { font-size: 9px; color: var(--text-secondary); margin-top: 8px; text-align: right; }
.sku-card-error { font-size: 10px; color: #e74c3c; margin-top: 8px; padding: 6px; background: rgba(231, 76, 60, 0.1); border-radius: 4px; }
.sku-card-images-info { font-size: 9px; color: var(--accent); margin-top: 4px; }
.sku-card-metafields-info { font-size: 9px; color: #10b981; margin-top: 4px; }
.sku-card-metafields-info.warning { color: #f59e0b; }
.sku-card-metafields-info.error { color: #ef4444; }
@keyframes pulse { 0%, 100% { opacity: 1; box-shadow: 0 0 10px rgba(243, 156, 18, 0.3); } 50% { opacity: 0.9; box-shadow: 0 0 20px rgba(243, 156, 18, 0.5); } }
.specs-box { background: var(--bg-primary); padding: 15px; border-radius: 8px; font-size: 12px; margin-top: 15px; }
.specs-box code { color: var(--accent); background: var(--border); padding: 2px 6px; border-radius: 4px; }
.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 { margin-bottom: 4px; }
.log-entry.error { color: #e74c3c; }
.log-entry.success { color: var(--accent); }
.log-entry.info { color: #f39c12; }
.history-toggle { cursor: pointer; display: flex; align-items: center; gap: 8px; }
.history-toggle:hover { color: var(--accent); }
.history-content { display: none; margin-top: 15px; }
.history-content.open { display: block; }
.history-item { background: var(--bg-primary); padding: 16px; border-radius: 12px; margin-bottom: 20px; font-size: 12px; border: 1px solid var(--border); }
.history-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 10px; }
.history-item-stats { display: flex; gap: 20px; flex-wrap: wrap; }
.history-item-stat { text-align: center; }
.history-item-stat-value { font-size: 18px; font-weight: 700; color: var(--accent); }
.history-item-stat-label { font-size: 10px; color: var(--text-secondary); text-transform: uppercase; }
.history-products-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 12px; }
@media (max-width: 1400px) { .history-products-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 1000px) { .history-products-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .history-products-grid { grid-template-columns: 1fr; } }
.history-product-card { background: var(--bg-secondary); border-radius: 8px; padding: 12px; border: 1px solid var(--border); font-size: 11px; }
.history-product-card.completed { border-color: #10b981; background: linear-gradient(135deg, #ecfdf5, #d1fae5); }
.history-product-card.error { border-color: #e74c3c; background: linear-gradient(135deg, #fef2f2, #fecaca); }
.history-product-card-title { font-weight: 600; color: var(--text-primary); margin-bottom: 6px; word-break: break-word; }
.history-product-card-sku { color: var(--accent); font-family: monospace; margin-bottom: 4px; }
.history-product-card-details { color: var(--text-secondary); font-size: 10px; }
.history-product-card-old { color: #ef4444; font-size: 10px; margin-top: 6px; padding: 4px 6px; background: rgba(239, 68, 68, 0.1); border-radius: 4px; }
.history-product-card-new { color: #10b981; font-size: 10px; margin-top: 4px; padding: 4px 6px; background: rgba(16, 185, 129, 0.1); border-radius: 4px; }
.history-toggle-products { cursor: pointer; color: var(--accent); font-size: 11px; margin-top: 10px; display: inline-block; }
.history-toggle-products:hover { text-decoration: underline; }
/* Approval Modal - White Background Theme */
.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); box-sizing: border-box; overflow-x: hidden; }
.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; font-weight: 700; }
.modal-close { background: #f3f4f6; border: none; color: #6b7280; font-size: 28px; cursor: pointer; padding: 5px 15px; border-radius: 8px; }
.modal-close:hover { background: #e5e7eb; color: #1f2937; }
.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; border: 1px solid #e5e7eb; }
.select-all-bar label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; color: #1f2937; }
.select-all-bar input[type="checkbox"] { width: 22px; height: 22px; accent-color: #10b981; cursor: pointer; }
.selected-count { color: #10b981; font-weight: 700; font-size: 16px; }
.approval-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; max-height: 60vh; overflow-y: auto; overflow-x: hidden; padding: 20px; background: #f9fafb; border-radius: 12px; box-sizing: border-box; }
.approval-item { background: #ffffff; border-radius: 12px; padding: 15px; border: 2px solid #e5e7eb; transition: all 0.2s; position: relative; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.05); display: flex; gap: 15px; box-sizing: border-box; overflow: visible; }
.approval-item:hover { border-color: #10b981; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.approval-item.selected { border-color: #10b981; background: #ecfdf5; }
.approval-item input[type="checkbox"] { position: absolute; top: 12px; left: 12px; width: 20px; height: 20px; accent-color: #10b981; cursor: pointer; z-index: 10; }
.approval-item img { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; border: 1px solid #e5e7eb; background: #f3f4f6; flex-shrink: 0; }
.approval-item .info-panel { flex: 1; text-align: left; min-width: 0; }
.approval-item .before-after { display: flex; flex-direction: column; gap: 8px; }
.approval-item .section { padding: 8px; border-radius: 6px; }
.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 .before .section-label { color: #ef4444; }
.approval-item .after .section-label { color: #10b981; }
.approval-item .title { font-size: 12px; color: #1f2937; font-weight: 600; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.3; }
.approval-item .details { font-size: 11px; color: #6b7280; }
.approval-item .details span { display: inline-block; margin-right: 8px; }
.approval-item .sku { font-size: 11px; color: #10b981; font-weight: 600; }
.approval-item.previously-updated { border-color: #3b82f6; background: linear-gradient(135deg, #eff6ff, #dbeafe); }
.approval-item .prev-updated-badge { display: flex; align-items: center; gap: 6px; background: #3b82f6; color: white; padding: 4px 10px; border-radius: 6px; font-size: 10px; font-weight: 600; margin-bottom: 8px; }
.approval-item .prev-updated-badge svg { width: 12px; height: 12px; }
.modal-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; padding-top: 20px; border-top: 2px solid #e5e7eb; flex-wrap: wrap; gap: 15px; }
.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; transition: all 0.2s; }
.batch-btn:hover { border-color: #10b981; color: #10b981; }
.batch-btn.active { background: #10b981; color: #ffffff; border-color: #10b981; }
.modal-footer .btn { padding: 12px 28px; font-size: 15px; font-weight: 600; }
.modal-footer .btn-primary { background: #10b981; color: white; border: none; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); transition: all 0.2s; }
.modal-footer .btn-primary:hover { background: #059669; box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4); transform: translateY(-1px); }
.modal-footer .btn-primary:active { transform: translateY(0); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); }
.modal-footer .btn-primary:disabled { background: #d1d5db; color: #9ca3af; cursor: not-allowed; box-shadow: none; transform: none; }
.modal-footer .btn-secondary { background: #f3f4f6; color: #374151; border: 2px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.modal-footer .btn-secondary:hover { background: #e5e7eb; }
.modal-footer .btn-load-more { background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.modal-footer .btn-load-more:hover { background: #2563eb; }
.modal-footer .btn-load-more:disabled { background: #9ca3af; cursor: wait; }
/* Expanded Detail View */
.approval-item .expand-btn { position: absolute; top: 12px; right: 12px; background: #f3f4f6; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 4px 10px; border-radius: 6px; z-index: 10; }
.approval-item .expand-btn:hover { background: #e5e7eb; color: #1f2937; }
.approval-item.expanded { grid-column: 1 / -1; overflow: visible; margin-left: 0; margin-right: 0; }
.approval-item .detail-panel { display: none; width: 100%; margin-top: 15px; margin-left: 0; margin-right: 0; padding-top: 15px; padding-left: 0; padding-right: 0; border-top: 1px solid #e5e7eb; box-sizing: border-box; overflow: visible; }
.approval-item.expanded .detail-panel { display: block; }
.detail-panel .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; width: 100%; box-sizing: border-box; }
.detail-panel .detail-section { background: #f9fafb; border-radius: 8px; padding: 15px; min-width: 0; overflow-wrap: break-word; }
.detail-panel .detail-section.current { border-left: 4px solid #ef4444; margin-left: 0; }
.detail-panel .detail-section.proposed { border-left: 4px solid #10b981; margin-left: 0; }
.detail-panel .detail-section h4 { font-size: 12px; text-transform: uppercase; color: #6b7280; margin-bottom: 10px; font-weight: 700; }
.detail-panel .detail-section.current h4 { color: #ef4444; }
.detail-panel .detail-section.proposed h4 { color: #10b981; }
.detail-panel .detail-row { margin-bottom: 10px; }
.detail-panel .detail-row label { display: block; font-size: 11px; color: #6b7280; margin-bottom: 4px; font-weight: 600; }
.detail-panel .detail-row .value { font-size: 13px; color: #1f2937; padding: 8px; background: #ffffff; border-radius: 4px; border: 1px solid #e5e7eb; word-break: break-word; }
.detail-panel .detail-row input, .detail-panel .detail-row textarea { width: 100%; padding: 8px; font-size: 13px; border: 2px solid #e5e7eb; border-radius: 6px; background: #ffffff; color: #1f2937; }
.detail-panel .detail-row input:focus, .detail-panel .detail-row textarea:focus { outline: none; border-color: #10b981; }
.detail-panel .detail-row textarea { min-height: 80px; resize: vertical; }
.detail-panel .override-indicator { display: inline-block; background: #fbbf24; color: #1f2937; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-left: 8px; }
.detail-panel .reset-btn { background: #f3f4f6; border: 1px solid #e5e7eb; color: #6b7280; font-size: 11px; padding: 4px 8px; border-radius: 4px; cursor: pointer; margin-left: 8px; }
.detail-panel .reset-btn:hover { background: #e5e7eb; }
@media (max-width: 800px) { .detail-panel .detail-grid { grid-template-columns: 1fr; } }
/* Make detail panel more visible */
.approval-item { min-height: 200px; }
.approval-item .detail-panel { max-height: none; }
.detail-panel .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 1000px) {
.detail-panel .detail-grid { grid-template-columns: 1fr; }
.approval-item.expanded { min-height: auto; }
}
.approval-item.fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 20000; background: #ffffff; overflow-y: auto; margin: 0; border-radius: 0; max-width: 100%; }
.approval-item.fullscreen .detail-panel { display: block !important; }
.fullscreen-btn { position: absolute; top: 12px; right: 120px; background: #3b82f6; border: none; color: white; font-size: 12px; cursor: pointer; padding: 4px 10px; border-radius: 6px; z-index: 10; }
.fullscreen-btn:hover { background: #2563eb; }
/* Make detail panel more prominent and visible */
.approval-item { min-height: 180px; }
.approval-item .detail-panel {
border: 2px dashed #e5e7eb;
background: #fafbfc;
margin-top: 20px;
}
.approval-item.expanded .detail-panel {
border-color: #10b981;
background: #f0fdf4;
}
.detail-panel .detail-section {
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
/* Make expand button more prominent */
.approval-item .expand-btn {
background: #10b981 !important;
color: white !important;
font-weight: 600;
}
.approval-item .expand-btn:hover {
background: #059669 !important;
}
</style>
</head>
<body>
${getUniversalHeader('Architectural Update Agent', theme)}
<div class="container">
<div class="header">
<h1>Architectural Wallcoverings Updater</h1>
<span id="statusBadge" class="status-badge status-idle">IDLE</span>
</div>
<div class="grid">
<!-- Left Column: Configuration -->
<div>
<div class="card">
<h2>Configuration</h2>
<div class="form-group" style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid var(--accent);">
<label>Update Skill</label>
<div style="display: flex; gap: 10px; margin-top: 8px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: var(--bg-primary); border: 2px solid var(--accent); border-radius: 8px; flex: 1; justify-content: center;" id="skillArchLabel">
<input type="radio" name="skillType" value="architectural" checked onchange="setSkill('architectural')" style="accent-color: var(--accent);">
<span style="font-weight: 600;">Architectural</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: var(--bg-primary); border: 2px solid var(--border); border-radius: 8px; flex: 1; justify-content: center;" id="skillResLabel">
<input type="radio" name="skillType" value="residential" onchange="setSkill('residential')" style="accent-color: #10b981;">
<span style="font-weight: 600;">Residential</span>
</label>
</div>
<small id="skillDescription" style="color: var(--text-secondary); margin-top: 8px; display: block;">
Commercial/specification-grade materials for architects and designers
</small>
</div>
<div class="form-group">
<label style="display: flex; justify-content: space-between; align-items: center;">
<span>Select Vendor</span>
<button onclick="refreshCache()" style="font-size: 12px; padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; color: var(--text-secondary);">↻ Refresh</button>
</label>
<select id="vendorSelect">
<option value="">-- Loading vendors... --</option>
</select>
</div>
<div class="form-group">
<label>Or Select Collection</label>
<select id="collectionSelect">
<option value="">-- Loading collections... --</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--border);">
<label>Or Paste Product URL</label>
<div style="display: flex; gap: 8px;">
<input type="text" id="productUrl" placeholder="https://designerwallcoverings.com/... or Shopify admin URL" style="flex: 1; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text-primary);">
<button class="btn btn-primary" onclick="updateSingleProduct()" style="white-space: nowrap;">Update Product</button>
</div>
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">
Paste a product URL to update just that one product
</small>
</div>
<div class="form-group" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--border);">
<label>Batch Size</label>
<select id="batchSize">
<option value="1">1 product</option>
<option value="2">2 products</option>
<option value="5">5 products</option>
<option value="10" selected>10 products</option>
<option value="20">20 products</option>
<option value="30">30 products</option>
<option value="50">50 products</option>
<option value="100">100 products</option>
<option value="250">250 products</option>
<option value="500">500 products</option>
<option value="all">All products</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="syncImages" checked style="width: 18px; height: 18px; accent-color: var(--accent);">
<span>Sync images from manufacturer website</span>
</label>
<small style="color: var(--text-secondary); margin-left: 28px; display: block; margin-top: 4px;">
Scrapes and updates product images from manufacturer website
</small>
</div>
<div class="specs-box">
<strong>Update Specifications:</strong><br><br>
<strong>Title:</strong> <code>Pattern Name Color (mfr#) | Architectural Wallcoverings</code><br>
<strong>No MFR#:</strong> <code>Pattern Name Color | Architectural Wallcoverings</code><br>
<strong>Category:</strong> <code>Building Materials</code><br>
<strong>Type:</strong> <code>Architectural Wallcoverings</code><br>
<strong>Sample:</strong> <code>$4.25</code><br>
<strong>Tags:</strong> <code>Pattern Name + Color + MFR SKU + AI color analysis</code><br>
<strong>Images:</strong> <code>Synced from manufacturer website</code>
</div>
<div class="btn-group">
<button id="btnPreview" 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>
<!-- Right Column: Live Progress -->
<div>
<!-- Live Status Card -->
<div class="card" style="margin-bottom: 20px;">
<h2 style="display: flex; align-items: center; gap: 10px;">
<span id="liveIndicator" style="width: 12px; height: 12px; background: #10b981; border-radius: 50%; animation: pulse 2s infinite;"></span>
Live Status
</h2>
<div id="liveStatus" style="padding: 15px; background: var(--surface-alt, #f1f5f9); border-radius: 8px; font-family: monospace; font-size: 13px; max-height: 200px; overflow-y: auto;">
<div style="color: var(--text-secondary);">Initializing...</div>
</div>
</div>
<!-- Progress Card -->
<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>
<!-- History - Full Width -->
<div class="card" style="margin-top: 20px;">
<h2 class="history-toggle" onclick="toggleHistory()">
<span id="historyArrow">▶</span> Process History
</h2>
<div id="historyContent" class="history-content"></div>
</div>
</div>
<!-- Approval Modal -->
<div id="approvalModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Preview Products for Approval</h2>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="select-all-bar">
<label>
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
<span>Select All</span>
</label>
<div class="batch-sizes">
<button class="batch-btn" onclick="selectBatch(50)">50</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>
</div>
<button class="batch-btn" onclick="unselectAlreadyRun()" style="background: #ef4444; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer;" title="Unselect products that were already processed">Unselect Already Run</button>
<span class="selected-count"><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 id="loadMoreBtn" class="btn btn-load-more" onclick="loadMoreProducts()" style="display: none;">Load +5 More</button>
<button id="approveBtn" class="btn btn-primary" onclick="approveAndStart()">Approve & Start Update</button>
</div>
</div>
</div>
<script>
let pollInterval = null;
let startTime = null;
let previewProducts = [];
let selectedProductIds = new Set();
let productOverrides = {}; // Store user overrides: { productId: { title: '', pattern: '', color: '', mfrSku: '', description: '' } }
let currentOffset = 0;
let totalProducts = 0;
let currentFilterType = '';
let currentFilterValue = '';
// Toggle expand/collapse detail panel
function toggleExpand(productId) {
const item = document.querySelector('.approval-item[data-id="' + productId + '"]');
const btn = item?.querySelector('.expand-btn');
if (item) {
if (item.classList.contains('expanded')) {
item.classList.remove('expanded');
if (btn) btn.textContent = 'Details ▼';
} else {
// Collapse all others first
document.querySelectorAll('.approval-item.expanded').forEach(function(el) {
el.classList.remove('expanded');
const otherBtn = el.querySelector('.expand-btn');
if (otherBtn) otherBtn.textContent = 'Details ▼';
});
item.classList.add('expanded');
if (btn) btn.textContent = 'Details ▲';
}
}
}
function toggleFullscreen(productId) {
const item = document.querySelector('.approval-item[data-id="' + productId + '"]');
if (item) {
if (item.classList.contains('fullscreen')) {
item.classList.remove('fullscreen');
document.body.style.overflow = '';
} else {
// Remove fullscreen from all other items
document.querySelectorAll('.approval-item.fullscreen').forEach(function(el) {
el.classList.remove('fullscreen');
});
item.classList.add('fullscreen');
document.body.style.overflow = 'hidden';
}
}
}
// Set override for a product field
function setOverride(productId, field, value) {
if (!productOverrides[productId]) {
productOverrides[productId] = {};
}
const product = previewProducts.find(p => p.id == productId);
const originalValue = getOriginalValue(product, field);
if (value !== originalValue) {
productOverrides[productId][field] = value;
const indicator = document.getElementById(field + 'Override_' + productId);
if (indicator) indicator.innerHTML = '<span class="override-indicator">MODIFIED</span>';
} else {
delete productOverrides[productId][field];
const indicator = document.getElementById(field + 'Override_' + productId);
if (indicator) indicator.innerHTML = '';
// Clean up empty override objects
if (Object.keys(productOverrides[productId]).length === 0) {
delete productOverrides[productId];
}
}
log('Override set for product ' + productId + ': ' + field + ' = ' + value.substring(0, 50), 'info');
}
// Get original proposed value for comparison
function getOriginalValue(product, field) {
if (!product) return '';
switch(field) {
case 'title': return product.preview?.newTitle || '';
case 'pattern': return product.preview?.pattern || '';
case 'color': return product.preview?.color || '';
case 'mfrSku': return product.preview?.mfrSku || product.variants?.[0]?.sku || '';
case 'description': return product.preview?.newDescription || 'Architectural Wallcoverings - Premium wallcovering solution for commercial and hospitality applications.';
case 'tags': return product.tags || '';
case 'handle': return product.handle || '';
case 'artist': return product.preview?.artist || '';
case 'tags': return product.tags || '';
default: return '';
}
}
async function setSkill(skill) {
try {
const res = await fetch('/api/set-skill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skill })
});
const data = await res.json();
if (data.success) {
// Update UI
const archLabel = document.getElementById('skillArchLabel');
const resLabel = document.getElementById('skillResLabel');
const desc = document.getElementById('skillDescription');
if (skill === 'architectural') {
archLabel.style.borderColor = 'var(--accent)';
resLabel.style.borderColor = 'var(--border)';
desc.textContent = 'Commercial/specification-grade materials for architects and designers';
} else {
archLabel.style.borderColor = 'var(--border)';
resLabel.style.borderColor = '#10b981';
desc.textContent = 'Premium designer materials for residential and boutique interiors';
}
log('Skill set to: ' + skill.charAt(0).toUpperCase() + skill.slice(1), 'success');
}
} catch (e) {
log('Error setting skill: ' + e.message, 'error');
}
}
window.onload = function() {
// Show initial status
var liveEl = document.getElementById('liveStatus');
if (liveEl) liveEl.innerHTML = '<div style="color:#f59e0b;">> Loading vendors and collections...</div>';
loadVendors();
loadCollections();
loadHistory();
pollStatus();
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
});
// Clear other dropdown when one is selected
document.getElementById('vendorSelect').addEventListener('change', function() {
if (this.value) document.getElementById('collectionSelect').value = '';
});
document.getElementById('collectionSelect').addEventListener('change', function() {
if (this.value) document.getElementById('vendorSelect').value = '';
});
};
async function loadVendors() {
const select = document.getElementById('vendorSelect');
select.innerHTML = '<option value="">-- Loading vendors... --</option>';
try {
const res = await fetch('/api/vendors', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (data.error) throw new Error(data.error);
select.innerHTML = '<option value="">-- Select a vendor (' + (data.vendorCount || data.vendors?.length || 0) + ' available) --</option>';
(data.vendors || []).forEach(v => {
select.innerHTML += '<option value="' + v.name + '">' + v.name + ' (' + v.count + ')</option>';
});
log('Loaded ' + (data.vendorCount || data.vendors?.length) + ' vendors', 'success');
} catch (e) {
select.innerHTML = '<option value="">-- Error loading vendors --</option>';
log('Failed to load vendors: ' + e.message, 'error');
console.error('Vendors error:', e);
}
}
async function loadCollections() {
const select = document.getElementById('collectionSelect');
select.innerHTML = '<option value="">-- Loading collections... --</option>';
try {
const res = await fetch('/api/collections', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (data.error) throw new Error(data.error);
select.innerHTML = '<option value="">-- Select a collection (' + (data.collections?.length || 0) + ' available) --</option>';
(data.collections || []).forEach(c => {
select.innerHTML += '<option value="' + c.id + '">' + c.title + ' (' + c.count + ')</option>';
});
log('Loaded ' + (data.collections?.length || 0) + ' collections', 'success');
} catch (e) {
select.innerHTML = '<option value="">-- Error loading collections --</option>';
log('Failed to load collections: ' + e.message, 'error');
console.error('Collections error:', e);
}
}
async function loadHistory() {
try {
const res = await fetch('/api/history');
const data = await res.json();
const container = document.getElementById('historyContent');
if (!data.history || data.history.length === 0) {
container.innerHTML = '<p style="color:var(--text-secondary);">No history yet.</p>';
return;
}
container.innerHTML = data.history.map((h, idx) => {
const statusColor = h.status === 'completed' ? '#10b981' : h.status === 'stopped' ? '#f59e0b' : '#6b7280';
const statusLabel = h.status === 'completed' ? 'Completed' : h.status === 'stopped' ? 'Stopped' : 'Unknown';
// Build products grid if products data exists
let productsHtml = '';
if (h.products && h.products.length > 0) {
const productCards = h.products.map(p => {
const cardClass = p.status === 'completed' ? 'completed' : p.status === 'error' ? 'error' : '';
const oldTitle = p.oldTitle ? '<div class="history-product-card-old">Old: ' + (p.oldTitle || '').substring(0, 60) + (p.oldTitle && p.oldTitle.length > 60 ? '...' : '') + '</div>' : '';
const newTitle = p.newTitle ? '<div class="history-product-card-new">New: ' + (p.newTitle || '').substring(0, 60) + (p.newTitle && p.newTitle.length > 60 ? '...' : '') + '</div>' : '';
const details = [];
if (p.pattern) details.push('Pattern: ' + p.pattern);
if (p.color) details.push('Color: ' + p.color);
if (p.mfrSku) details.push('MFR: ' + p.mfrSku);
if (p.imagesUpdated) details.push('Images: ' + (p.imageCount || 0) + ' synced');
const detailsHtml = details.length > 0 ? '<div class="history-product-card-details">' + details.join(' | ') + '</div>' : '';
const errorHtml = p.error ? '<div style="color:#e74c3c;font-size:10px;margin-top:4px;">Error: ' + p.error.substring(0, 50) + '</div>' : '';
return '<div class="history-product-card ' + cardClass + '">' +
'<div class="history-product-card-sku">SKU: ' + (p.sku || 'N/A') + '</div>' +
oldTitle +
newTitle +
detailsHtml +
errorHtml +
'</div>';
}).join('');
productsHtml = '<div class="history-toggle-products" onclick="toggleHistoryProducts(' + idx + ')">' +
'<span id="historyProductsArrow' + idx + '">▶</span> Show ' + h.products.length + ' Products' +
'</div>' +
'<div id="historyProducts' + idx + '" class="history-products-grid" style="display:none;">' +
productCards +
'</div>';
}
return '<div class="history-item">' +
'<div class="history-item-header">' +
'<div>' +
'<span style="color:var(--text-secondary);font-size:13px;">' + new Date(h.date).toLocaleString() + '</span>' +
'<span style="color:var(--accent);font-weight:600;font-size:16px;margin-left:12px;">' + (h.vendor || 'Unknown Vendor') + '</span>' +
'<span style="background:' + statusColor + ';color:white;padding:2px 8px;border-radius:4px;font-size:10px;margin-left:10px;">' + statusLabel + '</span>' +
'</div>' +
'<div class="history-item-stats">' +
'<div class="history-item-stat"><div class="history-item-stat-value">' + h.processed + '</div><div class="history-item-stat-label">Processed</div></div>' +
'<div class="history-item-stat"><div class="history-item-stat-value">' + h.total + '</div><div class="history-item-stat-label">Total</div></div>' +
'<div class="history-item-stat"><div class="history-item-stat-value" style="color:' + (h.errors > 0 ? '#e74c3c' : 'var(--accent)') + ';">' + h.errors + '</div><div class="history-item-stat-label">Errors</div></div>' +
(h.syncImages !== undefined ? '<div class="history-item-stat"><div class="history-item-stat-value" style="font-size:14px;">' + (h.syncImages ? 'Yes' : 'No') + '</div><div class="history-item-stat-label">Images</div></div>' : '') +
'</div>' +
'</div>' +
productsHtml +
'</div>';
}).join('');
} catch (e) { console.error('Error loading history:', e); }
}
function toggleHistoryProducts(idx) {
const grid = document.getElementById('historyProducts' + idx);
const arrow = document.getElementById('historyProductsArrow' + idx);
if (grid && arrow) {
const isHidden = grid.style.display === 'none';
grid.style.display = isHidden ? 'grid' : 'none';
arrow.innerHTML = isHidden ? '▼' : '▶';
}
}
function toggleHistory() {
const content = document.getElementById('historyContent');
const arrow = document.getElementById('historyArrow');
content.classList.toggle('open');
arrow.innerHTML = content.classList.contains('open') ? '▼' : '▶';
}
async function showPreviewModal() {
const vendor = document.getElementById('vendorSelect').value;
const collection = document.getElementById('collectionSelect').value;
if (!vendor && !collection) { alert('Select a vendor or collection first'); return; }
const batchSize = document.getElementById('batchSize').value;
const filterType = vendor ? 'vendor' : 'collection';
const filterValue = vendor || collection;
// Store current filter for load more
currentFilterType = filterType;
currentFilterValue = filterValue;
currentOffset = 0;
previewProducts = [];
selectedProductIds = new Set();
log('Loading preview for ' + filterType + ': ' + filterValue + '...', 'info');
try {
const res = await fetch('/api/preview?' + filterType + '=' + encodeURIComponent(filterValue) + '&limit=' + batchSize + '&offset=0');
const data = await res.json();
previewProducts = data.products || [];
totalProducts = data.total || 0;
currentOffset = previewProducts.length;
selectedProductIds = new Set(previewProducts.map(p => p.id));
document.getElementById('modalTitle').textContent = 'Preview: ' + filterValue + ' (' + data.total + ' total)';
updateLoadMoreButton();
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 || p.image || 'https://via.placeholder.com/120?text=No+Image';
const oldSku = p.variants?.[0]?.sku || 'N/A';
const newTitle = p.preview?.newTitle || 'Processing...';
const pattern = p.preview?.pattern || '—';
const color = p.preview?.color || '—';
const mfrSku = p.preview?.mfrSku || oldSku;
const oldDesc = (p.body_html || p.description || '').replace(/<[^>]*>/g, '').substring(0, 200) || 'No description';
const newDesc = p.preview?.newDescription || 'Architectural Wallcoverings - Premium wallcovering solution for commercial and hospitality applications.';
const prevUpdatedClass = p.previouslyUpdated ? ' previously-updated' : '';
const prevUpdatedBadge = p.previouslyUpdated && p.previouslyUpdatedAt
? '<div class="prev-updated-badge"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>Updated: ' + new Date(p.previouslyUpdatedAt).toLocaleString() + '</div>'
: '';
return '<div class="approval-item selected' + prevUpdatedClass + '" data-id="' + p.id + '" onclick="toggleProduct(' + p.id + ')">' +
'<input type="checkbox" checked onclick="event.stopPropagation(); toggleProduct(' + p.id + ')">' +
'<button class="expand-btn" onclick="event.stopPropagation(); toggleExpand(' + p.id + ')">Details ▲</button>' +
'<button class="fullscreen-btn" onclick="event.stopPropagation(); toggleFullscreen(' + p.id + ')" style="position: absolute; top: 12px; right: 120px; background: #3b82f6; border: none; color: white; font-size: 12px; cursor: pointer; padding: 4px 10px; border-radius: 6px; z-index: 10;">⛶ Fullscreen</button>' +
'<img src="' + img + '" alt="" onerror="this.src='https://via.placeholder.com/120?text=No+Image'">' +
'<div class="info-panel">' +
prevUpdatedBadge +
'<div class="before-after">' +
'<div class="section before">' +
'<div class="section-label">CURRENT</div>' +
'<div class="title">' + (p.title || 'Untitled') + '</div>' +
'<div class="details"><span>SKU: ' + oldSku + '</span></div>' +
'</div>' +
'<div class="section after">' +
'<div class="section-label">PROPOSED</div>' +
'<div class="title">' + newTitle + '</div>' +
'<div class="details">' +
'<span>Pattern: <b>' + pattern + '</b></span>' +
'<span>Color: <b>' + color + '</b></span>' +
'</div>' +
'<div class="sku">MFR#: ' + mfrSku + '</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="detail-panel">' +
'<div class="detail-grid">' +
'<div class="detail-section current">' +
'<h4>Current Data</h4>' +
'<div class="detail-row"><label>Title</label><div class="value">' + (p.title || 'Untitled') + '</div></div>' +
'<div class="detail-row"><label>Description</label><div class="value">' + oldDesc + '</div></div>' +
'<div class="detail-row"><label>SKU</label><div class="value">' + oldSku + '</div></div>' +
'<div class="detail-row"><label>Tags</label><div class="value" style="font-size:11px;">' + (p.tags || 'None') + '</div></div>' +
'<div class="detail-row"><label>Shopify Link</label><div class="value"><a href="' + (p.preview?.shopifyLink || (p.handle ? 'https://designerwallcoverings.com/products/' + p.handle : '#')) + '" target="_blank" style="color: #10b981; text-decoration: underline; word-break: break-all;">View Product</a></div></div>' +
'</div>' +
'<div class="detail-section proposed">' +
'<h4>Proposed Updates <span style="font-size:10px;color:#6b7280;">(editable)</span></h4>' +
'<div class="detail-row"><label>Title <span id="titleOverride_' + p.id + '"></span></label><input type="text" id="editTitle_' + p.id + '" value="' + newTitle.replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'title', this.value)" onclick="event.stopPropagation()"></div>' +
'<div class="detail-row"><label>Pattern <span id="patternOverride_' + p.id + '"></span></label><input type="text" id="editPattern_' + p.id + '" value="' + pattern.replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'pattern', this.value)" onclick="event.stopPropagation()"></div>' +
'<div class="detail-row"><label>Color <span id="colorOverride_' + p.id + '"></span></label><input type="text" id="editColor_' + p.id + '" value="' + color.replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'color', this.value)" onclick="event.stopPropagation()"></div>' +
'<div class="detail-row"><label>MFR SKU <span id="skuOverride_' + p.id + '"></span></label><input type="text" id="editSku_' + p.id + '" value="' + mfrSku.replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'mfrSku', this.value)" onclick="event.stopPropagation()"></div>' +
'<div class="detail-row"><label>Description</label><textarea id="editDesc_' + p.id + '" onchange="setOverride(' + p.id + ', 'description', this.value)" onclick="event.stopPropagation()">' + newDesc + '</textarea></div>' +
'<div class="detail-row"><label>Shopify Link</label><div class="value"><a href="' + (p.preview?.shopifyLink || (p.handle ? 'https://designerwallcoverings.com/products/' + p.handle : '#')) + '" target="_blank" style="color: #10b981; text-decoration: underline; word-break: break-all;">View Product</a></div></div>' +
'<div class="detail-row"><label>Tags <span id="tagsOverride_' + p.id + '"></span></label><input type="text" id="editTags_' + p.id + '" value="' + (p.tags || '').replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'tags', this.value)" onclick="event.stopPropagation()" style="width: 100%;"></div>' +
'<div class="detail-row"><label>Handle <span id="handleOverride_' + p.id + '"></span></label><input type="text" id="editHandle_' + p.id + '" value="' + (p.handle || '').replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'handle', this.value)" onclick="event.stopPropagation()"></div>' +
'<div class="detail-row"><label>Images</label><div class="value">' + (p.images?.length || 0) + ' image(s) - <a href="#" onclick="event.stopPropagation(); alert('Images will be synced from manufacturer website if enabled')" style="color: #10b981;">Info</a></div></div>' +
'<div class="detail-row" id="artistRow_' + p.id + '" style="display: ' + (p.preview?.isArtwork ? 'block' : 'none') + ';"><label>Artist <span id="artistOverride_' + p.id + '"></span></label><input type="text" id="editArtist_' + p.id + '" value="' + (p.preview?.artist || '').replace(/"/g, '"') + '" onchange="setOverride(' + p.id + ', 'artist', this.value)" onclick="event.stopPropagation()"></div>' +
'</div>' +
'<div class="detail-section ai-data" style="background: linear-gradient(135deg, #eff6ff, #dbeafe); border: 1px solid #3b82f6; border-radius: 8px; margin-top: 12px;">' +
'<h4 style="color: #1d4ed8; margin-bottom: 10px;">🤖 AI Generated Data (from Shopify Metafields)</h4>' +
'<div class="detail-row"><label style="color: #1e40af;">Manufacturer SKU</label><div class="value" style="font-family: monospace; font-weight: bold; font-size: 14px; color: #1d4ed8; cursor: pointer;" onclick="navigator.clipboard.writeText('' + (p.preview?.manufacturerSku || '') + ''); this.style.background='#bbf7d0'; setTimeout(() => this.style.background='', 500);">' + (p.preview?.manufacturerSku || '—') + ' 📋</div></div>' +
'<div class="detail-row"><label style="color: #1e40af;">AI Colors</label><div class="value" style="cursor: pointer;" onclick="document.getElementById('editColor_' + p.id + '').value = '' + (p.preview?.aiColors?.split(',')?.[0]?.trim() || '') + ''; setOverride(' + p.id + ', 'color', '' + (p.preview?.aiColors?.split(',')?.[0]?.trim() || '') + ''); this.style.background='#bbf7d0'; setTimeout(() => this.style.background='', 500);">' + (p.preview?.aiColors || '—') + ' <span style="color:#10b981;">← click to use</span></div></div>' +
'<div class="detail-row"><label style="color: #1e40af;">AI Tags</label><div class="value" style="font-size: 10px; max-height: 60px; overflow-y: auto;">' + (p.preview?.aiTags || '—') + '</div></div>' +
'<div class="detail-row"><label style="color: #1e40af;">AI Description</label><div class="value" style="font-size: 11px; max-height: 100px; overflow-y: auto;">' + (p.preview?.aiDescription?.replace(/<[^>]*>/g, ' ').substring(0, 300) || '—') + '...</div></div>' +
'<div class="detail-row"><label style="color: #1e40af;">Source URL</label><div class="value">' + (p.preview?.sourceUrl ? '<a href="' + p.preview.sourceUrl + '" target="_blank" style="color: #3b82f6; text-decoration: underline;">View on Manufacturer Site ↗</a>' : '—') + '</div></div>' +
'<div class="detail-row"><label style="color: #1e40af;">All Metafields (' + (p.preview?.allMetafields?.length || 0) + ')</label><div class="value" style="font-size: 10px; max-height: 150px; overflow-y: auto; font-family: monospace; background: #f8fafc; padding: 8px; border-radius: 4px;">' + (p.preview?.allMetafieldsHtml || '<em>No metafields</em>') + '</div></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
document.getElementById('approvalModal').classList.add('open');
log('Loaded ' + previewProducts.length + ' products for approval', 'success');
} catch (e) {
log('Preview failed: ' + e.message, 'error');
}
}
function closeModal() {
document.getElementById('approvalModal').classList.remove('open');
}
function updateLoadMoreButton() {
const btn = document.getElementById('loadMoreBtn');
const remaining = totalProducts - currentOffset;
if (remaining > 0) {
btn.style.display = 'inline-block';
btn.textContent = 'Load +5 More (' + remaining + ' remaining)';
} else {
btn.style.display = 'none';
}
}
async function loadMoreProducts() {
const loadMoreBtn = document.getElementById('loadMoreBtn');
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading...';
try {
const res = await fetch('/api/preview?' + currentFilterType + '=' + encodeURIComponent(currentFilterValue) + '&limit=5&offset=' + currentOffset);
const data = await res.json();
const newProducts = data.products || [];
if (newProducts.length > 0) {
// Add new products to array
newProducts.forEach(p => {
if (!previewProducts.find(existing => existing.id === p.id)) {
previewProducts.push(p);
selectedProductIds.add(p.id);
}
});
currentOffset += newProducts.length;
// Append to grid
const grid = document.getElementById('approvalGrid');
newProducts.forEach(function(p) {
const img = p.images?.[0]?.src || p.image || 'https://via.placeholder.com/120?text=No+Image';
const oldSku = p.variants?.[0]?.sku || 'N/A';
const newTitle = p.preview?.newTitle || 'Processing...';
const pattern = p.preview?.pattern || '—';
const color = p.preview?.color || '—';
const mfrSku = p.preview?.mfrSku || oldSku;
const prevUpdatedClass = p.previouslyUpdated ? ' previously-updated' : '';
const prevUpdatedBadge = p.previouslyUpdated && p.previouslyUpdatedAt
? '<div class="prev-updated-badge"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>Updated: ' + new Date(p.previouslyUpdatedAt).toLocaleString() + '</div>'
: '';
const html = '<div class="approval-item selected' + prevUpdatedClass + '" data-id="' + p.id + '" onclick="toggleProduct(' + p.id + ')">' +
'<input type="checkbox" checked onclick="event.stopPropagation(); toggleProduct(' + p.id + ')">' +
'<img src="' + img + '" alt="" onerror="this.src='https://via.placeholder.com/120?text=No+Image'">' +
'<div class="info-panel">' +
prevUpdatedBadge +
'<div class="before-after">' +
'<div class="section before">' +
'<div class="section-label">BEFORE</div>' +
'<div class="title">' + (p.title || 'Untitled') + '</div>' +
'<div class="details"><span>SKU: ' + oldSku + '</span></div>' +
'</div>' +
'<div class="section after">' +
'<div class="section-label">AFTER</div>' +
'<div class="title">' + newTitle + '</div>' +
'<div class="details">' +
'<span>Pattern: <b>' + pattern + '</b></span>' +
'<span>Color: <b>' + color + '</b></span>' +
'</div>' +
'<div class="sku">MFR#: ' + mfrSku + '</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
grid.insertAdjacentHTML('beforeend', html);
});
document.getElementById('totalCount').textContent = previewProducts.length;
document.getElementById('selectedCount').textContent = selectedProductIds.size;
log('Loaded ' + newProducts.length + ' more products (total: ' + previewProducts.length + ')', 'success');
}
updateLoadMoreButton();
} catch (e) {
log('Failed to load more: ' + e.message, 'error');
} finally {
loadMoreBtn.disabled = false;
}
}
function toggleProduct(id) {
const item = document.querySelector('.approval-item[data-id="' + id + '"]');
const checkbox = item?.querySelector('input[type="checkbox"]');
if (selectedProductIds.has(id)) {
selectedProductIds.delete(id);
item?.classList.remove('selected');
if (checkbox) checkbox.checked = false;
} else {
selectedProductIds.add(id);
item?.classList.add('selected');
if (checkbox) checkbox.checked = true;
}
updateSelectedCount();
}
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll').checked;
document.querySelectorAll('.approval-item').forEach(item => {
const id = parseInt(item.dataset.id);
const checkbox = item.querySelector('input[type="checkbox"]');
if (selectAll) {
selectedProductIds.add(id);
item.classList.add('selected');
if (checkbox) checkbox.checked = true;
} else {
selectedProductIds.delete(id);
item.classList.remove('selected');
if (checkbox) checkbox.checked = false;
}
});
updateSelectedCount();
}
async function selectBatch(count) {
// If we need more products than currently loaded, fetch them first
if (count > previewProducts.length && currentOffset < totalProducts) {
const needed = Math.min(count - previewProducts.length, totalProducts - currentOffset);
log('Loading ' + needed + ' more products to reach ' + count + '...', 'info');
try {
const res = await fetch('/api/preview?' + currentFilterType + '=' + encodeURIComponent(currentFilterValue) + '&limit=' + needed + '&offset=' + currentOffset);
const data = await res.json();
const newProducts = data.products || [];
if (newProducts.length > 0) {
const grid = document.getElementById('approvalGrid');
newProducts.forEach(function(p) {
if (!previewProducts.find(existing => existing.id === p.id)) {
previewProducts.push(p);
const img = p.images?.[0]?.src || p.image || 'https://via.placeholder.com/120?text=No+Image';
const oldSku = p.variants?.[0]?.sku || 'N/A';
const newTitle = p.preview?.newTitle || 'Processing...';
const pattern = p.preview?.pattern || '—';
const color = p.preview?.color || '—';
const mfrSku = p.preview?.mfrSku || oldSku;
const prevUpdatedClass = p.previouslyUpdated ? ' previously-updated' : '';
const prevUpdatedBadge = p.previouslyUpdated && p.previouslyUpdatedAt
? '<div class="prev-updated-badge"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>Updated: ' + new Date(p.previouslyUpdatedAt).toLocaleString() + '</div>'
: '';
const html = '<div class="approval-item" data-id="' + p.id + '" onclick="toggleProduct(' + p.id + ')">' +
'<input type="checkbox" onclick="event.stopPropagation(); toggleProduct(' + p.id + ')">' +
'<img src="' + img + '" alt="" onerror="this.src='https://via.placeholder.com/120?text=No+Image'">' +
'<div class="info-panel">' +
prevUpdatedBadge +
'<div class="before-after">' +
'<div class="section before">' +
'<div class="section-label">BEFORE</div>' +
'<div class="title">' + (p.title || 'Untitled') + '</div>' +
'<div class="details"><span>SKU: ' + oldSku + '</span></div>' +
'</div>' +
'<div class="section after">' +
'<div class="section-label">AFTER</div>' +
'<div class="title">' + newTitle + '</div>' +
'<div class="details">' +
'<span>Pattern: <b>' + pattern + '</b></span>' +
'<span>Color: <b>' + color + '</b></span>' +
'</div>' +
'<div class="sku">MFR#: ' + mfrSku + '</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
grid.insertAdjacentHTML('beforeend', html);
}
});
currentOffset += newProducts.length;
document.getElementById('totalCount').textContent = previewProducts.length;
updateLoadMoreButton();
log('Loaded ' + newProducts.length + ' more products', 'success');
}
} catch (e) {
log('Failed to load more products: ' + e.message, 'error');
}
}
// Now select the requested count
selectedProductIds.clear();
document.querySelectorAll('.approval-item').forEach((item, i) => {
const id = parseInt(item.dataset.id);
const checkbox = item.querySelector('input[type="checkbox"]');
if (i < count) {
selectedProductIds.add(id);
item.classList.add('selected');
if (checkbox) checkbox.checked = true;
} else {
item.classList.remove('selected');
if (checkbox) checkbox.checked = false;
}
});
document.getElementById('selectAll').checked = selectedProductIds.size === previewProducts.length;
updateSelectedCount();
}
// Unselect products that were already processed (from history)
async function unselectAlreadyRun() {
try {
log('Fetching process history to find already processed products...', 'info');
const res = await fetch('/api/history');
const data = await res.json();
// Collect all product IDs that were processed successfully
const processedIds = new Set();
(data.history || []).forEach(entry => {
if (entry.products && Array.isArray(entry.products)) {
entry.products.forEach(p => {
if (p.id && p.status === 'completed') {
processedIds.add(String(p.id));
}
});
}
});
if (processedIds.size === 0) {
log('No processed products found in history', 'info');
return;
}
log('Found ' + processedIds.size + ' already processed products', 'info');
// Unselect products that are in the processedIds set
let unselectedCount = 0;
document.querySelectorAll('.approval-item').forEach(item => {
const id = item.dataset.id;
if (processedIds.has(id)) {
selectedProductIds.delete(parseInt(id));
item.classList.remove('selected');
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = false;
unselectedCount++;
}
});
document.getElementById('selectAll').checked = false;
updateSelectedCount();
log('Unselected ' + unselectedCount + ' already processed products', 'success');
} catch (e) {
log('Failed to unselect already run products: ' + e.message, 'error');
}
}
function updateSelectedCount() {
const selectedCountEl = document.getElementById('selectedCount');
const approveBtn = document.getElementById('approveBtn');
if (selectedCountEl) {
selectedCountEl.textContent = selectedProductIds.size;
}
const selectAllEl = document.getElementById('selectAll');
if (selectAllEl) {
selectAllEl.checked = selectedProductIds.size === previewProducts.length;
}
if (approveBtn) {
approveBtn.disabled = selectedProductIds.size === 0;
if (selectedProductIds.size > 0) {
approveBtn.style.opacity = '1';
approveBtn.style.cursor = 'pointer';
} else {
approveBtn.style.opacity = '0.5';
approveBtn.style.cursor = 'not-allowed';
}
}
}
async function approveAndStart() {
if (selectedProductIds.size === 0) {
alert('Please select at least one product to update');
return;
}
// Disable button and show loading state
const approveBtn = document.getElementById('approveBtn');
if (approveBtn) {
approveBtn.disabled = true;
approveBtn.textContent = 'Starting Update...';
approveBtn.style.opacity = '0.7';
}
closeModal();
const vendor = document.getElementById('vendorSelect').value;
const collection = document.getElementById('collectionSelect').value;
const syncImages = document.getElementById('syncImages').checked;
const selectedProducts = previewProducts.filter(p => selectedProductIds.has(p.id));
log('Starting approved update for ' + selectedProducts.length + ' products...', 'info');
updateButtons('running');
startTime = Date.now();
// Show selected products in display cards
const container = document.getElementById('productsPreview');
container.innerHTML = selectedProducts.map(p => {
const sku = p.variants?.[0]?.sku || 'N/A';
const img = p.images?.[0]?.src || p.image || 'https://via.placeholder.com/60?text=No+Image';
const prevUpdated = p.previouslyUpdated ? 'previously-updated' : 'pending';
const prevStatus = p.previouslyUpdated ? 'Previously Updated' : 'Pending';
return '<div class="sku-card ' + prevUpdated + '" data-id="' + p.id + '" data-sku="' + sku + '">' +
'<button class="sku-card-refresh" onclick="refreshSingleSku(event, '' + p.id + '', '' + sku + '')">↻ Refresh</button>' +
'<div class="sku-card-header">' +
'<img src="' + img + '" alt="" onerror="this.src='https://via.placeholder.com/60?text=No+Image'">' +
'<div class="sku-card-info">' +
'<span class="sku-card-status ' + prevUpdated + '">' + prevStatus + '</span>' +
'<div class="sku-card-title">' + (p.title || 'Untitled').substring(0, 60) + '</div>' +
'<div class="sku-card-sku">SKU: ' + sku + '</div>' +
'</div>' +
'</div>' +
'<div class="sku-card-old-title">Old: ' + (p.title || 'Untitled').substring(0, 80) + '</div>' +
'<div class="sku-card-new-title" style="display:none;"></div>' +
'<div class="sku-card-images-info" style="display:none;"></div>' +
'<div class="sku-card-metafields-info" style="display:none;"></div>' +
'<div class="sku-card-details"></div>' +
'<div class="sku-card-error" style="display:none;"></div>' +
'<div class="sku-card-timestamp"></div>' +
'</div>';
}).join('');
const statTotalEl = document.getElementById('statTotal');
if (statTotalEl) statTotalEl.textContent = selectedProducts.length;
try {
// Only send product IDs and overrides to avoid 413 Payload Too Large error
const overridesCount = Object.keys(productOverrides).length;
if (overridesCount > 0) {
log('Sending ' + overridesCount + ' user overrides with request', 'info');
}
const res = await fetch('/api/start-approved', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendor, collection, syncImages, productIds: Array.from(selectedProductIds), overrides: productOverrides })
});
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
}
const data = await res.json();
if (data.success) {
log('Approved process started with ' + selectedProducts.length + ' products!', 'success');
startPolling();
} else {
log('Failed: ' + (data.error || 'Unknown error'), 'error');
updateButtons('idle');
// Re-enable approve button on error
const approveBtn = document.getElementById('approveBtn');
if (approveBtn) {
approveBtn.disabled = false;
approveBtn.textContent = 'Approve & Start Update';
approveBtn.style.opacity = '1';
}
}
} catch (e) {
log('Error starting process: ' + (e.message || 'Unknown error'), 'error');
updateButtons('idle');
console.error('Start approved error:', e);
// Re-enable approve button on error
const approveBtn = document.getElementById('approveBtn');
if (approveBtn) {
approveBtn.disabled = false;
approveBtn.textContent = 'Approve & Start Update';
approveBtn.style.opacity = '1';
}
}
}
async function startProcess() {
const vendor = document.getElementById('vendorSelect').value;
const collection = document.getElementById('collectionSelect').value;
if (!vendor && !collection) { alert('Select a vendor or collection first'); return; }
const batchSize = document.getElementById('batchSize').value;
const syncImages = document.getElementById('syncImages').checked;
const filterType = vendor ? 'vendor' : 'collection';
const filterValue = vendor || collection;
log('Starting update for ' + filterType + ': ' + filterValue + (syncImages ? ' (with image sync)' : '') + '...', 'info');
updateButtons('running');
startTime = Date.now();
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendor, collection, batchSize, syncImages })
});
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
}
const data = await res.json();
if (data.success) {
log('Process started!', 'success');
startPolling();
} else {
log('Failed: ' + (data.error || 'Unknown error'), 'error');
updateButtons('idle');
}
} catch (e) {
log('Error starting process: ' + (e.message || 'Unknown error'), 'error');
updateButtons('idle');
console.error('Start process error:', e);
}
}
async function refreshCache() {
log('Refreshing vendor/collection cache...', 'info');
try {
const res = await fetch('/api/refresh-cache', { method: 'POST' });
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
}
const data = await res.json();
if (data.success) {
log('Cache refresh started, reloading in 5s...', 'info');
setTimeout(() => {
loadVendors();
loadCollections();
}, 5000);
} else {
log('Refresh failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (e) {
log('Refresh failed: ' + (e.message || 'Unknown error'), 'error');
console.error('Refresh cache error:', e);
}
}
async function updateSingleProduct() {
const urlInput = document.getElementById('productUrl');
if (!urlInput) return;
const url = urlInput.value.trim();
if (!url) {
alert('Please paste a product URL');
return;
}
const syncImagesEl = document.getElementById('syncImages');
const syncImages = syncImagesEl ? syncImagesEl.checked : false;
log('Updating single product from URL: ' + url, 'info');
// Disable button during request
const btn = document.querySelector('button[onclick="updateSingleProduct()"]');
const originalText = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Updating...';
}
try {
const res = await fetch('/api/update-single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, syncImages })
});
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
}
const data = await res.json();
if (data.success) {
log('Product update started: ' + data.title + ' (running in background)', 'success');
urlInput.value = '';
// Show the product in display cards
const container = document.getElementById('productsPreview');
if (container) {
const sku = 'N/A'; // Will be updated by polling
const img = 'https://via.placeholder.com/60?text=Loading';
container.innerHTML = '<div class="sku-card pending" data-id="' + data.id + '" data-sku="' + sku + '">' +
'<div class="sku-card-header">' +
'<img src="' + img + '" alt="" onerror="this.src='https://via.placeholder.com/60?text=No+Image'">' +
'<div class="sku-card-info">' +
'<span class="sku-card-status pending">Pending</span>' +
'<div class="sku-card-title">' + (data.title || 'Loading...').substring(0, 60) + '</div>' +
'<div class="sku-card-sku">SKU: ' + sku + '</div>' +
'</div>' +
'</div>' +
'<div class="sku-card-old-title">Old: ' + (data.title || 'Loading...').substring(0, 80) + '</div>' +
'<div class="sku-card-new-title" style="display:none;"></div>' +
'<div class="sku-card-images-info" style="display:none;"></div>' +
'<div class="sku-card-metafields-info" style="display:none;"></div>' +
'<div class="sku-card-error" style="display:none;"></div>' +
'<div class="sku-card-timestamp"></div>' +
'</div>';
const statTotalEl = document.getElementById('statTotal');
if (statTotalEl) statTotalEl.textContent = '1';
startPolling();
}
loadHistory();
} else {
log('Failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (e) {
log('Error: ' + (e.message || 'Unknown error'), 'error');
} finally {
// Re-enable button
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'Update Product';
}
}
}
async function pauseProcess() {
try {
const res = await fetch('/api/pause', { method: 'POST' });
if (!res.ok) throw new Error('Pause failed');
log('Paused', 'info');
updateButtons('paused');
} catch (e) {
log('Failed to pause: ' + (e.message || 'Unknown error'), 'error');
}
}
async function resumeProcess() {
try {
const res = await fetch('/api/resume', { method: 'POST' });
if (!res.ok) throw new Error('Resume failed');
log('Resumed', 'info');
updateButtons('running');
startPolling();
} catch (e) {
log('Failed to resume: ' + (e.message || 'Unknown error'), 'error');
}
}
async function stopProcess() {
if (!confirm('Stop the process?')) return;
try {
const res = await fetch('/api/stop', { method: 'POST' });
if (!res.ok) throw new Error('Stop failed');
log('Stopped', 'info');
updateButtons('idle');
stopPolling();
loadHistory();
} catch (e) {
log('Failed to stop: ' + (e.message || 'Unknown error'), 'error');
}
}
function startPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(pollStatus, 2000);
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
async function pollStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) {
console.error('Status fetch failed:', res.status);
return;
}
const data = await res.json();
const badge = document.getElementById('statusBadge');
if (!badge) return; // Safety check
if (data.running && !data.paused) {
badge.className = 'status-badge status-running';
badge.textContent = 'RUNNING';
updateButtons('running');
if (!pollInterval) startPolling();
} else if (data.paused) {
badge.className = 'status-badge status-paused';
badge.textContent = 'PAUSED';
updateButtons('paused');
} else {
badge.className = 'status-badge status-idle';
badge.textContent = 'IDLE';
if (data.processed > 0 && !data.running) {
updateButtons('idle');
stopPolling();
loadHistory();
}
}
const statProcessed = document.getElementById('statProcessed');
const statTotal = document.getElementById('statTotal');
const statErrors = document.getElementById('statErrors');
const progressFill = document.getElementById('progressFill');
if (statProcessed) statProcessed.textContent = data.processed || 0;
if (statTotal) statTotal.textContent = data.total || 0;
if (statErrors) statErrors.textContent = data.errors || 0;
const pct = data.total > 0 ? Math.round((data.processed / data.total) * 100) : 0;
if (progressFill) {
progressFill.style.width = pct + '%';
progressFill.textContent = pct + '%';
}
if (startTime) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
document.getElementById('statElapsed').textContent = Math.floor(elapsed / 60) + ':' + String(elapsed % 60).padStart(2, '0');
}
// Update display cards with product update status
if (data.productUpdates && data.products) {
data.products.forEach(function(p) {
const productId = String(p.id);
const update = data.productUpdates[productId];
const cardEl = document.querySelector('.sku-card[data-id="' + productId + '"]');
if (cardEl && update) {
// Update card status class
cardEl.className = 'sku-card ' + update.status;
// Update status badge
const statusEl = cardEl.querySelector('.sku-card-status');
if (statusEl) {
statusEl.className = 'sku-card-status ' + update.status;
statusEl.textContent = update.status.charAt(0).toUpperCase() + update.status.slice(1);
}
// Update new title if available
const newTitleEl = cardEl.querySelector('.sku-card-new-title');
if (newTitleEl && update.newTitle) {
newTitleEl.style.display = 'block';
newTitleEl.textContent = 'New: ' + update.newTitle.substring(0, 80);
}
// Update timestamp
const timestampEl = cardEl.querySelector('.sku-card-timestamp');
if (timestampEl && update.timestamp) {
const date = new Date(update.timestamp);
timestampEl.textContent = date.toLocaleTimeString();
}
// Show error if present
const errorEl = cardEl.querySelector('.sku-card-error');
if (errorEl) {
if (update.error) {
errorEl.style.display = 'block';
errorEl.textContent = 'Error: ' + update.error;
} else {
errorEl.style.display = 'none';
}
}
// Show images info if available
const imagesInfoEl = cardEl.querySelector('.sku-card-images-info');
if (imagesInfoEl) {
if (update.imagesUpdated && update.imageCount) {
imagesInfoEl.style.display = 'block';
imagesInfoEl.textContent = '✓ ' + update.imageCount + ' images synced';
} else {
imagesInfoEl.style.display = 'none';
}
}
// Show metafields info if available
const metafieldsInfoEl = cardEl.querySelector('.sku-card-metafields-info');
if (metafieldsInfoEl) {
if (update.metafieldResult) {
metafieldsInfoEl.style.display = 'block';
if (update.metafieldResult.success && update.metafieldResult.updatedFields) {
metafieldsInfoEl.className = 'sku-card-metafields-info';
metafieldsInfoEl.textContent = '✓ ' + update.metafieldResult.updatedFields.length + ' metafields synced';
} else if (update.metafieldResult.errors && update.metafieldResult.errors.length > 0) {
metafieldsInfoEl.className = 'sku-card-metafields-info warning';
metafieldsInfoEl.textContent = '⚠ Metafield warnings: ' + update.metafieldResult.errors.length;
} else {
metafieldsInfoEl.style.display = 'none';
}
} else {
metafieldsInfoEl.style.display = 'none';
}
}
// Update full details card for completed items
if (update.status === 'completed') {
updateCardDetails(cardEl, update);
}
// Scroll card into view if it's currently being updated
if (update.status === 'updating') {
cardEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else if (cardEl && !update) {
// Card exists but no update data - might be a new product, initialize as pending
const statusEl = cardEl.querySelector('.sku-card-status');
if (statusEl && !statusEl.classList.contains('pending')) {
cardEl.className = 'sku-card pending';
statusEl.className = 'sku-card-status pending';
statusEl.textContent = 'Pending';
}
}
});
}
} catch (e) {
console.error('Poll status error:', e);
// Don't stop polling on error, just log it
}
}
function updateButtons(state) {
const btns = {
start: document.getElementById('btnStart'),
pause: document.getElementById('btnPause'),
resume: document.getElementById('btnResume'),
stop: document.getElementById('btnStop'),
preview: document.getElementById('btnPreview')
};
const selects = [document.getElementById('vendorSelect'), document.getElementById('collectionSelect'), document.getElementById('batchSize')];
// Safety checks
if (!btns.start || !btns.pause || !btns.resume || !btns.stop || !btns.preview) {
console.error('Button elements not found');
return;
}
if (state === 'running') {
btns.start.disabled = btns.preview.disabled = true;
btns.pause.disabled = btns.stop.disabled = false;
btns.resume.disabled = true;
selects.forEach(s => { if (s) s.disabled = true; });
} else if (state === 'paused') {
btns.start.disabled = btns.pause.disabled = btns.preview.disabled = true;
btns.resume.disabled = btns.stop.disabled = false;
selects.forEach(s => { if (s) s.disabled = true; });
} else {
btns.start.disabled = btns.preview.disabled = false;
btns.pause.disabled = btns.resume.disabled = btns.stop.disabled = true;
selects.forEach(s => { if (s) s.disabled = false; });
}
}
function log(msg, type = '') {
const el = document.getElementById('log');
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
el.appendChild(entry);
el.scrollTop = el.scrollHeight;
// Also update live status
updateLiveStatus(msg, type);
}
function updateLiveStatus(msg, type = '') {
const el = document.getElementById('liveStatus');
if (!el) return;
const color = type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#f59e0b';
const entry = document.createElement('div');
entry.style.color = color;
entry.style.marginBottom = '4px';
entry.textContent = '> ' + msg;
el.appendChild(entry);
el.scrollTop = el.scrollHeight;
// Keep only last 20 entries
while (el.children.length > 20) el.removeChild(el.firstChild);
}
async function refreshSingleSku(event, productId, sku) {
event.stopPropagation();
const cardEl = document.querySelector('.sku-card[data-id="' + productId + '"]');
if (!cardEl) return;
// Set card to updating state
cardEl.className = 'sku-card updating';
const statusEl = cardEl.querySelector('.sku-card-status');
if (statusEl) {
statusEl.className = 'sku-card-status updating';
statusEl.textContent = 'Refreshing...';
}
log('Refreshing SKU: ' + sku, 'info');
try {
const syncImages = document.getElementById('syncImages')?.checked || false;
const res = await fetch('/api/refresh-sku', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, sku, syncImages })
});
const data = await res.json();
if (data.success) {
log('Refreshed: ' + (data.newTitle || sku), 'success');
// Update card with new data
cardEl.className = 'sku-card completed';
if (statusEl) {
statusEl.className = 'sku-card-status completed';
statusEl.textContent = 'Completed';
}
const newTitleEl = cardEl.querySelector('.sku-card-new-title');
if (newTitleEl && data.newTitle) {
newTitleEl.style.display = 'block';
newTitleEl.textContent = 'New: ' + data.newTitle.substring(0, 80);
}
// Update details section
updateCardDetails(cardEl, data);
} else {
throw new Error(data.error || 'Refresh failed');
}
} catch (e) {
log('Refresh error: ' + e.message, 'error');
cardEl.className = 'sku-card error';
if (statusEl) {
statusEl.className = 'sku-card-status error';
statusEl.textContent = 'Error';
}
const errorEl = cardEl.querySelector('.sku-card-error');
if (errorEl) {
errorEl.style.display = 'block';
errorEl.textContent = 'Error: ' + e.message;
}
}
}
function updateCardDetails(cardEl, update) {
const detailsEl = cardEl.querySelector('.sku-card-details');
if (!detailsEl) {
console.error('No details element found in card');
return;
}
console.log('Updating card details with:', JSON.stringify(update, null, 2).substring(0, 500));
let html = '';
if (update.pattern) {
html += '<div class="detail-row"><span class="detail-label">Pattern:</span><span class="detail-value">' + update.pattern + '</span></div>';
}
if (update.color) {
html += '<div class="detail-row"><span class="detail-label">Color:</span><span class="detail-value">' + update.color + '</span></div>';
}
if (update.mfrSku) {
html += '<div class="detail-row"><span class="detail-label">MFR#:</span><span class="detail-value">' + update.mfrSku + '</span></div>';
}
if (update.productType) {
html += '<div class="detail-row"><span class="detail-label">Type:</span><span class="detail-value">' + update.productType + '</span></div>';
}
if (update.category) {
html += '<div class="detail-row"><span class="detail-label">Category:</span><span class="detail-value">' + update.category + '</span></div>';
}
if (update.imagesUpdated && update.imageCount) {
html += '<div class="detail-row"><span class="detail-label">Images:</span><span class="detail-value">✓ ' + update.imageCount + ' synced</span></div>';
}
if (update.tags && update.tags.length > 0) {
html += '<div class="sku-card-tags">';
update.tags.slice(0, 8).forEach(function(tag) {
html += '<span class="sku-card-tag">' + tag + '</span>';
});
if (update.tags.length > 8) html += '<span class="sku-card-tag">+' + (update.tags.length - 8) + ' more</span>';
html += '</div>';
}
if (update.colorAnalysis) {
if (update.colorAnalysis.backgroundColor) {
html += '<div class="detail-row"><span class="detail-label">BG Color:</span><span class="detail-value">' + update.colorAnalysis.backgroundColor + '</span></div>';
}
}
// Show metafield update results
if (update.metafieldResult) {
if (update.metafieldResult.success && update.metafieldResult.updatedFields && update.metafieldResult.updatedFields.length > 0) {
html += '<div class="detail-row"><span class="detail-label">Metafields:</span><span class="detail-value" style="color:#10b981;">✓ ' + update.metafieldResult.updatedFields.length + ' synced (' + update.metafieldResult.updatedFields.join(', ') + ')</span></div>';
}
if (update.metafieldResult.errors && update.metafieldResult.errors.length > 0) {
html += '<div class="detail-row"><span class="detail-label">MF Warnings:</span><span class="detail-value" style="color:#f59e0b;">' + update.metafieldResult.errors.join('; ') + '</span></div>';
}
}
detailsEl.innerHTML = html;
detailsEl.style.display = html ? 'block' : 'none';
}
</script>
</body>
</html>
`;
}
// API Routes
app.get('/', (req, res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.send(getDashboardHTML());
});
// Cache for vendors (refreshes every 5 minutes)
let vendorCache: { data: any; timestamp: number } | null = null;
const VENDOR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
app.get('/api/vendors', async (req, res) => {
try {
// Return cached data if fresh
if (vendorCache && (Date.now() - vendorCache.timestamp) < VENDOR_CACHE_TTL) {
console.log('[Vendors] Returning cached data');
return res.json(vendorCache.data);
}
console.log('[Vendors] Using GraphQL to fetch all vendors...');
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
// GraphQL query to get all vendor names (fast - single call)
const vendorQuery = `{
shop {
productVendors(first: 250) {
edges { node }
pageInfo { hasNextPage endCursor }
}
}
}`;
let allVendors: string[] = [];
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const query = cursor
? `{ shop { productVendors(first: 250, after: "${cursor}") { edges { node } pageInfo { hasNextPage endCursor } } } }`
: vendorQuery;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
});
const data: any = await response.json();
const vendorData = data?.data?.shop?.productVendors;
if (vendorData?.edges) {
allVendors.push(...vendorData.edges.map((e: any) => e.node));
}
hasNextPage = vendorData?.pageInfo?.hasNextPage || false;
cursor = vendorData?.pageInfo?.endCursor || null;
}
// Get product count for each vendor using GraphQL productsCount
const vendorCounts: { name: string; count: number }[] = [];
// Batch vendor count queries (5 at a time to avoid rate limits)
for (let i = 0; i < allVendors.length; i += 5) {
const batch = allVendors.slice(i, i + 5);
const countPromises = batch.map(async (vendor) => {
const countQuery = `{
productsCount(query: "vendor:'${vendor.replace(/'/g, "\\'")}'") {
count
}
}`;
const countRes = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: countQuery })
});
const countData: any = await countRes.json();
return { name: vendor, count: countData?.data?.productsCount?.count || 0 };
});
const results = await Promise.all(countPromises);
vendorCounts.push(...results);
if (i % 50 === 0 && i > 0) {
console.log(`[Vendors] Counted ${i}/${allVendors.length} vendors...`);
}
}
// Sort alphabetically
vendorCounts.sort((a, b) => a.name.localeCompare(b.name));
const totalProducts = vendorCounts.reduce((sum, v) => sum + v.count, 0);
console.log(`[Vendors] Complete! ${vendorCounts.length} vendors, ${totalProducts} total products`);
// Save to cache
const responseData = { vendors: vendorCounts, totalProducts, vendorCount: vendorCounts.length };
vendorCache = { data: responseData, timestamp: Date.now() };
res.json(responseData);
} catch (e: any) {
console.error('[Vendors] Error:', e.message);
res.json({ vendors: [], error: e.message });
}
});
// Cache for collections (refreshes every 5 minutes)
let collectionsCache: { data: any; timestamp: number } | null = null;
const COLLECTIONS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Refresh cache endpoint
app.post('/api/refresh-cache', async (req, res) => {
console.log('[Cache] Manual refresh requested...');
vendorCache = null;
collectionsCache = null;
// Trigger preload in background
preloadCache();
res.json({ success: true, message: 'Cache refresh started' });
});
// Set skill endpoint (Architectural vs Residential)
app.post('/api/set-skill', (req, res) => {
const { skill } = req.body;
if (skill === 'architectural' || skill === 'residential') {
currentSkill = skill;
const specs = getCurrentUpdateSpecs();
console.log(`[Skill] Changed to: ${skill} - Suffix: "${specs.suffix}", Type: "${specs.productType}"`);
res.json({ success: true, skill, specs });
} else {
res.status(400).json({ success: false, error: 'Invalid skill type. Use "architectural" or "residential"' });
}
});
// Get current skill
app.get('/api/skill', (req, res) => {
const specs = getCurrentUpdateSpecs();
res.json({ skill: currentSkill, specs });
});
// Refresh single SKU
app.post('/api/refresh-sku', async (req, res) => {
const { productId, sku, syncImages } = req.body;
if (!productId) {
return res.status(400).json({ success: false, error: 'Product ID is required' });
}
try {
console.log(`[Refresh SKU] Refreshing product ${productId} (SKU: ${sku})`);
// Fetch the product from Shopify
const productUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/products/${productId}.json`;
const productRes = await fetch(productUrl, {
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken }
});
if (!productRes.ok) {
throw new Error(`Failed to fetch product: ${productRes.status}`);
}
const productData = await productRes.json();
const product = productData.product;
// Update the product
const result = await updateProduct(product, { syncImages: syncImages || false });
// Save to updated SKUs
if (sku && sku !== 'N/A') {
saveUpdatedSku({
sku,
productId: String(productId),
title: result.newTitle,
pattern: result.mfrData?.patternName || null,
color: result.mfrData?.colorName || null,
mfrSku: result.mfrData?.mfrSku || null,
updatedAt: new Date().toISOString(),
vendor: product.vendor || null
});
}
res.json({
success: true,
newTitle: result.newTitle,
pattern: result.mfrData?.patternName,
color: result.mfrData?.colorName,
mfrSku: result.mfrData?.mfrSku,
artist: result.mfrData?.artist,
productType: getCurrentUpdateSpecs().productType,
category: getCurrentUpdateSpecs().category,
tags: result.tags,
imagesUpdated: result.imagesUpdated,
imageCount: result.imageCount,
colorAnalysis: result.colorAnalysis
});
} catch (e: any) {
console.error(`[Refresh SKU] Error:`, e.message);
res.status(500).json({ success: false, error: e.message });
}
});
app.get('/api/collections', async (req, res) => {
try {
// Return cached data if fresh
if (collectionsCache && (Date.now() - collectionsCache.timestamp) < COLLECTIONS_CACHE_TTL) {
console.log('[Collections] Returning cached data');
return res.json(collectionsCache.data);
}
console.log('[Collections] Using GraphQL to fetch all collections...');
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
// GraphQL query to get all collections with product counts
const collectionsQuery = `{
collections(first: 250) {
edges {
node {
id
title
productsCount {
count
}
}
}
}
}`;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: collectionsQuery })
});
const data: any = await response.json();
if (data.errors) {
console.error('[Collections] GraphQL errors:', data.errors);
return res.json({ collections: [], error: data.errors[0]?.message });
}
const collections = data.data?.collections?.edges?.map((edge: any) => {
const node = edge.node;
const numericId = node.id.replace('gid://shopify/Collection/', '');
return {
id: numericId,
title: node.title,
count: node.productsCount?.count || 0
};
}) || [];
collections.sort((a: any, b: any) => a.title.localeCompare(b.title));
console.log(`[Collections] Found ${collections.length} collections`);
// Save to cache
const responseData = { collections };
collectionsCache = { data: responseData, timestamp: Date.now() };
res.json(responseData);
} catch (e: any) {
console.error('[Collections] Error:', e.message);
res.json({ collections: [], error: e.message });
}
});
app.get('/api/preview', async (req, res) => {
try {
const { vendor, collection, limit, offset } = req.query as { vendor?: string; collection?: string; limit: string; offset?: string };
const limitNum = limit === 'all' ? 250 : parseInt(limit) || 10;
const offsetNum = parseInt(offset || '0') || 0;
// Use GraphQL to fetch products WITH metafields
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
const fetchLimit = Math.min(offsetNum + limitNum, 250);
let queryFilter = '';
if (collection) {
queryFilter = `collection_id:${collection}`;
} else if (vendor) {
queryFilter = `vendor:'${vendor.replace(/'/g, "\\'")}'`;
} else {
return res.json({ products: [], total: 0, error: 'No vendor or collection specified' });
}
const productQuery = `{
products(first: ${fetchLimit}, query: "${queryFilter}") {
edges {
node {
id
title
handle
tags
images(first: 10) { edges { node { url altText } } }
variants(first: 1) { edges { node { price sku } } }
metafields(first: 25) {
edges {
node {
namespace
key
value
}
}
}
}
}
}
productsCount(query: "${queryFilter}") { count }
}`;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: productQuery })
});
const graphqlData: any = await response.json();
if (graphqlData.errors) {
console.error('[Preview] GraphQL errors:', graphqlData.errors);
return res.json({ products: [], total: 0, error: graphqlData.errors[0]?.message || 'GraphQL error' });
}
// Transform GraphQL response to match expected format
const data = {
products: graphqlData.data?.products?.edges?.map((e: any) => {
const node = e.node;
return {
id: node.id?.replace('gid://shopify/Product/', ''),
title: node.title,
handle: node.handle,
tags: node.tags?.join(', ') || '',
images: node.images?.edges?.map((img: any) => ({ src: img.node.url, alt: img.node.altText })) || [],
variants: node.variants?.edges?.map((v: any) => ({ price: v.node.price, sku: v.node.sku })) || [],
metafields: node.metafields?.edges?.map((m: any) => m.node) || []
};
}) || [],
totalCount: graphqlData.data?.productsCount?.count || 0
};
// Generate preview data for each product (what it will look like after update)
const products = data.products?.map((p: any) => {
// Extract manufacturer data to preview the new title
const handle = p.handle || '';
const tags = p.tags || '';
const existingTitle = p.title || '';
// PRIORITY 0: Check existing title FIRST - if it already has good data, PRESERVE IT!
const fromTitle = parseExistingTitle(existingTitle);
// Quick extraction for preview (same logic as full extraction but sync)
// SKU priority: Tags FIRST (real MFR SKU), then handle, then title (only if not DWWC internal)
const fromTags = extractDataFromTags(tags);
const mfrSkuFromHandle = extractRealMfrSku(p);
// CRITICAL: Skip DWWC- internal SKUs from title - get REAL MFR SKU from tags
let mfrSku = fromTags.mfrSku || mfrSkuFromHandle;
if (!mfrSku && fromTitle.sku && !fromTitle.sku.startsWith('DWWC')) {
mfrSku = fromTitle.sku;
}
const fromHandle = parseHandleForPatternAndColor(handle, mfrSku);
// CRITICAL: Preserve existing good data - check title FIRST!
const pattern = fromTitle.pattern || fromHandle.pattern || fromTags.patternName || getPatternFromSkuOrHandle(mfrSku, handle) || 'Unknown';
// For artworks, don't force a color
const color = fromTitle.isArtwork ? null : (fromTitle.color || fromHandle.color || fromTags.colorName || getColorFromSkuOrHandle(mfrSku, handle) || null);
const artist = fromTitle.artist || null;
const isArtwork = fromTitle.isArtwork || false;
// Log if we're preserving existing title data
if (fromTitle.isArtwork && fromTitle.pattern) {
console.log(` ✓ ARTWORK: "${existingTitle}" → "${fromTitle.pattern}" by ${fromTitle.artist}`);
} else if (fromTitle.pattern && fromTitle.color) {
console.log(` ✓ PRESERVING: "${existingTitle}" → Pattern="${fromTitle.pattern}", Color="${fromTitle.color}"`);
}
// Generate preview title (with artist info for artworks)
const newTitle = formatTitle(pattern, color, mfrSku, { artist, isArtwork });
// Check if this SKU was previously updated
const variantSku = p.variants?.[0]?.sku;
const previouslyUpdatedRecord = variantSku ? isSkuPreviouslyUpdated(variantSku) : null;
// Extract AI-generated metafield data
const metafields = p.metafields || [];
const getMetafield = (key: string) => {
const mf = metafields.find((m: any) => m.key === key);
return mf?.value || null;
};
const aiMfrSku = getMetafield('manufacturer_sku');
const aiDescription = getMetafield('ai_generated_description');
const aiColors = getMetafield('ai_generated_colors');
const aiTags = getMetafield('ai_generated_tags');
const sourceUrl = getMetafield('source_url');
return {
id: p.id,
title: p.title,
handle: p.handle,
tags: p.tags,
image: p.images?.[0]?.src || null,
variants: p.variants,
images: p.images,
metafields: p.metafields,
previouslyUpdated: !!previouslyUpdatedRecord,
previouslyUpdatedAt: previouslyUpdatedRecord?.updatedAt || null,
// Preview data showing what the product will look like after update
preview: {
newTitle,
pattern,
color: color || (isArtwork ? '(Artwork)' : '(none)'),
mfrSku: mfrSku || 'N/A',
artist,
isArtwork,
shopifyLink: p.handle ? `https://designerwallcoverings.com/products/${p.handle}` : null,
// AI-generated metafield data from Shopify
manufacturerSku: aiMfrSku || mfrSku || 'N/A',
aiDescription: aiDescription,
aiColors: aiColors,
aiTags: aiTags,
sourceUrl: sourceUrl,
// Generate preview description with AI content, specs, and MFR link
newDescription: (() => {
let desc = aiDescription || 'Architectural Wallcoverings - Premium wallcovering solution for commercial and hospitality applications.';
desc += '\n\n';
// Add specs
const specs = [];
if (aiMfrSku || mfrSku) specs.push(`<li><strong>Manufacturer SKU:</strong> ${aiMfrSku || mfrSku}</li>`);
if (specs.length > 0) {
desc += '<h3>Specifications</h3>\n<ul>\n' + specs.join('\n') + '\n</ul>\n\n';
}
// Add manufacturer link
if (sourceUrl) {
desc += `<p><a href="${sourceUrl}" target="_blank" rel="noopener">View on Manufacturer Website →</a></p>\n\n`;
}
desc += '<p><strong>Resources:</strong> Spec Sheet | Installation Guide</p>';
return desc;
})(),
// All metafields for display
allMetafields: metafields.filter((m: any) => m.value && m.value.trim() !== ''),
allMetafieldsHtml: metafields
.filter((m: any) => m.value && m.value.trim() !== '')
.map((m: any) => `<div style="margin-bottom: 4px; padding: 2px 0; border-bottom: 1px solid #e2e8f0;"><strong style="color: #3b82f6;">${m.namespace}.${m.key}:</strong> <span style="color: #374151;">${(m.value || '').substring(0, 100)}${m.value?.length > 100 ? '...' : ''}</span></div>`)
.join('')
}
};
}) || [];
// DEDUPLICATE products by SKU - remove duplicates that have same variant SKU
const seenSkus = new Set<string>();
const deduplicatedProducts = products.filter((p: any) => {
const sku = p.variants?.[0]?.sku;
if (!sku) return true; // Keep products without SKU
if (seenSkus.has(sku)) {
console.log(` [Dedup] Removing duplicate SKU: ${sku} (${p.title.substring(0, 40)}...)`);
return false;
}
seenSkus.add(sku);
return true;
});
console.log(` [Preview] Loaded ${products.length} products, ${deduplicatedProducts.length} after deduplication`);
// Apply offset - slice to get only the requested portion
const slicedProducts = deduplicatedProducts.slice(offsetNum, offsetNum + limitNum);
console.log(` [Preview] Offset=${offsetNum}, Limit=${limitNum}, Returning ${slicedProducts.length} products`);
// Total count from GraphQL response
const totalCount = data.totalCount || deduplicatedProducts.length;
// Only update processState if this is the initial load (offset=0)
if (offsetNum === 0) {
processState.products = slicedProducts;
processState.totalProducts = totalCount;
// Initialize product updates tracking
processState.productUpdates.clear();
}
// Add new products to tracking (for load more)
slicedProducts.forEach((p: any) => {
if (!processState.productUpdates.has(String(p.id))) {
processState.productUpdates.set(String(p.id), {
status: 'pending',
oldTitle: p.title,
newTitle: p.preview?.newTitle,
sku: p.variants?.[0]?.sku || 'N/A'
});
}
});
res.json({ products: slicedProducts, total: totalCount });
} catch (e: any) {
res.json({ products: [], total: 0, error: e.message });
}
});
app.get('/api/status', (req, res) => {
// Convert Map to object for JSON serialization
const productUpdatesObj: any = {};
processState.productUpdates.forEach((value, key) => {
productUpdatesObj[key] = {
...value,
timestamp: value.timestamp?.toISOString()
};
});
res.json({
running: processState.running,
paused: processState.paused,
vendor: processState.vendor,
processed: processState.processed,
total: processState.totalProducts,
errors: processState.errors.length,
products: processState.products,
productUpdates: productUpdatesObj
});
});
app.post('/api/start', async (req, res) => {
if (processState.running) {
return res.json({ success: false, error: 'Already running' });
}
const { vendor, batchSize, syncImages } = req.body;
if (!vendor) return res.json({ success: false, error: 'No vendor' });
const limitNum = batchSize === 'all' ? 250 : parseInt(batchSize) || 10;
// Fetch products using GraphQL
console.log(`[Start] Fetching ${limitNum} products for vendor: ${vendor}`);
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
const productQuery = `{
products(first: ${Math.min(limitNum, 250)}, query: "vendor:'${vendor.replace(/'/g, "\\'")}'") {
edges {
node {
id
title
handle
tags
images(first: 10) { edges { node { url altText } } }
variants(first: 1) { edges { node { price sku } } }
}
}
}
productsCount(query: "vendor:'${vendor.replace(/'/g, "\\'")}'") { count }
}`;
try {
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: productQuery })
});
const data: any = await response.json();
if (data.errors) {
console.error('[Start] GraphQL errors:', data.errors);
return res.json({ success: false, error: data.errors[0]?.message || 'GraphQL error' });
}
// Transform GraphQL response to REST-like format for compatibility
const products = data.data?.products?.edges?.map((edge: any) => {
const node = edge.node;
const numericId = node.id.replace('gid://shopify/Product/', '');
return {
id: parseInt(numericId),
title: node.title,
handle: node.handle,
tags: node.tags?.join(', ') || '',
images: node.images?.edges?.map((ie: any) => ({ src: ie.node.url, alt: ie.node.altText })) || [],
variants: node.variants?.edges?.map((ve: any) => ({ price: ve.node.price, sku: ve.node.sku })) || []
};
}) || [];
const totalCount = data.data?.productsCount?.count || products.length;
console.log(`[Start] Found ${products.length} products (total: ${totalCount})`);
processState.running = true;
processState.paused = false;
processState.vendor = vendor;
processState.batchSize = limitNum;
processState.currentIndex = 0;
processState.processed = 0;
processState.errors = [];
processState.startTime = new Date();
processState.logs = [];
processState.syncImages = syncImages !== false;
processState.products = products;
processState.totalProducts = totalCount;
// Initialize product updates tracking
processState.productUpdates.clear();
products.forEach((p: any) => {
processState.productUpdates.set(String(p.id), {
status: 'pending',
oldTitle: p.title,
sku: p.variants?.[0]?.sku || 'N/A'
});
});
runUpdateProcess();
res.json({ success: true, productsLoaded: products.length, totalAvailable: totalCount });
} catch (e: any) {
console.error('[Start] Error fetching products:', e.message);
res.json({ success: false, error: e.message });
}
});
// Start with pre-approved products (from approval modal)
// Update single product by URL
app.post('/api/update-single', async (req, res) => {
const { url, syncImages } = req.body;
if (!url) return res.json({ success: false, error: 'No URL provided' });
// Check if already running
if (processState.running) {
return res.json({ success: false, error: 'Update process already running. Please stop it first.' });
}
console.log(`[Single] Updating product from URL: ${url}`);
try {
// Extract product ID or handle from URL
let productId: string | null = null;
let handle: string | null = null;
// Shopify admin URL: /admin/products/12345 or /products/12345
const adminMatch = url.match(/(?:admin\/)?products\/(\d+)/);
if (adminMatch) productId = adminMatch[1];
// Frontend URL: /products/handle-name or designerwallcoverings.com/products/handle
// Only match if we didn't already find a numeric ID
if (!productId) {
const handleMatch = url.match(/\/products\/([^\/\?]+)/);
if (handleMatch) handle = handleMatch[1];
}
// Search URL: /search?q=something - extract query and search for product
let searchQuery: string | null = null;
if (!productId && !handle) {
const searchMatch = url.match(/[?&]q=([^&]+)/);
if (searchMatch) {
searchQuery = decodeURIComponent(searchMatch[1]).replace(/\+/g, ' ');
console.log('[Single] Detected search URL, query: ' + searchQuery);
}
}
if (!productId && !handle && !searchQuery) {
return res.json({ success: false, error: 'Could not extract product ID, handle, or search query from URL. Use a product URL like /products/handle-name' });
}
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
let product: any = null;
if (searchQuery) {
// Search for product by title
const escapedQuery = searchQuery.replace(/"/g, '\\"');
const query = '{ products(first: 1, query: "title:*' + escapedQuery.split(' ')[0] + '*") { edges { node { id title handle vendor tags images(first:10) { edges { node { url } } } variants(first:1) { edges { node { price sku } } } } } } }';
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data: any = await response.json();
if (data.errors) {
return res.json({ success: false, error: data.errors[0]?.message || 'GraphQL error' });
}
product = data.data?.products?.edges?.[0]?.node;
if (!product) {
return res.json({ success: false, error: 'No product found matching: ' + searchQuery });
}
} else if (productId) {
// Fetch by ID
const query = `{ product(id: "gid://shopify/Product/${productId}") { id title handle vendor tags images(first:10) { edges { node { url } } } variants(first:1) { edges { node { price sku } } } } }`;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data: any = await response.json();
if (data.errors) {
return res.json({ success: false, error: data.errors[0]?.message || 'GraphQL error' });
}
product = data.data?.product;
} else if (handle) {
// Fetch by handle - escape handle for GraphQL
const escapedHandle = handle.replace(/"/g, '\\"');
const query = `{ productByHandle(handle: "${escapedHandle}") { id title handle vendor tags images(first:10) { edges { node { url } } } variants(first:1) { edges { node { price sku } } } } }`;
const response = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data: any = await response.json();
if (data.errors) {
return res.json({ success: false, error: data.errors[0]?.message || 'GraphQL error' });
}
product = data.data?.productByHandle;
}
if (!product) {
return res.json({ success: false, error: 'Product not found' });
}
// Transform and update
const numericId = product.id.replace('gid://shopify/Product/', '');
const productData = {
id: parseInt(numericId),
title: product.title,
handle: product.handle,
vendor: product.vendor,
tags: product.tags?.join(', ') || '',
images: product.images?.edges?.map((e: any) => ({ src: e.node.url })) || [],
variants: product.variants?.edges?.map((e: any) => ({ price: e.node.price, sku: e.node.sku })) || []
};
// Initialize state for single product update
processState.products = [productData];
processState.totalProducts = 1;
processState.processed = 0;
processState.errors = [];
processState.running = true;
processState.paused = false;
processState.vendor = product.vendor || 'Single Product';
processState.batchSize = 1;
processState.currentIndex = 0;
processState.startTime = new Date();
processState.syncImages = syncImages !== false;
// Initialize product updates tracking
processState.productUpdates.clear();
processState.productUpdates.set(String(productData.id), {
status: 'pending',
oldTitle: productData.title,
sku: productData.variants?.[0]?.sku || 'N/A'
});
// Return immediately and run update in background
res.json({ success: true, title: product.title, id: numericId, message: 'Update started in background' });
// Run update process in background (don't await)
runUpdateProcess().catch((e: any) => {
console.error('[Single] Update process error:', e.message);
});
} catch (e: any) {
console.error('[Single] Error:', e.message);
res.json({ success: false, error: e.message });
}
});
app.post('/api/start-approved', async (req, res) => {
if (processState.running) {
return res.json({ success: false, error: 'Already running' });
}
const { vendor, collection, syncImages, productIds, products, overrides } = req.body;
// Log if overrides received
if (overrides && Object.keys(overrides).length > 0) {
console.log(`[Start-Approved] Received ${Object.keys(overrides).length} user overrides`);
}
// Support both new (productIds only) and legacy (full products) formats
let productsToProcess: any[] = [];
if (productIds && productIds.length > 0) {
// New format: fetch products by IDs in batches to avoid timeout
console.log(`[Start-Approved] Fetching ${productIds.length} products by ID...`);
const batchSize = 50;
for (let i = 0; i < productIds.length; i += batchSize) {
const batchIds = productIds.slice(i, i + batchSize);
const idFilter = batchIds.map((id: any) => `id:${id}`).join(' OR ');
const query = `{
products(first: ${batchSize}, query: "${idFilter}") {
edges {
node {
id
title
handle
vendor
productType
tags
images(first: 5) { edges { node { src altText } } }
variants(first: 10) { edges { node { id sku price } } }
}
}
}
}`;
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 data: any = await response.json();
const fetchedProducts = data?.data?.products?.edges?.map((e: any) => {
const node = e.node;
return {
id: node.id.replace('gid://shopify/Product/', ''),
title: node.title,
handle: node.handle,
vendor: node.vendor,
product_type: node.productType,
tags: node.tags?.join(', ') || '',
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
})) || []
};
}) || [];
productsToProcess.push(...fetchedProducts);
console.log(`[Start-Approved] Fetched batch ${Math.floor(i/batchSize) + 1}: ${fetchedProducts.length} products`);
} catch (e: any) {
console.error(`[Start-Approved] Error fetching batch: ${e.message}`);
}
// Small delay between batches
if (i + batchSize < productIds.length) {
await new Promise(r => setTimeout(r, 200));
}
}
console.log(`[Start-Approved] Total fetched: ${productsToProcess.length} products`);
} else if (products && products.length > 0) {
// Legacy format: use provided products directly
productsToProcess = products;
}
if (productsToProcess.length === 0) {
return res.json({ success: false, error: 'No products found or selected' });
}
console.log(`[Start-Approved] Starting with ${productsToProcess.length} pre-approved products`);
processState.running = true;
processState.paused = false;
processState.vendor = vendor || collection || 'Approved Selection';
processState.batchSize = productsToProcess.length;
processState.currentIndex = 0;
processState.processed = 0;
processState.errors = [];
processState.startTime = new Date();
processState.logs = [];
processState.syncImages = syncImages !== false;
processState.overrides = overrides || {}; // Store user overrides
processState.products = productsToProcess;
processState.totalProducts = productsToProcess.length;
// Initialize product updates tracking
processState.productUpdates.clear();
productsToProcess.forEach((p: any) => {
processState.productUpdates.set(String(p.id), {
status: 'pending',
oldTitle: p.title,
sku: p.variants?.[0]?.sku || 'N/A'
});
});
runUpdateProcess();
res.json({ success: true, productsLoaded: productsToProcess.length });
});
app.post('/api/pause', (req, res) => {
processState.paused = true;
res.json({ success: true });
});
app.post('/api/resume', (req, res) => {
processState.paused = false;
res.json({ success: true });
});
app.post('/api/stop', (req, res) => {
processState.running = false;
processState.paused = false;
// Serialize productUpdates Map to array for history
const productsData = Array.from(processState.productUpdates.entries()).map(([id, data]) => ({
id,
...data
}));
saveHistoryEntry({
date: new Date().toISOString(),
vendor: processState.vendor,
processed: processState.processed,
total: processState.totalProducts,
errors: processState.errors.length,
status: 'stopped',
products: productsData
});
res.json({ success: true });
});
app.get('/api/history', (req, res) => {
res.json({ history: loadHistory() });
});
// AI Chat endpoint
app.post('/api/chat', async (req, res) => {
try {
const { message } = req.body;
if (!message) {
return res.json({ response: 'Please provide a message.' });
}
const response = await anthropic.messages.create({
model: 'claude-opus-4-8',
max_tokens: 500,
system: `You are the Architectural Update Agent AI assistant. You help with:
- Updating commercial wallcovering products to Architectural Wallcoverings branding
- Title format: "Pattern Name Color (MFR#) | Architectural Wallcoverings"
- Color analysis using AI vision
- Syncing images from manufacturer websites
- Tracking updates in PostgreSQL database
Current status: ${processState.running ? 'Running' : 'Idle'}
Vendor: ${processState.vendor || 'None selected'}
Processed: ${processState.processed}/${processState.totalProducts}
Be helpful and concise.`,
messages: [{ role: 'user', content: message }]
});
const textContent = response.content.find(c => c.type === 'text');
res.json({ response: textContent?.text || 'No response generated.' });
} catch (e: any) {
console.error('Chat error:', e.message);
res.json({ response: `Sorry, I encountered an error: ${e.message}` });
}
});
// Database endpoints for tracking
app.get('/api/db/updates', async (req, res) => {
try {
const { vendor, limit = 100 } = req.query;
const result = await pool.query(`
SELECT * FROM shopify_product_updates
WHERE ($1::text IS NULL OR vendor_name = $1)
ORDER BY created_at DESC LIMIT $2
`, [vendor || null, Number(limit)]);
res.json({ updates: result.rows, count: result.rows.length });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/db/batches', async (req, res) => {
try {
const result = await pool.query(`
SELECT * FROM shopify_update_batches ORDER BY started_at DESC LIMIT 50
`);
res.json({ batches: result.rows });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/db/check/:mfrSku', async (req, res) => {
try {
const { mfrSku } = req.params;
const result = await pool.query(
'SELECT id, shopify_product_id, new_title, updated_at FROM shopify_product_updates WHERE mfr_sku = $1',
[mfrSku]
);
res.json({
exists: result.rows.length > 0,
mfrSku,
record: result.rows[0] || null
});
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/db/stats', async (req, res) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) as total_updates,
COUNT(DISTINCT vendor_name) as vendors_updated,
COUNT(DISTINCT mfr_sku) as unique_skus,
MAX(created_at) as last_update
FROM shopify_product_updates
`);
res.json(result.rows[0]);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// Helper function to log update to database
async function logUpdateToDatabase(
product: any,
mfrData: any,
colorAnalysis: any,
oldTitle: string,
newTitle: string,
oldTags: string[],
newTags: string[],
imagesUpdated: boolean,
imageCount: number,
metafieldResult?: MetafieldPushResult
) {
try {
await pool.query(`
INSERT INTO shopify_product_updates
(shopify_product_id, shopify_handle, mfr_sku, vendor_name,
pattern_name, color_name, old_title, new_title,
old_tags, new_tags, manufacturer_data, color_analysis,
images_synced, image_count, batch_id, update_status,
metafields_updated, metafields_count, metafield_errors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'success', $16, $17, $18)
ON CONFLICT (shopify_product_id) DO UPDATE SET
new_title = EXCLUDED.new_title,
new_tags = EXCLUDED.new_tags,
manufacturer_data = EXCLUDED.manufacturer_data,
color_analysis = EXCLUDED.color_analysis,
images_synced = EXCLUDED.images_synced,
image_count = EXCLUDED.image_count,
batch_id = EXCLUDED.batch_id,
metafields_updated = EXCLUDED.metafields_updated,
metafields_count = EXCLUDED.metafields_count,
metafield_errors = EXCLUDED.metafield_errors,
updated_at = NOW()
`, [
product.id,
product.handle || '',
mfrData?.mfrSku || null,
processState.vendor,
mfrData?.patternName || null,
mfrData?.colorName || null,
oldTitle,
newTitle,
oldTags,
newTags,
JSON.stringify(mfrData || {}),
JSON.stringify(colorAnalysis || {}),
imagesUpdated,
imageCount,
currentBatchId,
metafieldResult?.success || false,
metafieldResult?.updatedFields?.length || 0,
JSON.stringify(metafieldResult?.errors || [])
]);
} catch (e: any) {
console.error('Database logging error:', e.message);
}
}
// Helper function to start batch tracking
async function startBatchTracking(vendor: string, batchSize: number, totalProducts: number, syncImages: boolean) {
currentBatchId = crypto.randomUUID();
try {
await pool.query(`
INSERT INTO shopify_update_batches (batch_id, vendor_name, batch_size, sync_images, total_products, status)
VALUES ($1, $2, $3, $4, $5, 'running')
`, [currentBatchId, vendor, batchSize, syncImages, totalProducts]);
} catch (e: any) {
console.error('Batch tracking start error:', e.message);
}
return currentBatchId;
}
// Helper function to update batch progress
async function updateBatchProgress(processed: number, successful: number, failed: number) {
if (!currentBatchId) return;
try {
await pool.query(`
UPDATE shopify_update_batches
SET processed = $1, successful = $2, failed = $3
WHERE batch_id = $4
`, [processed, successful, failed, currentBatchId]);
} catch (e: any) {
console.error('Batch progress update error:', e.message);
}
}
// Helper function to complete batch tracking
async function completeBatchTracking(status: string, summary: any) {
if (!currentBatchId) return;
try {
await pool.query(`
UPDATE shopify_update_batches
SET status = $1, completed_at = NOW(), summary = $2,
processed = $3, successful = $4, failed = $5
WHERE batch_id = $6
`, [status, JSON.stringify(summary), summary.processed, summary.successful, summary.failed, currentBatchId]);
} catch (e: any) {
console.error('Batch tracking complete error:', e.message);
}
currentBatchId = null;
}
// Background update process
async function runUpdateProcess() {
console.log(`[Architectural Update] Starting for vendor: ${processState.vendor} (syncImages: ${processState.syncImages})`);
logActivity('info', `Starting update for ${processState.vendor}${processState.syncImages ? ' (with image sync)' : ''}`);
// Start batch tracking in database
await startBatchTracking(
processState.vendor || 'Unknown',
processState.batchSize,
processState.products.length,
processState.syncImages
);
let successCount = 0;
let failCount = 0;
while (processState.running && processState.currentIndex < processState.products.length) {
if (processState.paused) {
await new Promise(r => setTimeout(r, 1000));
continue;
}
const product = processState.products[processState.currentIndex];
if (!product) break;
const productId = String(product.id);
const sku = product.variants?.[0]?.sku || 'N/A';
// Mark product as updating
processState.productUpdates.set(productId, {
status: 'updating',
oldTitle: product.title,
sku: sku,
timestamp: new Date()
});
try {
logActivity('info', `Updating: ${product.title.substring(0, 40)}...`);
const result = await updateProduct(product, { syncImages: processState.syncImages });
// Log update result with image info
let logMsg = `Updated: ${result.newTitle.substring(0, 50)}`;
if (result.imagesUpdated) {
logMsg += ` (${result.imageCount} images synced)`;
}
logActivity('update', logMsg);
// Mark product as completed with FULL update details
const oldTags = (product.tags || '').split(',').map((t: string) => t.trim()).filter((t: string) => t);
processState.productUpdates.set(productId, {
status: 'completed',
oldTitle: product.title,
newTitle: result.newTitle,
sku: sku,
timestamp: new Date(),
imagesUpdated: result.imagesUpdated,
imageCount: result.imageCount,
// Extended details for full display
pattern: result.mfrData?.patternName || null,
color: result.mfrData?.colorName || null,
mfrSku: result.mfrData?.mfrSku || null,
tags: result.tags || [],
oldTags: oldTags,
productType: getCurrentUpdateSpecs().productType,
category: getCurrentUpdateSpecs().category,
colorAnalysis: result.colorAnalysis || null,
mfrData: result.mfrData || null,
metafieldResult: result.metafieldResult || null
});
// Save to persistent updated SKUs store (for tracking across sessions)
if (sku && sku !== 'N/A') {
saveUpdatedSku({
sku,
productId,
title: result.newTitle,
pattern: result.mfrData?.patternName || null,
color: result.mfrData?.colorName || null,
mfrSku: result.mfrData?.mfrSku || null,
updatedAt: new Date().toISOString(),
vendor: processState.vendor || product.vendor || null
});
}
stats.totalUpdated++;
processState.processed++;
processState.currentIndex++;
successCount++;
// Update batch progress in database
await updateBatchProgress(processState.processed, successCount, failCount);
// Rate limiting (longer if syncing images)
const delay = processState.syncImages ? 2000 : 1000;
await new Promise(r => setTimeout(r, delay));
} catch (e: any) {
console.error(`[Architectural Update] Error:`, e.message);
logActivity('error', `Error on ${product.id}: ${e.message}`);
// Mark product as error
processState.productUpdates.set(productId, {
status: 'error',
oldTitle: product.title,
sku: sku,
timestamp: new Date(),
error: e.message
});
processState.errors.push({ id: product.id, error: e.message });
stats.totalErrors++;
processState.currentIndex++;
failCount++;
// Update batch progress in database
await updateBatchProgress(processState.processed, successCount, failCount);
}
}
if (processState.running) {
processState.running = false;
stats.lastVendor = processState.vendor;
stats.lastRunTime = new Date();
if (processState.vendor && !stats.vendorsProcessed.includes(processState.vendor)) {
stats.vendorsProcessed.push(processState.vendor);
}
// Serialize productUpdates Map to array for history
const productsData = Array.from(processState.productUpdates.entries()).map(([id, data]) => ({
id,
...data
}));
saveHistoryEntry({
date: new Date().toISOString(),
vendor: processState.vendor,
processed: processState.processed,
total: processState.totalProducts,
errors: processState.errors.length,
status: 'completed',
syncImages: processState.syncImages,
products: productsData
});
logActivity('complete', `Completed: ${processState.processed} updated, ${processState.errors.length} errors`);
console.log(`[Architectural Update] Completed. Updated: ${processState.processed}, Errors: ${processState.errors.length}`);
// Complete batch tracking in database
await completeBatchTracking('completed', {
processed: processState.processed,
successful: successCount,
failed: failCount,
vendor: processState.vendor,
syncImages: processState.syncImages
});
} else {
// Batch was stopped early
await completeBatchTracking('stopped', {
processed: processState.processed,
successful: successCount,
failed: failCount,
vendor: processState.vendor,
syncImages: processState.syncImages
});
}
}
// Pre-load cache function
async function preloadCache() {
console.log('[Startup] Pre-loading vendor and collection cache...');
const graphqlUrl = `https://${SHOPIFY_CONFIG.shop}/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`;
try {
// Load vendors
const vendorQuery = `{ shop { productVendors(first: 250) { edges { node } } } }`;
const vendorRes = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: vendorQuery })
});
const vendorData: any = await vendorRes.json();
const allVendors = vendorData?.data?.shop?.productVendors?.edges?.map((e: any) => e.node) || [];
// Get counts in batches of 10
const vendorCounts: { name: string; count: number }[] = [];
for (let i = 0; i < allVendors.length; i += 10) {
const batch = allVendors.slice(i, i + 10);
const results = await Promise.all(batch.map(async (vendor: string) => {
const countQuery = `{ productsCount(query: "vendor:'${vendor.replace(/'/g, "\\'")}'") { count } }`;
const res = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: countQuery })
});
const data: any = await res.json();
return { name: vendor, count: data?.data?.productsCount?.count || 0 };
}));
vendorCounts.push(...results);
if (i % 50 === 0) console.log(`[Startup] Loaded ${Math.min(i + 10, allVendors.length)}/${allVendors.length} vendors...`);
}
vendorCounts.sort((a, b) => a.name.localeCompare(b.name));
const totalProducts = vendorCounts.reduce((sum, v) => sum + v.count, 0);
vendorCache = { data: { vendors: vendorCounts, totalProducts, vendorCount: vendorCounts.length }, timestamp: Date.now() };
console.log(`[Startup] Vendors cached: ${vendorCounts.length} vendors, ${totalProducts} products`);
// Load collections
const collectionsQuery = `{ collections(first: 250) { edges { node { id title productsCount { count } } } } }`;
const colRes = await fetch(graphqlUrl, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: collectionsQuery })
});
const colData: any = await colRes.json();
const collections = colData.data?.collections?.edges?.map((edge: any) => ({
id: edge.node.id.replace('gid://shopify/Collection/', ''),
title: edge.node.title,
count: edge.node.productsCount?.count || 0
})) || [];
collections.sort((a: any, b: any) => a.title.localeCompare(b.title));
collectionsCache = { data: { collections }, timestamp: Date.now() };
console.log(`[Startup] Collections cached: ${collections.length} collections`);
} catch (e: any) {
console.error('[Startup] Cache preload error:', e.message);
}
}
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Architectural Update Agent running at http://45.61.58.125:${PORT}`);
console.log('Auth: admin / <password> (via shared-global-auth)');
// Pre-load cache in background
preloadCache();
});