Newsletter recommendation tweaks, add campaign history dialog

This commit is contained in:
2026-02-01 17:37:08 -05:00
parent 450fd96e19
commit 2744e82264
9 changed files with 1232 additions and 113 deletions

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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,6 +139,7 @@ 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,
@@ -199,6 +202,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
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
@@ -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,6 +349,7 @@ 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,
@@ -405,6 +412,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
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
@@ -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,6 +569,7 @@ 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,
@@ -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,6 +855,7 @@ 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,

View File

@@ -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;

View File

@@ -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<CampaignsResponse>({
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 (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell className="font-medium text-sm">{campaign.campaign_name || campaign.campaign_id}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-right text-sm">{campaign.product_count}</TableCell>
<TableCell className="text-right text-sm">{campaign.links.length}</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={5} className="p-0">
<div className="bg-muted/30 p-3 space-y-3">
<div>
<p className="text-xs font-semibold mb-1.5">Products ({campaign.products.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5" style={{ gridAutoFlow: "column", gridTemplateRows: `repeat(${Math.ceil(campaign.products.length / 2)}, minmax(0, auto))` }}>
{campaign.products.map((p) => (
<div key={p.pid} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
{p.image ? (
<img src={p.image} alt="" className="w-6 h-6 object-cover rounded shrink-0" />
) : (
<div className="w-6 h-6 bg-muted rounded shrink-0" />
)}
<span className="truncate flex-1">{p.title}</span>
<span className="text-muted-foreground shrink-0">{p.pid}</span>
{p.product_url && (
<a href={p.product_url} target="_blank" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
))}
</div>
</div>
{campaign.links.length > 0 && (
<div>
<p className="text-xs font-semibold mb-1.5">Links ({campaign.links.length})</p>
<div className="space-y-1">
{campaign.links.map((l, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
className="text-blue-500 hover:underline truncate"
onClick={(e) => e.stopPropagation()}>
{l.link_url}
</a>
</div>
))}
</div>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Product Row (expandable campaign list) ───────────
function ProductRow({ product }: { product: ProductAggregate }) {
const [expanded, setExpanded] = useState(false)
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell>
<div className="flex items-center gap-2 max-w-[400px]">
{product.image ? (
<img src={product.image} alt="" className="w-7 h-7 object-cover rounded shrink-0" />
) : (
<div className="w-7 h-7 bg-muted rounded shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{product.title}</p>
<p className="text-xs text-muted-foreground">{product.sku}</p>
</div>
</div>
</TableCell>
<TableCell className="text-sm">{product.brand}</TableCell>
<TableCell className="text-right text-sm font-medium">{product.times_featured}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{product.first_featured_at ? new Date(product.first_featured_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{product.days_since_featured === 0 ? "Today" : `${product.days_since_featured}d ago`}
</TableCell>
<TableCell className="text-sm text-muted-foreground text-right">
{product.avg_days_between_features != null ? `${product.avg_days_between_features}d` : "—"}
</TableCell>
<TableCell className="w-[30px]">
{product.permalink && (
<a href={product.permalink} target="_blank" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={8} className="p-0">
<div className="bg-muted/30 p-3">
<p className="text-xs font-semibold mb-1.5">Campaigns ({product.campaigns.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
{product.campaigns.map((c) => (
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
<span className="text-muted-foreground shrink-0">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</span>
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
</div>
))}
</div>
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Skeleton loader ──────────────────────────────────
function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="p-4 space-y-3">
{Array.from({ length: rows }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
)
}
// ── Tab: Campaigns ───────────────────────────────────
function CampaignsTab({ data, isLoading }: { data: CampaignsResponse | undefined; isLoading: boolean }) {
return (
<div className="space-y-4">
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}><CardContent className="p-3"><Skeleton className="h-3 w-24 mb-1" /><Skeleton className="h-7 w-12" /></CardContent></Card>
))}
</div>
) : data?.summary ? (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Total Campaigns</p>
<p className="text-xl font-bold">{Number(data.summary.total_campaigns).toLocaleString()}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Unique Products Featured</p>
<p className="text-xl font-bold">{Number(data.summary.total_unique_products).toLocaleString()}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Avg Products / Campaign</p>
<p className="text-xl font-bold">{data.summary.avg_products_per_campaign}</p>
</CardContent>
</Card>
</div>
) : null}
<div className="flex-1 overflow-auto rounded-md border max-h-[50vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Campaign</TableHead>
<TableHead>Sent</TableHead>
<TableHead className="text-right">Products</TableHead>
<TableHead className="text-right">Links</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.campaigns.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No campaigns found</TableCell>
</TableRow>
) : (
data?.campaigns.map((c) => <CampaignRow key={c.campaign_id} campaign={c} />)
)}
</TableBody>
</Table>
)}
</div>
</div>
)
}
// ── 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 (
<div className="space-y-2">
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Product</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Avg Gap</TableHead>
<TableHead className="w-[30px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No products found</TableCell>
</TableRow>
) : (
pageProducts.map((p) => <ProductRow key={p.pid} product={p} />)
)}
</TableBody>
</Table>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{allProducts.length.toLocaleString()} products
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">Page {page} of {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
// ── Brand Row (expandable campaign list) ─────────────
function BrandRow({ brand }: { brand: BrandAggregate }) {
const [expanded, setExpanded] = useState(false)
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/50"
onClick={() => setExpanded(!expanded)}
>
<TableCell className="w-[30px]">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</TableCell>
<TableCell className="text-sm font-medium">{brand.brand}</TableCell>
<TableCell className="text-right text-sm">{brand.product_count}</TableCell>
<TableCell className="text-right text-sm font-medium">{brand.times_featured}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{brand.first_featured_at ? new Date(brand.first_featured_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{brand.days_since_featured === 0 ? "Today" : `${brand.days_since_featured}d ago`}
</TableCell>
<TableCell className="text-sm text-muted-foreground text-right">
{brand.avg_days_between_features != null ? `${brand.avg_days_between_features}d` : "—"}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={7} className="p-0">
<div className="bg-muted/30 p-3">
<p className="text-xs font-semibold mb-1.5">Campaigns ({brand.campaigns.length})</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
{brand.campaigns.map((c) => (
<div key={c.campaign_id} className="flex items-center gap-2 text-xs bg-background rounded px-2 py-1">
<span className="text-muted-foreground shrink-0">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</span>
<span className="truncate">{c.campaign_name || c.campaign_id}</span>
</div>
))}
</div>
</div>
</TableCell>
</TableRow>
)}
</>
)
}
// ── Tab: Brands ──────────────────────────────────────
function BrandsTab({ data, isLoading }: { data: { brands: BrandAggregate[] } | undefined; isLoading: boolean }) {
return (
<div className="flex-1 overflow-auto rounded-md border max-h-[55vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Products</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Avg Gap</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.brands.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No brands found</TableCell>
</TableRow>
) : (
data?.brands.map((b) => <BrandRow key={b.brand} brand={b} />)
)}
</TableBody>
</Table>
)}
</div>
)
}
// ── Tab: Links ───────────────────────────────────────
function LinksTab({ data, isLoading }: { data: { links: LinkAggregate[] } | undefined; isLoading: boolean }) {
return (
<div className="flex-1 overflow-auto rounded-md border max-h-[60vh]">
{isLoading ? <TableSkeleton /> : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Link</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Used</TableHead>
<TableHead>First</TableHead>
<TableHead>Last</TableHead>
<TableHead className="text-right">Campaigns</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.links.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No links found</TableCell>
</TableRow>
) : (
data?.links.map((l, i) => (
<TableRow key={i}>
<TableCell className="max-w-[500px]">
<a href={l.link_url} target="_blank" rel="noopener noreferrer"
className="text-sm text-blue-500 hover:underline truncate block">
{l.link_url}
</a>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{l.link_type || "other"}</Badge>
</TableCell>
<TableCell className="text-right text-sm font-medium">{l.times_used}×</TableCell>
<TableCell className="text-sm text-muted-foreground">
{l.first_used_at ? new Date(l.first_used_at).toLocaleDateString() : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{l.days_since_used === 0 ? "Today" : `${l.days_since_used}d ago`}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{l.campaign_names?.length ?? 0}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
)
}
// ── Main Dialog ──────────────────────────────────────
export function CampaignHistoryDialog() {
const [open, setOpen] = useState(false)
const { campaigns, products, brands, links } = useCampaignData(open)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<History className="h-4 w-4 mr-2" />
Campaign History
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Newsletter Campaign History</DialogTitle>
</DialogHeader>
<Tabs defaultValue="campaigns" className="">
<TabsList>
<TabsTrigger value="campaigns">Campaigns</TabsTrigger>
<TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="brands">Brands</TabsTrigger>
<TabsTrigger value="links">Links</TabsTrigger>
</TabsList>
<TabsContent value="campaigns" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<CampaignsTab data={campaigns.data} isLoading={campaigns.isLoading} />
</TabsContent>
<TabsContent value="products" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<ProductsTab data={products.data} isLoading={products.isLoading} />
</TabsContent>
<TabsContent value="brands" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<BrandsTab data={brands.data} isLoading={brands.isLoading} />
</TabsContent>
<TabsContent value="links" forceMount className="flex-1 min-h-0 data-[state=inactive]:hidden">
<LinksTab data={links.data} isLoading={links.isLoading} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-16 mt-1" />
</CardContent>
</Card>
))}
</div>
)
}
const stats = [
{
@@ -66,7 +83,7 @@ export function NewsletterStats() {
return (
<TooltipProvider>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="p-4">
@@ -78,6 +95,7 @@ export function NewsletterStats() {
<Info className="h-3 w-3 shrink-0 cursor-help opacity-50 hover:opacity-100 transition-opacity" />
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px]">
<p className="text-xs font-medium">{s.label}</p>
<p>{s.tooltip}</p>
</TooltipContent>
</Tooltip>

View File

@@ -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 (
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-center gap-1.5">
<span className="text-sm">{directCount}×</span>
{hasLineHistory && (
<TooltipProvider>
@@ -108,7 +110,7 @@ function LastFeaturedCell({ p }: { p: Product }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex items-center gap-1 text-blue-500">
<TooltipTrigger className="flex items-center justify-center gap-1 text-blue-500">
<Layers className="h-3 w-3" />
<span>{lineLabel}</span>
</TooltipTrigger>
@@ -122,10 +124,124 @@ function LastFeaturedCell({ p }: { p: Product }) {
}
return <span>Never</span>
}
function CopyPidButton({ pid }: { pid: number }) {
const [copied, setCopied] = useState(false)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(String(pid))
toast.success(`Copied PID ${pid}`)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{copied ? "Copied!" : "Copy product ID"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
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<keyof ScoreBreakdown, string> = {
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<ScoreBreakdown>({
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild onMouseEnter={() => setHovered(true)}>
{children}
</TooltipTrigger>
<TooltipContent side="right" className="p-0">
<div className="p-2 min-w-[180px]">
<p className="text-xs font-semibold mb-1.5 border-b pb-1">Score Breakdown: {score}</p>
{data ? (
<div className="space-y-0.5">
{(Object.keys(SCORE_LABELS) as (keyof ScoreBreakdown)[]).map(k => {
const v = Number(data[k])
if (v === 0) return null
return (
<div key={k} className="flex justify-between text-xs gap-4">
<span className="text-muted-foreground">{SCORE_LABELS[k]}</span>
<span className={v > 0 ? "text-green-600" : "text-red-500"}>{v > 0 ? "+" : ""}{v}</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">Loading</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
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 (
<TableHead className={`${className ?? ""} cursor-pointer select-none`} onClick={() => onSort(column)}>
<div className={`flex items-center gap-1 ${className?.includes("text-right") ? "justify-end" : className?.includes("text-center") ? "justify-center" : ""}`}>
<span>{label}</span>
{active && sort.direction === "asc" ? <ArrowUp className="h-3 w-3" /> :
active && sort.direction === "desc" ? <ArrowDown className="h-3 w-3" /> :
<ArrowUpDown className="h-3 w-3 opacity-30" />}
</div>
</TableHead>
)
}
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<SortState>({ column: null, direction: null })
const limit = 100
const { data, isLoading } = useQuery<RecommendationResponse>({
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 <div className="text-center py-12 text-muted-foreground">Loading recommendations</div>
}
const products = data?.products ?? []
const pagination = data?.pagination
return (
<div>
<div className="rounded-md border">
<Table>
<div className="rounded-md border overflow-x-auto">
<Table className="">
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">Score</TableHead>
<SortableHeader label="Score" column="score" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="w-[50px]" />
<TableHead className="w-[60px]">Image</TableHead>
<TableHead>Product</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">7d Sales</TableHead>
<TableHead className="text-right">30d Sales</TableHead>
<SortableHeader label="Brand" column="brand" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
<SortableHeader label="Price" column="price" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="Stock" column="stock" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="7d Sales" column="sales_7d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="30d Sales" column="sales_30d" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<TableHead>Tags</TableHead>
<TableHead className="text-right">Featured</TableHead>
<TableHead>Last Featured</TableHead>
<TableHead className="w-[40px]"></TableHead>
<SortableHeader label="Featured" column="times_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} className="text-center" />
<SortableHeader label="Last Featured" column="days_since_featured" sort={sort} onSort={(c) => setSort(toggleSort(sort, c))} />
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -176,6 +312,17 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
products.map((p) => (
<TableRow key={p.pid}>
<TableCell>
{canDebug ? (
<ScoreBreakdownTooltip pid={p.pid} score={p.score}>
<span className={`font-mono font-bold text-sm cursor-help ${
p.score >= 40 ? "text-green-600" :
p.score >= 20 ? "text-yellow-600" :
"text-muted-foreground"
}`}>
{p.score}
</span>
</ScoreBreakdownTooltip>
) : (
<span className={`font-mono font-bold text-sm ${
p.score >= 40 ? "text-green-600" :
p.score >= 20 ? "text-yellow-600" :
@@ -183,6 +330,7 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
}`}>
{p.score}
</span>
)}
</TableCell>
<TableCell>
{p.image ? (
@@ -192,16 +340,15 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
)}
</TableCell>
<TableCell>
<div className="max-w-[250px]">
<p className="font-medium text-sm truncate">{p.title}</p>
<p className="text-xs text-muted-foreground">{p.sku}</p>
<div className="max-w-[400px]">
<p className="font-medium text-sm line-clamp-2">{p.title}</p>
{p.line && (
<p className="text-[10px] text-muted-foreground/70 truncate">{p.line}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm">{p.brand}</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
<div>
{p.is_daily_deal && p.deal_price ? (
<>
@@ -230,7 +377,7 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
)}
</div>
</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
{p.current_stock ?? 0}
</span>
@@ -238,16 +385,16 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span>
)}
</TableCell>
<TableCell className="text-right text-sm">{p.sales_7d ?? 0}</TableCell>
<TableCell className="text-right text-sm">{p.sales_30d ?? 0}</TableCell>
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>
<TableCell className="text-center text-sm">{p.sales_30d ?? 0}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0">New</Badge>}
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">Pre-Order</Badge>}
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Clearance</Badge>}
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0 whitespace-nowrap">New</Badge>}
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Pre-Order</Badge>}
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Clearance</Badge>}
{p.is_daily_deal && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 bg-orange-500">Deal</Badge>}
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0">Back in Stock</Badge>}
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500">Low Stock</Badge>}
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 whitespace-nowrap">Back in Stock</Badge>}
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500 whitespace-nowrap">Low Stock</Badge>}
{(p.baskets > 0 || p.notifies > 0) && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-purple-500">
{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) {
)}
</div>
</TableCell>
<TableCell className="text-right text-sm">
<TableCell className="text-center text-sm">
<FeaturedCell p={p} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<TableCell className="text-center text-sm text-muted-foreground">
<LastFeaturedCell p={p} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<CopyPidButton pid={p.pid} />
{p.permalink && (
<a href={p.permalink} target="_blank" rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
>
<a href={p.permalink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
Open in shop
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))

View File

@@ -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() {
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Newsletter Recommendations</h2>
<CampaignHistoryDialog />
</div>
<NewsletterStats />

File diff suppressed because one or more lines are too long