diff --git a/inventory-server/db/migrations/add-date-online-and-shop-score.sql b/inventory-server/db/migrations/add-date-online-and-shop-score.sql new file mode 100644 index 0000000..c6fd71a --- /dev/null +++ b/inventory-server/db/migrations/add-date-online-and-shop-score.sql @@ -0,0 +1,20 @@ +-- Migration: Add date_online and shop_score columns to products table +-- These fields are imported from production to improve newsletter recommendation accuracy: +-- date_online = products.date_ol in production (date product went live on the shop) +-- shop_score = products.score in production (sales-based popularity score) +-- +-- After running this migration, do a full (non-incremental) import to backfill: +-- INCREMENTAL_UPDATE=false node scripts/import-from-prod.js + +-- Add date_online column (production: products.date_ol) +ALTER TABLE products ADD COLUMN IF NOT EXISTS date_online TIMESTAMP WITH TIME ZONE; + +-- Add shop_score column (production: products.score) +-- Using NUMERIC(10,2) to preserve the decimal precision from production +ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_score NUMERIC(10, 2) DEFAULT 0; + +-- If shop_score was previously created as INTEGER, convert it +ALTER TABLE products ALTER COLUMN shop_score TYPE NUMERIC(10, 2); + +-- Index on date_online for the newsletter "new products" filter +CREATE INDEX IF NOT EXISTS idx_products_date_online ON products(date_online); diff --git a/inventory-server/db/schema.sql b/inventory-server/db/schema.sql index 1b36110..127ac26 100644 --- a/inventory-server/db/schema.sql +++ b/inventory-server/db/schema.sql @@ -21,6 +21,7 @@ CREATE TABLE products ( description TEXT, sku TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE, + date_online TIMESTAMP WITH TIME ZONE, first_received TIMESTAMP WITH TIME ZONE, stock_quantity INTEGER DEFAULT 0, preorder_count INTEGER DEFAULT 0, @@ -63,6 +64,7 @@ CREATE TABLE products ( baskets INTEGER DEFAULT 0, notifies INTEGER DEFAULT 0, date_last_sold DATE, + shop_score NUMERIC(10, 2) DEFAULT 0, updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (pid) ); diff --git a/inventory-server/scripts/import/products.js b/inventory-server/scripts/import/products.js index c1ce5bc..fd74a47 100644 --- a/inventory-server/scripts/import/products.js +++ b/inventory-server/scripts/import/products.js @@ -75,6 +75,7 @@ async function setupTemporaryTables(connection) { artist TEXT, categories TEXT, created_at TIMESTAMP WITH TIME ZONE, + date_online TIMESTAMP WITH TIME ZONE, first_received TIMESTAMP WITH TIME ZONE, landing_cost_price NUMERIC(14, 4), barcode TEXT, @@ -98,6 +99,7 @@ async function setupTemporaryTables(connection) { baskets INTEGER, notifies INTEGER, date_last_sold TIMESTAMP WITH TIME ZONE, + shop_score NUMERIC(10, 2) DEFAULT 0, primary_iid INTEGER, image TEXT, image_175 TEXT, @@ -137,13 +139,14 @@ async function importMissingProducts(prodConnection, localConnection, missingPid p.notes AS description, p.itemnumber AS sku, p.date_created, + p.date_ol, p.datein AS first_received, p.location, p.upc AS barcode, p.harmonized_tariff_code, p.stamp AS updated_at, CASE WHEN si.show + si.buyable > 0 THEN 1 ELSE 0 END AS visible, - CASE + CASE WHEN p.reorder < 0 THEN 0 WHEN p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR) THEN 1 WHEN COALESCE(pnb.inventory, 0) > 0 THEN 1 @@ -160,20 +163,20 @@ async function importMissingProducts(prodConnection, localConnection, missingPid COALESCE(pnb.inventory, 0) as notions_inv_count, COALESCE(pcp.price_each, 0) as price, COALESCE(p.sellingprice, 0) AS regular_price, - CASE - WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) + CASE + WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) THEN ( - SELECT ROUND(SUM(costeach * count) / SUM(count), 5) - FROM product_inventory + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory WHERE pid = p.pid AND count > 0 ) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) END AS cost_price, NULL as landing_cost_price, s.companyname AS vendor, - CASE - WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber - ELSE sid.supplier_itemnumber + CASE + WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber + ELSE sid.supplier_itemnumber END AS vendor_reference, sid.notions_itemnumber AS notions_reference, CONCAT('https://www.acherryontop.com/shop/product/', p.pid) AS permalink, @@ -181,7 +184,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid pc2.name AS line, pc3.name AS subline, pc4.name AS artist, - COALESCE(CASE + COALESCE(CASE WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit ELSE sid.supplier_qty_per_unit END, sid.notions_qty_per_unit) AS moq, @@ -194,17 +197,18 @@ async function importMissingProducts(prodConnection, localConnection, missingPid p.country_of_origin, (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, - (SELECT COALESCE(SUM(oi.qty_ordered), 0) - FROM order_items oi - JOIN _order o ON oi.order_id = o.order_id + (SELECT COALESCE(SUM(oi.qty_ordered), 0) + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold, pls.date_sold as date_last_sold, + COALESCE(p.score, 0) as shop_score, (SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid, - GROUP_CONCAT(DISTINCT CASE - WHEN pc.cat_id IS NOT NULL + GROUP_CONCAT(DISTINCT CASE + WHEN pc.cat_id IS NOT NULL AND pc.type IN (10, 20, 11, 21, 12, 13) AND pci.cat_id NOT IN (16, 17) - THEN pci.cat_id + THEN pci.cat_id END) as category_ids FROM products p LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0 @@ -238,8 +242,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid const batch = prodData.slice(i, i + BATCH_SIZE); const placeholders = batch.map((_, idx) => { - const base = idx * 48; // 48 columns - return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + const base = idx * 50; // 50 columns + return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`; }).join(','); const values = batch.flatMap(row => { @@ -264,6 +268,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid row.artist, row.category_ids, validateDate(row.date_created), + validateDate(row.date_ol), validateDate(row.first_received), row.landing_cost_price, row.barcode, @@ -287,6 +292,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid row.baskets, row.notifies, validateDate(row.date_last_sold), + Number(row.shop_score) || 0, row.primary_iid, imageUrls.image, imageUrls.image_175, @@ -301,11 +307,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid INSERT INTO products ( pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, price, regular_price, cost_price, vendor, vendor_reference, notions_reference, - brand, line, subline, artist, categories, created_at, first_received, + brand, line, subline, artist, categories, created_at, date_online, first_received, landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, managing_stock, replenishable, permalink, moq, uom, rating, reviews, weight, length, width, height, country_of_origin, location, total_sold, - baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags + baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags ) VALUES ${placeholders} ON CONFLICT (pid) DO NOTHING @@ -343,13 +349,14 @@ async function materializeCalculations(prodConnection, localConnection, incremen p.notes AS description, p.itemnumber AS sku, p.date_created, + p.date_ol, p.datein AS first_received, p.location, p.upc AS barcode, p.harmonized_tariff_code, p.stamp AS updated_at, CASE WHEN si.show + si.buyable > 0 THEN 1 ELSE 0 END AS visible, - CASE + CASE WHEN p.reorder < 0 THEN 0 WHEN p.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR) THEN 1 WHEN COALESCE(pnb.inventory, 0) > 0 THEN 1 @@ -366,20 +373,20 @@ async function materializeCalculations(prodConnection, localConnection, incremen COALESCE(pnb.inventory, 0) as notions_inv_count, COALESCE(pcp.price_each, 0) as price, COALESCE(p.sellingprice, 0) AS regular_price, - CASE - WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) + CASE + WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) THEN ( - SELECT ROUND(SUM(costeach * count) / SUM(count), 5) - FROM product_inventory + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory WHERE pid = p.pid AND count > 0 ) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) END AS cost_price, NULL as landing_cost_price, s.companyname AS vendor, - CASE - WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber - ELSE sid.supplier_itemnumber + CASE + WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber + ELSE sid.supplier_itemnumber END AS vendor_reference, sid.notions_itemnumber AS notions_reference, CONCAT('https://www.acherryontop.com/shop/product/', p.pid) AS permalink, @@ -387,7 +394,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen pc2.name AS line, pc3.name AS subline, pc4.name AS artist, - COALESCE(CASE + COALESCE(CASE WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit ELSE sid.supplier_qty_per_unit END, sid.notions_qty_per_unit) AS moq, @@ -400,17 +407,18 @@ async function materializeCalculations(prodConnection, localConnection, incremen p.country_of_origin, (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, - (SELECT COALESCE(SUM(oi.qty_ordered), 0) - FROM order_items oi - JOIN _order o ON oi.order_id = o.order_id + (SELECT COALESCE(SUM(oi.qty_ordered), 0) + FROM order_items oi + JOIN _order o ON oi.order_id = o.order_id WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold, pls.date_sold as date_last_sold, + COALESCE(p.score, 0) as shop_score, (SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid, - GROUP_CONCAT(DISTINCT CASE - WHEN pc.cat_id IS NOT NULL + GROUP_CONCAT(DISTINCT CASE + WHEN pc.cat_id IS NOT NULL AND pc.type IN (10, 20, 11, 21, 12, 13) AND pci.cat_id NOT IN (16, 17) - THEN pci.cat_id + THEN pci.cat_id END) as category_ids FROM products p LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0 @@ -449,8 +457,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen await withRetry(async () => { const placeholders = batch.map((_, idx) => { - const base = idx * 48; // 48 columns - return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + const base = idx * 50; // 50 columns + return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`; }).join(','); const values = batch.flatMap(row => { @@ -475,6 +483,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen row.artist, row.category_ids, validateDate(row.date_created), + validateDate(row.date_ol), validateDate(row.first_received), row.landing_cost_price, row.barcode, @@ -498,6 +507,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen row.baskets, row.notifies, validateDate(row.date_last_sold), + Number(row.shop_score) || 0, row.primary_iid, imageUrls.image, imageUrls.image_175, @@ -511,11 +521,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen INSERT INTO temp_products ( pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, price, regular_price, cost_price, vendor, vendor_reference, notions_reference, - brand, line, subline, artist, categories, created_at, first_received, + brand, line, subline, artist, categories, created_at, date_online, first_received, landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, managing_stock, replenishable, permalink, moq, uom, rating, reviews, weight, length, width, height, country_of_origin, location, total_sold, - baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags + baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags ) VALUES ${placeholders} ON CONFLICT (pid) DO UPDATE SET title = EXCLUDED.title, @@ -535,6 +545,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen subline = EXCLUDED.subline, artist = EXCLUDED.artist, created_at = EXCLUDED.created_at, + date_online = EXCLUDED.date_online, first_received = EXCLUDED.first_received, landing_cost_price = EXCLUDED.landing_cost_price, barcode = EXCLUDED.barcode, @@ -558,13 +569,14 @@ async function materializeCalculations(prodConnection, localConnection, incremen baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, date_last_sold = EXCLUDED.date_last_sold, + shop_score = EXCLUDED.shop_score, primary_iid = EXCLUDED.primary_iid, image = EXCLUDED.image, image_175 = EXCLUDED.image_175, image_full = EXCLUDED.image_full, options = EXCLUDED.options, tags = EXCLUDED.tags - RETURNING + RETURNING xmax = 0 as inserted `, values); }, `Error inserting batch ${i} to ${i + batch.length}`); @@ -614,8 +626,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen AND t.barcode IS NOT DISTINCT FROM p.barcode AND t.updated_at IS NOT DISTINCT FROM p.updated_at AND t.total_sold IS NOT DISTINCT FROM p.total_sold - -- Check key fields that are likely to change - -- We don't need to check every single field, just the important ones + AND t.date_online IS NOT DISTINCT FROM p.date_online + AND t.shop_score IS NOT DISTINCT FROM p.shop_score `); // Get count of products that need updating @@ -688,6 +700,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate t.artist, t.categories, t.created_at, + t.date_online, t.first_received, t.landing_cost_price, t.barcode, @@ -710,6 +723,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate t.baskets, t.notifies, t.date_last_sold, + t.shop_score, t.primary_iid, t.image, t.image_175, @@ -728,8 +742,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate const batch = products.rows.slice(i, i + BATCH_SIZE); const placeholders = batch.map((_, idx) => { - const base = idx * 47; // 47 columns - return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`; + const base = idx * 49; // 49 columns + return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`; }).join(','); const values = batch.flatMap(row => { @@ -754,6 +768,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate row.artist, row.categories, validateDate(row.created_at), + validateDate(row.date_online), validateDate(row.first_received), row.landing_cost_price, row.barcode, @@ -777,6 +792,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate row.baskets, row.notifies, validateDate(row.date_last_sold), + Number(row.shop_score) || 0, imageUrls.image, imageUrls.image_175, imageUrls.image_full, @@ -790,11 +806,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate INSERT INTO products ( pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, price, regular_price, cost_price, vendor, vendor_reference, notions_reference, - brand, line, subline, artist, categories, created_at, first_received, + brand, line, subline, artist, categories, created_at, date_online, first_received, landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, managing_stock, replenishable, permalink, moq, uom, rating, reviews, weight, length, width, height, country_of_origin, location, total_sold, - baskets, notifies, date_last_sold, image, image_175, image_full, options, tags + baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags ) VALUES ${placeholders} ON CONFLICT (pid) DO UPDATE SET @@ -815,6 +831,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate subline = EXCLUDED.subline, artist = EXCLUDED.artist, created_at = EXCLUDED.created_at, + date_online = EXCLUDED.date_online, first_received = EXCLUDED.first_received, landing_cost_price = EXCLUDED.landing_cost_price, barcode = EXCLUDED.barcode, @@ -838,15 +855,16 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, date_last_sold = EXCLUDED.date_last_sold, + shop_score = EXCLUDED.shop_score, image = EXCLUDED.image, image_175 = EXCLUDED.image_175, image_full = EXCLUDED.image_full, options = EXCLUDED.options, tags = EXCLUDED.tags - RETURNING + RETURNING xmax = 0 as inserted ) - SELECT + SELECT COUNT(*) FILTER (WHERE inserted) as inserted, COUNT(*) FILTER (WHERE NOT inserted) as updated FROM upserted diff --git a/inventory-server/src/routes/newsletter.js b/inventory-server/src/routes/newsletter.js index 4c9be12..6d526c4 100644 --- a/inventory-server/src/routes/newsletter.js +++ b/inventory-server/src/routes/newsletter.js @@ -10,13 +10,13 @@ const REF_DATE_CTE = ` // Category definitions matching production website logic: // -// NEW: created_at within 31 days, NOT preorder (mutually exclusive on prod) +// NEW: date_online within 31 days (matches prod's date_ol), NOT preorder // PRE-ORDER: preorder_count > 0, NOT new -// CLEARANCE: (regular_price - price) / regular_price >= 0.35, price > 0 +// CLEARANCE: (regular_price - price) / regular_price >= 0.35 (matches prod's 35% clearance threshold) // DAILY DEALS: product_daily_deals table // BACK IN STOCK: date_last_received > date_first_received, received within 14d, // first received > 30d ago, excludes new products (prod excludes datein < 30d) -// BESTSELLERS: ranked by recent order volume (prod's "hot" logic) +// BESTSELLERS: shop_score > 20 + in stock + recent sales (matches prod's /shop/hot page) // // Mutual exclusivity: // - New and Pre-order are exclusive: if preorder_count > 0, it's preorder not new @@ -29,7 +29,7 @@ const CATEGORY_FILTERS = { clearance: "AND is_clearance = true", daily_deals: "AND is_daily_deal = true", back_in_stock: "AND is_back_in_stock = true", - bestsellers: "AND COALESCE(sales_30d, 0) > 0", + bestsellers: "AND shop_score > 20 AND COALESCE(current_stock, 0) > 0 AND COALESCE(sales_30d, 0) > 0", never_featured: "AND times_featured IS NULL AND line_last_featured_at IS NULL", }; @@ -38,6 +38,8 @@ function buildScoredCTE({ forCount = false } = {}) { const selectColumns = forCount ? ` p.pid, p.created_at, + p.date_online, + p.shop_score, p.preorder_count, p.price, p.regular_price, @@ -62,6 +64,7 @@ function buildScoredCTE({ forCount = false } = {}) { p.vendor, p.price, p.regular_price, + p.shop_score, p.image_175 as image, p.permalink, p.stock_quantity, @@ -70,6 +73,7 @@ function buildScoredCTE({ forCount = false } = {}) { p.categories, p.line, p.created_at as product_created_at, + p.date_online, p.first_received, p.date_last_sold, p.total_sold, @@ -101,7 +105,7 @@ function buildScoredCTE({ forCount = false } = {}) { EXTRACT(DAY FROM ref.d - lh.line_last_featured_at)::int as line_days_since_featured, COALESCE(nh.last_featured_at, lh.line_last_featured_at) as effective_last_featured, EXTRACT(DAY FROM ref.d - COALESCE(nh.last_featured_at, lh.line_last_featured_at))::int as effective_days_since_featured, - EXTRACT(DAY FROM ref.d - p.created_at)::int as age_days + EXTRACT(DAY FROM ref.d - COALESCE(p.date_online, p.created_at))::int as age_days `; return ` @@ -144,10 +148,11 @@ function buildScoredCTE({ forCount = false } = {}) { -- === CATEGORY FLAGS (production-accurate, mutually exclusive where needed) === - -- NEW: within 31 days of reference date, AND not on preorder + -- NEW: date_online within 31 days of reference date, AND not on preorder + -- Uses date_online (prod's date_ol) instead of created_at for accuracy CASE WHEN p.preorder_count > 0 THEN false - WHEN p.created_at > ref.d - INTERVAL '31 days' THEN true + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN true ELSE false END as is_new, @@ -157,7 +162,7 @@ function buildScoredCTE({ forCount = false } = {}) { ELSE false END as is_preorder, - -- CLEARANCE: 35%+ discount off regular price, price must be > 0 + -- CLEARANCE: 35%+ discount off regular price (matches prod threshold), price must be > 0 CASE WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35 @@ -184,7 +189,7 @@ function buildScoredCTE({ forCount = false } = {}) { -- We use date_last_received/date_first_received as our equivalents CASE WHEN p.preorder_count > 0 THEN false - WHEN p.created_at > ref.d - INTERVAL '31 days' THEN false + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN false WHEN pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' @@ -195,11 +200,11 @@ function buildScoredCTE({ forCount = false } = {}) { -- === RECOMMENDATION SCORE === ( - -- New product boost (first 31 days, not preorder) + -- New product boost (first 31 days by date_online, not preorder) CASE WHEN p.preorder_count > 0 THEN 0 - WHEN p.created_at > ref.d - INTERVAL '14 days' THEN 50 - WHEN p.created_at > ref.d - INTERVAL '31 days' THEN 35 + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50 + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35 ELSE 0 END -- Pre-order boost @@ -219,7 +224,7 @@ function buildScoredCTE({ forCount = false } = {}) { -- Back in stock boost (only for actual restocks, not new arrivals) + CASE WHEN p.preorder_count = 0 - AND p.created_at <= ref.d - INTERVAL '31 days' + AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' AND pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' @@ -266,8 +271,12 @@ function buildScoredCTE({ forCount = false } = {}) { + CASE WHEN pm.abc_class = 'A' THEN 10 WHEN pm.abc_class = 'B' THEN 5 ELSE 0 END - -- In-stock requirement - + CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 ELSE 0 END + -- Stock penalty + + CASE + WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 + WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20 + ELSE 0 + END ) as score FROM ref, products p @@ -368,7 +377,7 @@ router.get('/stats', async (req, res) => { -- Unfeatured new products (SELECT COUNT(*) FROM products p, ref WHERE p.visible = true AND p.preorder_count = 0 - AND p.created_at > ref.d - INTERVAL '31 days' + AND COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' AND p.pid NOT IN (SELECT pid FROM featured_pids) ) as unfeatured_new, -- Back in stock, not yet featured since restock @@ -377,7 +386,7 @@ router.get('/stats', async (req, res) => { CROSS JOIN ref WHERE p.visible = true AND p.preorder_count = 0 - AND p.created_at <= ref.d - INTERVAL '31 days' + AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' AND pm.date_last_received > ref.d - INTERVAL '14 days' AND pm.date_last_received > pm.date_first_received AND pm.date_first_received < ref.d - INTERVAL '30 days' @@ -422,4 +431,293 @@ router.get('/stats', async (req, res) => { } }); +// GET /api/newsletter/score-breakdown/:pid +// Returns the individual scoring factors for a single product (debug endpoint) +router.get('/score-breakdown/:pid', async (req, res) => { + const pool = req.app.locals.pool; + const { pid } = req.params; + + try { + const { rows } = await pool.query(` + WITH ${REF_DATE_CTE}, + newsletter_history AS ( + SELECT pid, COUNT(*) as times_featured, MAX(sent_at) as last_featured_at + FROM klaviyo_campaign_products GROUP BY pid + ), + line_history AS ( + SELECT p2.line, + COUNT(DISTINCT kcp.pid) FILTER (WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '7 days') as line_products_featured_7d + FROM products p2 + JOIN klaviyo_campaign_products kcp ON kcp.pid = p2.pid + WHERE p2.line IS NOT NULL AND p2.line != '' + GROUP BY p2.line + ), + line_sizes AS ( + SELECT line, COUNT(*) as line_product_count + FROM products WHERE visible = true AND line IS NOT NULL AND line != '' GROUP BY line + ) + SELECT + -- New product boost + CASE + WHEN p.preorder_count > 0 THEN 0 + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '14 days' THEN 50 + WHEN COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN 35 + ELSE 0 + END as new_boost, + -- Pre-order boost + CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END as preorder_boost, + -- Clearance boost + CASE + WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price + AND ((p.regular_price - p.price) / p.regular_price * 100) >= 35 + THEN LEAST(((p.regular_price - p.price) / p.regular_price * 50)::int, 25) + ELSE 0 + END as clearance_boost, + -- Sales velocity + CASE WHEN COALESCE(pm.sales_7d, 0) >= 5 THEN 15 + WHEN COALESCE(pm.sales_7d, 0) >= 2 THEN 10 + WHEN COALESCE(pm.sales_7d, 0) >= 1 THEN 5 + ELSE 0 END as velocity_boost, + -- Back in stock + CASE + WHEN p.preorder_count = 0 + AND COALESCE(p.date_online, p.created_at) <= ref.d - INTERVAL '31 days' + AND pm.date_last_received > ref.d - INTERVAL '14 days' + AND pm.date_last_received > pm.date_first_received + AND pm.date_first_received < ref.d - INTERVAL '30 days' + AND pm.current_stock > 0 + THEN 25 ELSE 0 + END as back_in_stock_boost, + -- Interest + LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15) as interest_boost, + -- Recency + CASE + WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) IS NULL THEN 10 + WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '2 days' THEN -30 + WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '5 days' THEN -15 + WHEN COALESCE(nh.last_featured_at, lh.line_last_featured_at) > ref.d - INTERVAL '10 days' THEN -5 + ELSE 5 + END as recency_adj, + -- Over-featured + CASE + WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10 + WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5 + ELSE 0 + END as over_featured_adj, + -- Line saturation + CASE + WHEN lh2.line_products_featured_7d IS NOT NULL + AND ls.line_product_count IS NOT NULL AND ls.line_product_count > 0 + AND (lh2.line_products_featured_7d::float / ls.line_product_count) > 0.7 + THEN -10 + WHEN lh2.line_products_featured_7d IS NOT NULL AND lh2.line_products_featured_7d >= 4 + THEN -5 + ELSE 0 + END as line_saturation_adj, + -- Price tier + CASE + WHEN COALESCE(p.price, 0) < 3 THEN -15 + WHEN COALESCE(p.price, 0) < 8 THEN -5 + WHEN COALESCE(p.price, 0) >= 25 THEN 5 + ELSE 0 + END as price_tier_adj, + -- ABC class + CASE WHEN pm.abc_class = 'A' THEN 10 WHEN pm.abc_class = 'B' THEN 5 ELSE 0 END as abc_boost, + -- Stock penalty + CASE + WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 + WHEN COALESCE(pm.current_stock, 0) <= 2 AND COALESCE(p.preorder_count, 0) = 0 THEN -20 + ELSE 0 + END as stock_penalty + FROM ref, products p + LEFT JOIN product_metrics pm ON pm.pid = p.pid + LEFT JOIN newsletter_history nh ON nh.pid = p.pid + LEFT JOIN LATERAL ( + SELECT MAX(kcp.sent_at) as line_last_featured_at + FROM products p3 + JOIN klaviyo_campaign_products kcp ON kcp.pid = p3.pid + WHERE p3.line = p.line AND p.line IS NOT NULL AND p.line != '' + ) lh ON true + LEFT JOIN line_history lh2 ON lh2.line = p.line AND p.line IS NOT NULL AND p.line != '' + LEFT JOIN line_sizes ls ON ls.line = p.line AND p.line IS NOT NULL AND p.line != '' + WHERE p.pid = $1 + `, [pid]); + + if (rows.length === 0) { + return res.status(404).json({ error: 'Product not found' }); + } + res.json(rows[0]); + } catch (error) { + console.error('Error fetching score breakdown:', error); + res.status(500).json({ error: 'Failed to fetch score breakdown' }); + } +}); + +// GET /api/newsletter/campaigns +// Returns all campaigns with product counts and links +router.get('/campaigns', async (req, res) => { + const pool = req.app.locals.pool; + + try { + const [campaignsResult, linksResult, summaryResult] = await Promise.all([ + pool.query(` + SELECT + kcp.campaign_id, + kcp.campaign_name, + kcp.sent_at, + COUNT(*) as product_count, + json_agg(json_build_object( + 'pid', kcp.pid, + 'title', p.title, + 'sku', p.sku, + 'brand', p.brand, + 'line', p.line, + 'image', p.image_175, + 'product_url', kcp.product_url + ) ORDER BY p.brand, p.line, p.title) as products + FROM klaviyo_campaign_products kcp + LEFT JOIN products p ON p.pid = kcp.pid + GROUP BY kcp.campaign_id, kcp.campaign_name, kcp.sent_at + ORDER BY kcp.sent_at DESC + `), + pool.query(` + SELECT campaign_id, campaign_name, sent_at, link_url, link_type + FROM klaviyo_campaign_links + ORDER BY sent_at DESC + `), + pool.query(` + SELECT + COUNT(DISTINCT campaign_id) as total_campaigns, + COUNT(DISTINCT pid) as total_unique_products, + ROUND(COUNT(*)::numeric / NULLIF(COUNT(DISTINCT campaign_id), 0), 1) as avg_products_per_campaign + FROM klaviyo_campaign_products + `) + ]); + + // Group links by campaign_id + const linksByCampaign = {}; + for (const link of linksResult.rows) { + if (!linksByCampaign[link.campaign_id]) linksByCampaign[link.campaign_id] = []; + linksByCampaign[link.campaign_id].push(link); + } + + const campaigns = campaignsResult.rows.map(c => ({ + ...c, + links: linksByCampaign[c.campaign_id] || [] + })); + + res.json({ + campaigns, + summary: summaryResult.rows[0] + }); + } catch (error) { + console.error('Error fetching campaigns:', error); + res.status(500).json({ error: 'Failed to fetch campaigns' }); + } +}); + +// GET /api/newsletter/campaigns/products +// Returns product-level aggregate stats across all campaigns +router.get('/campaigns/products', async (req, res) => { + const pool = req.app.locals.pool; + + try { + const { rows } = await pool.query(` + SELECT + kcp.pid, + p.title, + p.sku, + p.brand, + p.image_175 as image, + p.permalink, + COUNT(*) as times_featured, + MIN(kcp.sent_at) as first_featured_at, + MAX(kcp.sent_at) as last_featured_at, + EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured, + EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::int as featured_span_days, + CASE WHEN COUNT(*) > 1 + THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(*) - 1), 1) + ELSE NULL + END as avg_days_between_features, + json_agg(json_build_object( + 'campaign_id', kcp.campaign_id, + 'campaign_name', kcp.campaign_name, + 'sent_at', kcp.sent_at + ) ORDER BY kcp.sent_at DESC) as campaigns + FROM klaviyo_campaign_products kcp + LEFT JOIN products p ON p.pid = kcp.pid + GROUP BY kcp.pid, p.title, p.sku, p.brand, p.image_175, p.permalink + ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC + `); + + res.json({ products: rows }); + } catch (error) { + console.error('Error fetching campaign products:', error); + res.status(500).json({ error: 'Failed to fetch campaign products' }); + } +}); + +// GET /api/newsletter/campaigns/brands +// Returns brand-level aggregate stats across all campaigns +router.get('/campaigns/brands', async (req, res) => { + const pool = req.app.locals.pool; + + try { + const { rows } = await pool.query(` + SELECT + COALESCE(p.brand, 'Unknown') as brand, + COUNT(DISTINCT kcp.pid) as product_count, + COUNT(*) as times_featured, + MIN(kcp.sent_at) as first_featured_at, + MAX(kcp.sent_at) as last_featured_at, + EXTRACT(DAY FROM NOW() - MAX(kcp.sent_at))::int as days_since_featured, + CASE WHEN COUNT(DISTINCT kcp.campaign_id) > 1 + THEN ROUND(EXTRACT(DAY FROM MAX(kcp.sent_at) - MIN(kcp.sent_at))::numeric / (COUNT(DISTINCT kcp.campaign_id) - 1), 1) + ELSE NULL + END as avg_days_between_features, + json_agg(DISTINCT jsonb_build_object( + 'campaign_id', kcp.campaign_id, + 'campaign_name', kcp.campaign_name, + 'sent_at', kcp.sent_at + )) as campaigns + FROM klaviyo_campaign_products kcp + LEFT JOIN products p ON p.pid = kcp.pid + GROUP BY COALESCE(p.brand, 'Unknown') + ORDER BY COUNT(*) DESC, MAX(kcp.sent_at) DESC + `); + + res.json({ brands: rows }); + } catch (error) { + console.error('Error fetching campaign brands:', error); + res.status(500).json({ error: 'Failed to fetch campaign brands' }); + } +}); + +// GET /api/newsletter/campaigns/links +// Returns link-level aggregate stats across all campaigns +router.get('/campaigns/links', async (req, res) => { + const pool = req.app.locals.pool; + + try { + const { rows } = await pool.query(` + SELECT + link_url, + link_type, + COUNT(*) as times_used, + MIN(sent_at) as first_used_at, + MAX(sent_at) as last_used_at, + EXTRACT(DAY FROM NOW() - MAX(sent_at))::int as days_since_used, + json_agg(DISTINCT campaign_name ORDER BY campaign_name) as campaign_names + FROM klaviyo_campaign_links + GROUP BY link_url, link_type + ORDER BY COUNT(*) DESC, MAX(sent_at) DESC + `); + + res.json({ links: rows }); + } catch (error) { + console.error('Error fetching campaign links:', error); + res.status(500).json({ error: 'Failed to fetch campaign links' }); + } +}); + module.exports = router; diff --git a/inventory/src/components/newsletter/CampaignHistoryDialog.tsx b/inventory/src/components/newsletter/CampaignHistoryDialog.tsx new file mode 100644 index 0000000..3419e7d --- /dev/null +++ b/inventory/src/components/newsletter/CampaignHistoryDialog.tsx @@ -0,0 +1,597 @@ +import { useState, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { History, ChevronDown, ChevronRight, ChevronLeft, ExternalLink } from "lucide-react" +import config from "@/config" + +function useCampaignData(open: boolean) { + const campaigns = useQuery({ + queryKey: ["newsletter-campaigns"], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/campaigns`) + if (!res.ok) throw new Error("Failed to fetch campaigns") + return res.json() + }, + enabled: open, + staleTime: 5 * 60_000, + }) + + const products = useQuery<{ products: ProductAggregate[] }>({ + queryKey: ["newsletter-campaigns-products"], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/campaigns/products`) + if (!res.ok) throw new Error("Failed to fetch") + return res.json() + }, + enabled: open, + staleTime: 5 * 60_000, + }) + + const links = useQuery<{ links: LinkAggregate[] }>({ + queryKey: ["newsletter-campaigns-links"], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/campaigns/links`) + if (!res.ok) throw new Error("Failed to fetch") + return res.json() + }, + enabled: open, + staleTime: 5 * 60_000, + }) + + const brands = useQuery<{ brands: BrandAggregate[] }>({ + queryKey: ["newsletter-campaigns-brands"], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/campaigns/brands`) + if (!res.ok) throw new Error("Failed to fetch") + return res.json() + }, + enabled: open, + staleTime: 5 * 60_000, + }) + + return { campaigns, products, brands, links } +} + +// ── Types ──────────────────────────────────────────── + +interface CampaignProduct { + pid: number + title: string + sku: string + brand: string | null + line: string | null + image: string | null + product_url: string | null +} + +interface CampaignLink { + link_url: string + link_type: string +} + +interface Campaign { + campaign_id: string + campaign_name: string + sent_at: string + product_count: number + products: CampaignProduct[] + links: CampaignLink[] +} + +interface CampaignSummary { + total_campaigns: number + total_unique_products: number + avg_products_per_campaign: number +} + +interface CampaignsResponse { + campaigns: Campaign[] + summary: CampaignSummary +} + +interface ProductAggregate { + pid: number + title: string + sku: string + brand: string + image: string | null + permalink: string | null + times_featured: number + first_featured_at: string + last_featured_at: string + days_since_featured: number + featured_span_days: number + avg_days_between_features: number | null + campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[] +} + +interface BrandAggregate { + brand: string + product_count: number + times_featured: number + first_featured_at: string + last_featured_at: string + days_since_featured: number + avg_days_between_features: number | null + campaigns: { campaign_id: string; campaign_name: string; sent_at: string }[] +} + +interface LinkAggregate { + link_url: string + link_type: string + times_used: number + first_used_at: string + last_used_at: string + days_since_used: number + campaign_names: string[] +} + +// ── Campaign Row (expandable) ──────────────────────── + +function CampaignRow({ campaign }: { campaign: Campaign }) { + const [expanded, setExpanded] = useState(false) + + return ( + <> + setExpanded(!expanded)} + > + + {expanded ? : } + + {campaign.campaign_name || campaign.campaign_id} + + {campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : "—"} + + {campaign.product_count} + {campaign.links.length} + + {expanded && ( + + +
+
+

Products ({campaign.products.length})

+
+ {campaign.products.map((p) => ( +
+ {p.image ? ( + + ) : ( +
+ )} + {p.title} + {p.pid} + {p.product_url && ( + e.stopPropagation()}> + + + )} +
+ ))} +
+
+ {campaign.links.length > 0 && ( +
+

Links ({campaign.links.length})

+
+ {campaign.links.map((l, i) => ( +
+ {l.link_type || "other"} + e.stopPropagation()}> + {l.link_url} + +
+ ))} +
+
+ )} +
+ + + )} + + ) +} + +// ── Product Row (expandable campaign list) ─────────── + +function ProductRow({ product }: { product: ProductAggregate }) { + const [expanded, setExpanded] = useState(false) + + return ( + <> + setExpanded(!expanded)} + > + + {expanded ? : } + + +
+ {product.image ? ( + + ) : ( +
+ )} +
+

{product.title}

+

{product.sku}

+
+
+ + {product.brand} + {product.times_featured}× + + {product.first_featured_at ? new Date(product.first_featured_at).toLocaleDateString() : "—"} + + + {product.days_since_featured === 0 ? "Today" : `${product.days_since_featured}d ago`} + + + {product.avg_days_between_features != null ? `${product.avg_days_between_features}d` : "—"} + + + {product.permalink && ( + e.stopPropagation()}> + + + )} + + + {expanded && ( + + +
+

Campaigns ({product.campaigns.length})

+
+ {product.campaigns.map((c) => ( +
+ + {c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"} + + {c.campaign_name || c.campaign_id} +
+ ))} +
+
+
+
+ )} + + ) +} + +// ── Skeleton loader ────────────────────────────────── + +function TableSkeleton({ rows = 8 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => )} +
+ ) +} + +// ── Tab: Campaigns ─────────────────────────────────── + +function CampaignsTab({ data, isLoading }: { data: CampaignsResponse | undefined; isLoading: boolean }) { + return ( +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : data?.summary ? ( +
+ + +

Total Campaigns

+

{Number(data.summary.total_campaigns).toLocaleString()}

+
+
+ + +

Unique Products Featured

+

{Number(data.summary.total_unique_products).toLocaleString()}

+
+
+ + +

Avg Products / Campaign

+

{data.summary.avg_products_per_campaign}

+
+
+
+ ) : null} + +
+ {isLoading ? : ( + + + + + Campaign + Sent + Products + Links + + + + {data?.campaigns.length === 0 ? ( + + No campaigns found + + ) : ( + data?.campaigns.map((c) => ) + )} + +
+ )} +
+
+ ) +} + +// ── Tab: Products ──────────────────────────────────── + +const PRODUCTS_PAGE_SIZE = 500 + +function ProductsTab({ data, isLoading }: { data: { products: ProductAggregate[] } | undefined; isLoading: boolean }) { + const [page, setPage] = useState(1) + const allProducts = data?.products ?? [] + const totalPages = Math.ceil(allProducts.length / PRODUCTS_PAGE_SIZE) + const pageProducts = useMemo( + () => allProducts.slice((page - 1) * PRODUCTS_PAGE_SIZE, page * PRODUCTS_PAGE_SIZE), + [allProducts, page] + ) + + return ( +
+
+ {isLoading ? : ( + + + + + Product + Brand + Featured + First + Last + Avg Gap + + + + + {pageProducts.length === 0 ? ( + + No products found + + ) : ( + pageProducts.map((p) => ) + )} + +
+ )} +
+ {totalPages > 1 && ( +
+

+ {allProducts.length.toLocaleString()} products +

+
+ + Page {page} of {totalPages} + +
+
+ )} +
+ ) +} + +// ── Brand Row (expandable campaign list) ───────────── + +function BrandRow({ brand }: { brand: BrandAggregate }) { + const [expanded, setExpanded] = useState(false) + + return ( + <> + setExpanded(!expanded)} + > + + {expanded ? : } + + {brand.brand} + {brand.product_count} + {brand.times_featured}× + + {brand.first_featured_at ? new Date(brand.first_featured_at).toLocaleDateString() : "—"} + + + {brand.days_since_featured === 0 ? "Today" : `${brand.days_since_featured}d ago`} + + + {brand.avg_days_between_features != null ? `${brand.avg_days_between_features}d` : "—"} + + + {expanded && ( + + +
+

Campaigns ({brand.campaigns.length})

+
+ {brand.campaigns.map((c) => ( +
+ + {c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"} + + {c.campaign_name || c.campaign_id} +
+ ))} +
+
+
+
+ )} + + ) +} + +// ── Tab: Brands ────────────────────────────────────── + +function BrandsTab({ data, isLoading }: { data: { brands: BrandAggregate[] } | undefined; isLoading: boolean }) { + return ( +
+ {isLoading ? : ( + + + + + Brand + Products + Featured + First + Last + Avg Gap + + + + {data?.brands.length === 0 ? ( + + No brands found + + ) : ( + data?.brands.map((b) => ) + )} + +
+ )} +
+ ) +} + +// ── Tab: Links ─────────────────────────────────────── + +function LinksTab({ data, isLoading }: { data: { links: LinkAggregate[] } | undefined; isLoading: boolean }) { + return ( +
+ {isLoading ? : ( + + + + Link + Type + Used + First + Last + Campaigns + + + + {data?.links.length === 0 ? ( + + No links found + + ) : ( + data?.links.map((l, i) => ( + + + + {l.link_url} + + + + {l.link_type || "other"} + + {l.times_used}× + + {l.first_used_at ? new Date(l.first_used_at).toLocaleDateString() : "—"} + + + {l.days_since_used === 0 ? "Today" : `${l.days_since_used}d ago`} + + + {l.campaign_names?.length ?? 0} + + + )) + )} + +
+ )} +
+ ) +} + +// ── Main Dialog ────────────────────────────────────── + +export function CampaignHistoryDialog() { + const [open, setOpen] = useState(false) + const { campaigns, products, brands, links } = useCampaignData(open) + + return ( + + + + + + + Newsletter Campaign History + + + + + Campaigns + Products + Brands + Links + + + + + + + + + + + + + + + + + + ) +} diff --git a/inventory/src/components/newsletter/NewsletterStats.tsx b/inventory/src/components/newsletter/NewsletterStats.tsx index 87fa0aa..713a29a 100644 --- a/inventory/src/components/newsletter/NewsletterStats.tsx +++ b/inventory/src/components/newsletter/NewsletterStats.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query" import { Card, CardContent } from "@/components/ui/card" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Skeleton } from "@/components/ui/skeleton" import { Sparkles, RotateCcw, TrendingUp, Clock, CalendarClock, EyeOff, Info } from "lucide-react" import config from "@/config" @@ -23,7 +24,23 @@ export function NewsletterStats() { }, }) - if (!data) return null + if (!data) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+ + +
+ +
+
+ ))} +
+ ) + } const stats = [ { @@ -66,7 +83,7 @@ export function NewsletterStats() { return ( -
+
{stats.map((s) => ( @@ -78,6 +95,7 @@ export function NewsletterStats() { +

{s.label}

{s.tooltip}

diff --git a/inventory/src/components/newsletter/RecommendationTable.tsx b/inventory/src/components/newsletter/RecommendationTable.tsx index d4584cf..f3f2e54 100644 --- a/inventory/src/components/newsletter/RecommendationTable.tsx +++ b/inventory/src/components/newsletter/RecommendationTable.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query" -import { useState } from "react" +import { useState, useMemo, useContext } from "react" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" @@ -8,7 +8,9 @@ import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { ChevronLeft, ChevronRight, ExternalLink, Layers } from "lucide-react" +import { ChevronLeft, ChevronRight, ExternalLink, Layers, Copy, Check, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react" +import { toast } from "sonner" +import { AuthContext } from "@/contexts/AuthContext" import config from "@/config" interface Product { @@ -74,7 +76,7 @@ function FeaturedCell({ p }: { p: Product }) { const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at return ( -
+
{directCount}× {hasLineHistory && ( @@ -108,7 +110,7 @@ function LastFeaturedCell({ p }: { p: Product }) { return ( - + {lineLabel} @@ -122,10 +124,124 @@ function LastFeaturedCell({ p }: { p: Product }) { } return Never } +function CopyPidButton({ pid }: { pid: number }) { + const [copied, setCopied] = useState(false) + return ( + + + + + + +

{copied ? "Copied!" : "Copy product ID"}

+
+
+
+ ) +} + +interface ScoreBreakdown { + new_boost: number; preorder_boost: number; clearance_boost: number + velocity_boost: number; back_in_stock_boost: number; interest_boost: number + recency_adj: number; over_featured_adj: number; line_saturation_adj: number + price_tier_adj: number; abc_boost: number; stock_penalty: number +} + +const SCORE_LABELS: Record = { + new_boost: "New Product", preorder_boost: "Pre-Order", clearance_boost: "Clearance", + velocity_boost: "Sales Velocity", back_in_stock_boost: "Back in Stock", interest_boost: "Interest", + recency_adj: "Recency", over_featured_adj: "Over-Featured", line_saturation_adj: "Line Saturation", + price_tier_adj: "Price Tier", abc_boost: "ABC Class", stock_penalty: "Stock" +} + +function ScoreBreakdownTooltip({ pid, score, children }: { pid: number; score: number; children: React.ReactNode }) { + const [hovered, setHovered] = useState(false) + const { data } = useQuery({ + queryKey: ["score-breakdown", pid], + queryFn: async () => { + const res = await fetch(`${config.apiUrl}/newsletter/score-breakdown/${pid}`) + if (!res.ok) throw new Error("Failed") + return res.json() + }, + enabled: hovered, + staleTime: 60_000, + }) + + return ( + + + setHovered(true)}> + {children} + + +
+

Score Breakdown: {score}

+ {data ? ( +
+ {(Object.keys(SCORE_LABELS) as (keyof ScoreBreakdown)[]).map(k => { + const v = Number(data[k]) + if (v === 0) return null + return ( +
+ {SCORE_LABELS[k]} + 0 ? "text-green-600" : "text-red-500"}>{v > 0 ? "+" : ""}{v} +
+ ) + })} +
+ ) : ( +

Loading…

+ )} +
+
+
+
+ ) +} + +type SortColumn = "score" | "brand" | "price" | "stock" | "sales_7d" | "sales_30d" | "times_featured" | "days_since_featured" +type SortDirection = "asc" | "desc" | null +interface SortState { column: SortColumn | null; direction: SortDirection } + +function toggleSort(prev: SortState, column: SortColumn): SortState { + if (prev.column !== column) return { column, direction: "asc" } + if (prev.direction === "asc") return { column, direction: "desc" } + return { column: null, direction: null } +} + +function SortableHeader({ label, column, sort, onSort, className }: { + label: string; column: SortColumn; sort: SortState; onSort: (c: SortColumn) => void; className?: string +}) { + const active = sort.column === column + return ( + onSort(column)}> +
+ {label} + {active && sort.direction === "asc" ? : + active && sort.direction === "desc" ? : + } +
+
+ ) +} export function RecommendationTable({ category }: RecommendationTableProps) { + const { user } = useContext(AuthContext) + const canDebug = user?.is_admin || user?.permissions?.includes("admin:debug") const [page, setPage] = useState(1) - const limit = 50 + const [sort, setSort] = useState({ column: null, direction: null }) + const limit = 100 const { data, isLoading } = useQuery({ queryKey: ["newsletter-recommendations", category, page], @@ -138,31 +254,51 @@ export function RecommendationTable({ category }: RecommendationTableProps) { }, }) + const products = useMemo(() => { + const list = data?.products ?? [] + if (!sort.column || !sort.direction) return list + const col = sort.column + const dir = sort.direction === "asc" ? 1 : -1 + return [...list].sort((a, b) => { + let av: number, bv: number + switch (col) { + case "score": av = a.score; bv = b.score; break + case "brand": return dir * (a.brand ?? "").localeCompare(b.brand ?? "") + case "price": av = Number(a.is_daily_deal && a.deal_price ? a.deal_price : a.price); bv = Number(b.is_daily_deal && b.deal_price ? b.deal_price : b.price); break + case "stock": av = a.current_stock ?? 0; bv = b.current_stock ?? 0; break + case "sales_7d": av = a.sales_7d ?? 0; bv = b.sales_7d ?? 0; break + case "sales_30d": av = a.sales_30d ?? 0; bv = b.sales_30d ?? 0; break + case "times_featured": av = a.times_featured ?? 0; bv = b.times_featured ?? 0; break + case "days_since_featured": av = a.effective_days_since_featured ?? 9999; bv = b.effective_days_since_featured ?? 9999; break + default: return 0 + } + return dir * (av - bv) + }) + }, [data?.products, sort.column, sort.direction]) + const pagination = data?.pagination + if (isLoading) { return
Loading recommendations…
} - const products = data?.products ?? [] - const pagination = data?.pagination - return (
-
- +
+
- Score + setSort(toggleSort(sort, c))} className="w-[50px]" /> Image Product - Brand - Price - Stock - 7d Sales - 30d Sales + setSort(toggleSort(sort, c))} /> + setSort(toggleSort(sort, c))} className="text-center" /> + setSort(toggleSort(sort, c))} className="text-center" /> + setSort(toggleSort(sort, c))} className="text-center" /> + setSort(toggleSort(sort, c))} className="text-center" /> Tags - Featured - Last Featured - + setSort(toggleSort(sort, c))} className="text-center" /> + setSort(toggleSort(sort, c))} /> + @@ -176,13 +312,25 @@ export function RecommendationTable({ category }: RecommendationTableProps) { products.map((p) => ( - = 40 ? "text-green-600" : - p.score >= 20 ? "text-yellow-600" : - "text-muted-foreground" - }`}> - {p.score} - + {canDebug ? ( + + = 40 ? "text-green-600" : + p.score >= 20 ? "text-yellow-600" : + "text-muted-foreground" + }`}> + {p.score} + + + ) : ( + = 40 ? "text-green-600" : + p.score >= 20 ? "text-yellow-600" : + "text-muted-foreground" + }`}> + {p.score} + + )} {p.image ? ( @@ -192,16 +340,15 @@ export function RecommendationTable({ category }: RecommendationTableProps) { )} -
-

{p.title}

-

{p.sku}

+
+

{p.title}

{p.line && (

{p.line}

)}
{p.brand} - +
{p.is_daily_deal && p.deal_price ? ( <> @@ -230,7 +377,7 @@ export function RecommendationTable({ category }: RecommendationTableProps) { )}
- + {p.current_stock ?? 0} @@ -238,16 +385,16 @@ export function RecommendationTable({ category }: RecommendationTableProps) { (+{p.on_order_qty}) )} - {p.sales_7d ?? 0} - {p.sales_30d ?? 0} + {p.sales_7d ?? 0} + {p.sales_30d ?? 0}
- {p.is_new && New} - {p.is_preorder && Pre-Order} - {p.is_clearance && Clearance} + {p.is_new && New} + {p.is_preorder && Pre-Order} + {p.is_clearance && Clearance} {p.is_daily_deal && Deal} - {p.is_back_in_stock && Back in Stock} - {p.is_low_stock && Low Stock} + {p.is_back_in_stock && Back in Stock} + {p.is_low_stock && Low Stock} {(p.baskets > 0 || p.notifies > 0) && ( {p.baskets > 0 ? `${p.baskets} 🛒` : ""}{p.baskets > 0 && p.notifies > 0 ? " " : ""}{p.notifies > 0 ? `${p.notifies} 🔔` : ""} @@ -255,19 +402,36 @@ export function RecommendationTable({ category }: RecommendationTableProps) { )}
- + - + - {p.permalink && ( - - - - )} +
+ + {p.permalink && ( + + + + + + + Open in shop + + + + )} +
)) diff --git a/inventory/src/pages/Newsletter.tsx b/inventory/src/pages/Newsletter.tsx index 68cb41f..b2c10f6 100644 --- a/inventory/src/pages/Newsletter.tsx +++ b/inventory/src/pages/Newsletter.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { NewsletterStats } from "@/components/newsletter/NewsletterStats" import { RecommendationTable } from "@/components/newsletter/RecommendationTable" +import { CampaignHistoryDialog } from "@/components/newsletter/CampaignHistoryDialog" const CATEGORIES = [ { value: "all", label: "All Recommendations" }, @@ -22,6 +23,7 @@ export function Newsletter() {

Newsletter Recommendations

+
diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 587d1ba..51c3bd3 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/products.tsx","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/products.tsx","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts"],"version":"5.6.3"} \ No newline at end of file