Compare commits
4 Commits
dd0e989669
...
a703019b0b
| Author | SHA1 | Date | |
|---|---|---|---|
| a703019b0b | |||
| 2744e82264 | |||
| 450fd96e19 | |||
| 4372dc5e26 |
@@ -16,6 +16,7 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"luxon": "^3.5.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.18.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1379,6 +1380,95 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
"pg-protocol": "^1.11.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1392,6 +1482,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -1809,6 +1938,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
@@ -1952,6 +2090,15 @@
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"luxon": "^3.5.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.18.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Stores individual product links found in Klaviyo campaign emails
|
||||
CREATE TABLE IF NOT EXISTS klaviyo_campaign_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
campaign_id TEXT NOT NULL,
|
||||
campaign_name TEXT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
pid BIGINT NOT NULL,
|
||||
product_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(campaign_id, pid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kcp_campaign_id ON klaviyo_campaign_products(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kcp_pid ON klaviyo_campaign_products(pid);
|
||||
CREATE INDEX IF NOT EXISTS idx_kcp_sent_at ON klaviyo_campaign_products(sent_at);
|
||||
|
||||
-- Stores non-product shop links (categories, filters, etc.) found in campaigns
|
||||
CREATE TABLE IF NOT EXISTS klaviyo_campaign_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
campaign_id TEXT NOT NULL,
|
||||
campaign_name TEXT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
link_url TEXT NOT NULL,
|
||||
link_type TEXT, -- 'category', 'brand', 'filter', 'clearance', 'deals', 'other'
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(campaign_id, link_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kcl_campaign_id ON klaviyo_campaign_links(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kcl_sent_at ON klaviyo_campaign_links(sent_at);
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Extract products featured in Klaviyo campaign emails and store in DB.
|
||||
*
|
||||
* - Fetches recent sent campaigns from Klaviyo API
|
||||
* - Gets template HTML for each campaign message
|
||||
* - Parses out product links (/shop/{id}) and other shop links
|
||||
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
|
||||
*
|
||||
* Usage: node scripts/poc-campaign-products.js [limit] [offset]
|
||||
* limit: number of sent campaigns to process (default: 10)
|
||||
* offset: number of sent campaigns to skip before processing (default: 0)
|
||||
*
|
||||
* Requires DB_* env vars (from inventory-server .env) and KLAVIYO_API_KEY.
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import pg from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Load klaviyo .env for API key
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
// Also load the main inventory-server .env for DB credentials
|
||||
const mainEnvPath = '/var/www/html/inventory/.env';
|
||||
if (fs.existsSync(mainEnvPath)) {
|
||||
dotenv.config({ path: mainEnvPath });
|
||||
}
|
||||
|
||||
const API_KEY = process.env.KLAVIYO_API_KEY;
|
||||
const REVISION = process.env.KLAVIYO_API_REVISION || '2026-01-15';
|
||||
const BASE_URL = 'https://a.klaviyo.com/api';
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('KLAVIYO_API_KEY not set in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Klaviyo API helpers ──────────────────────────────────────────────
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Klaviyo-API-Key ${API_KEY}`,
|
||||
'revision': REVISION,
|
||||
};
|
||||
|
||||
async function klaviyoGet(endpoint, params = {}) {
|
||||
const url = new URL(`${BASE_URL}${endpoint}`);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.append(k, v);
|
||||
}
|
||||
return klaviyoFetch(url.toString());
|
||||
}
|
||||
|
||||
async function klaviyoFetch(url) {
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Klaviyo ${res.status} on ${url}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function getRecentCampaigns(limit, offset = 0) {
|
||||
const campaigns = [];
|
||||
const messageMap = {};
|
||||
let skipped = 0;
|
||||
|
||||
let data = await klaviyoGet('/campaigns', {
|
||||
'filter': 'equals(messages.channel,"email")',
|
||||
'sort': '-scheduled_at',
|
||||
'include': 'campaign-messages',
|
||||
});
|
||||
|
||||
while (true) {
|
||||
for (const c of (data.data || [])) {
|
||||
if (c.attributes?.status === 'Sent') {
|
||||
if (skipped < offset) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
campaigns.push(c);
|
||||
if (campaigns.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const inc of (data.included || [])) {
|
||||
if (inc.type === 'campaign-message') {
|
||||
messageMap[inc.id] = inc;
|
||||
}
|
||||
}
|
||||
|
||||
const nextUrl = data.links?.next;
|
||||
if (campaigns.length >= limit || !nextUrl) break;
|
||||
|
||||
const progress = skipped < offset
|
||||
? `Skipped ${skipped}/${offset}...`
|
||||
: `Fetched ${campaigns.length}/${limit} sent campaigns, loading next page...`;
|
||||
console.log(` ${progress}`);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
data = await klaviyoFetch(nextUrl);
|
||||
}
|
||||
|
||||
return { campaigns: campaigns.slice(0, limit), messageMap };
|
||||
}
|
||||
|
||||
async function getTemplateHtml(messageId) {
|
||||
const data = await klaviyoGet(`/campaign-messages/${messageId}/template`, {
|
||||
'fields[template]': 'html,name',
|
||||
});
|
||||
return {
|
||||
templateId: data.data?.id,
|
||||
templateName: data.data?.attributes?.name,
|
||||
html: data.data?.attributes?.html || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── HTML parsing ─────────────────────────────────────────────────────
|
||||
|
||||
function parseProductsFromHtml(html) {
|
||||
const seen = new Set();
|
||||
const products = [];
|
||||
|
||||
const linkRegex = /href="([^"]*acherryontop\.com\/shop\/(\d+))[^"]*"/gi;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(html)) !== null) {
|
||||
const productId = match[2];
|
||||
if (!seen.has(productId)) {
|
||||
seen.add(productId);
|
||||
products.push({
|
||||
siteProductId: productId,
|
||||
url: match[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const categoryLinks = [];
|
||||
const catRegex = /href="([^"]*acherryontop\.com\/shop\/[^"]+)"/gi;
|
||||
while ((match = catRegex.exec(html)) !== null) {
|
||||
const url = match[1];
|
||||
if (/\/shop\/\d+$/.test(url)) continue;
|
||||
if (!categoryLinks.includes(url)) categoryLinks.push(url);
|
||||
}
|
||||
|
||||
return { products, categoryLinks };
|
||||
}
|
||||
|
||||
function classifyLink(url) {
|
||||
if (/\/shop\/(new|pre-order|backinstock)/.test(url)) return 'filter';
|
||||
if (/\/shop\/company\//.test(url)) return 'brand';
|
||||
if (/\/shop\/clearance/.test(url)) return 'clearance';
|
||||
if (/\/shop\/daily_deals/.test(url)) return 'deals';
|
||||
if (/\/shop\/category\//.test(url)) return 'category';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ── Database ─────────────────────────────────────────────────────────
|
||||
|
||||
function createPool() {
|
||||
return new pg.Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
}
|
||||
|
||||
async function insertProducts(pool, campaignId, campaignName, sentAt, products) {
|
||||
if (products.length === 0) return 0;
|
||||
|
||||
let inserted = 0;
|
||||
for (const p of products) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO klaviyo_campaign_products
|
||||
(campaign_id, campaign_name, sent_at, pid, product_url)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (campaign_id, pid) DO NOTHING`,
|
||||
[campaignId, campaignName, sentAt, parseInt(p.siteProductId), p.url]
|
||||
);
|
||||
inserted++;
|
||||
} catch (err) {
|
||||
console.error(` Error inserting product ${p.siteProductId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function insertLinks(pool, campaignId, campaignName, sentAt, links) {
|
||||
if (links.length === 0) return 0;
|
||||
|
||||
let inserted = 0;
|
||||
for (const url of links) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO klaviyo_campaign_links
|
||||
(campaign_id, campaign_name, sent_at, link_url, link_type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (campaign_id, link_url) DO NOTHING`,
|
||||
[campaignId, campaignName, sentAt, url, classifyLink(url)]
|
||||
);
|
||||
inserted++;
|
||||
} catch (err) {
|
||||
console.error(` Error inserting link: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const limit = parseInt(process.argv[2]) || 10;
|
||||
const offset = parseInt(process.argv[3]) || 0;
|
||||
const pool = createPool();
|
||||
|
||||
try {
|
||||
// Fetch campaigns
|
||||
console.log(`Fetching up to ${limit} recent campaigns (offset: ${offset})...\n`);
|
||||
const { campaigns, messageMap } = await getRecentCampaigns(limit, offset);
|
||||
console.log(`Found ${campaigns.length} sent campaigns.\n`);
|
||||
|
||||
let totalProducts = 0;
|
||||
let totalLinks = 0;
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
const name = campaign.attributes?.name || 'Unnamed';
|
||||
const sentAt = campaign.attributes?.send_time;
|
||||
|
||||
console.log(`━━━ ${name} (${sentAt?.slice(0, 10) || 'no date'}) ━━━`);
|
||||
|
||||
const msgIds = (campaign.relationships?.['campaign-messages']?.data || [])
|
||||
.map(r => r.id);
|
||||
|
||||
if (msgIds.length === 0) {
|
||||
console.log(' No messages.\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const msgId of msgIds) {
|
||||
const msg = messageMap[msgId];
|
||||
const subject = msg?.attributes?.definition?.content?.subject;
|
||||
if (subject) console.log(` Subject: ${subject}`);
|
||||
|
||||
try {
|
||||
const template = await getTemplateHtml(msgId);
|
||||
const { products, categoryLinks } = parseProductsFromHtml(template.html);
|
||||
|
||||
const pInserted = await insertProducts(pool, campaign.id, name, sentAt, products);
|
||||
const lInserted = await insertLinks(pool, campaign.id, name, sentAt, categoryLinks);
|
||||
|
||||
console.log(` ${products.length} products (${pInserted} new), ${categoryLinks.length} links (${lInserted} new)`);
|
||||
totalProducts += pInserted;
|
||||
totalLinks += lInserted;
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (err) {
|
||||
console.log(` Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`Done. Inserted ${totalProducts} product rows, ${totalLinks} link rows.`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
17
inventory-server/db/daily-deals-schema.sql
Normal file
17
inventory-server/db/daily-deals-schema.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Daily Deals schema for local PostgreSQL
|
||||
-- Synced from production MySQL product_daily_deals + product_current_prices
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_daily_deals (
|
||||
deal_id serial PRIMARY KEY,
|
||||
deal_date date NOT NULL,
|
||||
pid bigint NOT NULL,
|
||||
price_id bigint NOT NULL,
|
||||
-- Denormalized from product_current_prices so we don't need to sync that whole table
|
||||
deal_price numeric(10,3),
|
||||
created_at timestamptz DEFAULT NOW(),
|
||||
CONSTRAINT fk_daily_deals_pid FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_deals_date ON product_daily_deals(deal_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_deals_pid ON product_daily_deals(pid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_deals_unique ON product_daily_deals(deal_date, pid);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ const importCategories = require('./import/categories');
|
||||
const { importProducts } = require('./import/products');
|
||||
const importOrders = require('./import/orders');
|
||||
const importPurchaseOrders = require('./import/purchase-orders');
|
||||
const importDailyDeals = require('./import/daily-deals');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
@@ -14,6 +15,7 @@ const IMPORT_CATEGORIES = true;
|
||||
const IMPORT_PRODUCTS = true;
|
||||
const IMPORT_ORDERS = true;
|
||||
const IMPORT_PURCHASE_ORDERS = true;
|
||||
const IMPORT_DAILY_DEALS = true;
|
||||
|
||||
// Add flag for incremental updates
|
||||
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
||||
@@ -78,7 +80,8 @@ async function main() {
|
||||
IMPORT_CATEGORIES,
|
||||
IMPORT_PRODUCTS,
|
||||
IMPORT_ORDERS,
|
||||
IMPORT_PURCHASE_ORDERS
|
||||
IMPORT_PURCHASE_ORDERS,
|
||||
IMPORT_DAILY_DEALS
|
||||
].filter(Boolean).length;
|
||||
|
||||
try {
|
||||
@@ -126,10 +129,11 @@ async function main() {
|
||||
'categories_enabled', $2::boolean,
|
||||
'products_enabled', $3::boolean,
|
||||
'orders_enabled', $4::boolean,
|
||||
'purchase_orders_enabled', $5::boolean
|
||||
'purchase_orders_enabled', $5::boolean,
|
||||
'daily_deals_enabled', $6::boolean
|
||||
)
|
||||
) RETURNING id
|
||||
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
|
||||
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS]);
|
||||
importHistoryId = historyResult.rows[0].id;
|
||||
} catch (error) {
|
||||
console.error("Error creating import history record:", error);
|
||||
@@ -146,7 +150,8 @@ async function main() {
|
||||
categories: null,
|
||||
products: null,
|
||||
orders: null,
|
||||
purchaseOrders: null
|
||||
purchaseOrders: null,
|
||||
dailyDeals: null
|
||||
};
|
||||
|
||||
let totalRecordsAdded = 0;
|
||||
@@ -224,6 +229,34 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (IMPORT_DAILY_DEALS) {
|
||||
try {
|
||||
const stepStart = Date.now();
|
||||
results.dailyDeals = await importDailyDeals(prodConnection, localConnection);
|
||||
stepTimings.dailyDeals = Math.round((Date.now() - stepStart) / 1000);
|
||||
|
||||
if (isImportCancelled) throw new Error("Import cancelled");
|
||||
completedSteps++;
|
||||
console.log('Daily deals import result:', results.dailyDeals);
|
||||
|
||||
if (results.dailyDeals?.status === 'error') {
|
||||
console.error('Daily deals import had an error:', results.dailyDeals.error);
|
||||
} else {
|
||||
totalRecordsAdded += parseInt(results.dailyDeals?.recordsAdded || 0);
|
||||
totalRecordsUpdated += parseInt(results.dailyDeals?.recordsUpdated || 0);
|
||||
totalRecordsDeleted += parseInt(results.dailyDeals?.recordsDeleted || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during daily deals import:', error);
|
||||
results.dailyDeals = {
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
recordsAdded: 0,
|
||||
recordsUpdated: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
||||
|
||||
@@ -241,15 +274,17 @@ async function main() {
|
||||
'products_enabled', $5::boolean,
|
||||
'orders_enabled', $6::boolean,
|
||||
'purchase_orders_enabled', $7::boolean,
|
||||
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
|
||||
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
|
||||
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
|
||||
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
||||
'total_deleted', $12::integer,
|
||||
'total_skipped', $13::integer,
|
||||
'step_timings', $14::jsonb
|
||||
'daily_deals_enabled', $8::boolean,
|
||||
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
|
||||
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
|
||||
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
||||
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
|
||||
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
|
||||
'total_deleted', $14::integer,
|
||||
'total_skipped', $15::integer,
|
||||
'step_timings', $16::jsonb
|
||||
)
|
||||
WHERE id = $15
|
||||
WHERE id = $17
|
||||
`, [
|
||||
totalElapsedSeconds,
|
||||
parseInt(totalRecordsAdded),
|
||||
@@ -258,10 +293,12 @@ async function main() {
|
||||
IMPORT_PRODUCTS,
|
||||
IMPORT_ORDERS,
|
||||
IMPORT_PURCHASE_ORDERS,
|
||||
IMPORT_DAILY_DEALS,
|
||||
JSON.stringify(results.categories),
|
||||
JSON.stringify(results.products),
|
||||
JSON.stringify(results.orders),
|
||||
JSON.stringify(results.purchaseOrders),
|
||||
JSON.stringify(results.dailyDeals),
|
||||
totalRecordsDeleted,
|
||||
totalRecordsSkipped,
|
||||
JSON.stringify(stepTimings),
|
||||
|
||||
167
inventory-server/scripts/import/daily-deals.js
Normal file
167
inventory-server/scripts/import/daily-deals.js
Normal file
@@ -0,0 +1,167 @@
|
||||
const { outputProgress, formatElapsedTime } = require('../metrics-new/utils/progress');
|
||||
|
||||
/**
|
||||
* Import daily deals from production MySQL to local PostgreSQL.
|
||||
*
|
||||
* Production has two tables:
|
||||
* - product_daily_deals (deal_id, deal_date, pid, price_id)
|
||||
* - product_current_prices (price_id, pid, price_each, active, ...)
|
||||
*
|
||||
* We join them in the prod query to denormalize the deal price, avoiding
|
||||
* the need to sync the full product_current_prices table.
|
||||
*
|
||||
* On each sync:
|
||||
* 1. Fetch deals from the last 7 days (plus today) from production
|
||||
* 2. Upsert into local table
|
||||
* 3. Hard delete local deals older than 7 days past their deal_date
|
||||
*/
|
||||
async function importDailyDeals(prodConnection, localConnection) {
|
||||
outputProgress({
|
||||
operation: "Starting daily deals import",
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await localConnection.query('BEGIN');
|
||||
|
||||
// Fetch recent daily deals from production (MySQL 5.7, no CTEs)
|
||||
// Join product_current_prices to get the actual deal price
|
||||
// Only grab last 7 days + today + tomorrow (for pre-scheduled deals)
|
||||
const [deals] = await prodConnection.query(`
|
||||
SELECT
|
||||
pdd.deal_id,
|
||||
pdd.deal_date,
|
||||
pdd.pid,
|
||||
pdd.price_id,
|
||||
pcp.price_each as deal_price
|
||||
FROM product_daily_deals pdd
|
||||
LEFT JOIN product_current_prices pcp ON pcp.price_id = pdd.price_id
|
||||
WHERE pdd.deal_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
AND pdd.deal_date <= DATE_ADD(CURDATE(), INTERVAL 1 DAY)
|
||||
ORDER BY pdd.deal_date DESC, pdd.pid
|
||||
`);
|
||||
|
||||
outputProgress({
|
||||
status: "running",
|
||||
operation: "Daily deals import",
|
||||
message: `Fetched ${deals.length} deals from production`,
|
||||
elapsed: formatElapsedTime(startTime),
|
||||
});
|
||||
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
if (deals.length > 0) {
|
||||
// Batch upsert — filter to only PIDs that exist locally
|
||||
const pids = [...new Set(deals.map(d => d.pid))];
|
||||
const existingResult = await localConnection.query(
|
||||
`SELECT pid FROM products WHERE pid = ANY($1)`,
|
||||
[pids]
|
||||
);
|
||||
const existingPids = new Set(
|
||||
(Array.isArray(existingResult) ? existingResult[0] : existingResult)
|
||||
.rows.map(r => Number(r.pid))
|
||||
);
|
||||
|
||||
const validDeals = deals.filter(d => existingPids.has(Number(d.pid)));
|
||||
|
||||
if (validDeals.length > 0) {
|
||||
// Build batch upsert
|
||||
const values = validDeals.flatMap(d => [
|
||||
d.deal_date,
|
||||
d.pid,
|
||||
d.price_id,
|
||||
d.deal_price ?? null,
|
||||
]);
|
||||
|
||||
const placeholders = validDeals
|
||||
.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`)
|
||||
.join(',');
|
||||
|
||||
const upsertQuery = `
|
||||
WITH upserted AS (
|
||||
INSERT INTO product_daily_deals (deal_date, pid, price_id, deal_price)
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT (deal_date, pid) DO UPDATE SET
|
||||
price_id = EXCLUDED.price_id,
|
||||
deal_price = EXCLUDED.deal_price
|
||||
WHERE
|
||||
product_daily_deals.price_id IS DISTINCT FROM EXCLUDED.price_id OR
|
||||
product_daily_deals.deal_price IS DISTINCT FROM EXCLUDED.deal_price
|
||||
RETURNING
|
||||
CASE WHEN xmax = 0 THEN true ELSE false END as is_insert
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE is_insert) as inserted,
|
||||
COUNT(*) FILTER (WHERE NOT is_insert) as updated
|
||||
FROM upserted
|
||||
`;
|
||||
|
||||
const result = await localConnection.query(upsertQuery, values);
|
||||
const queryResult = Array.isArray(result) ? result[0] : result;
|
||||
totalInserted = parseInt(queryResult.rows[0].inserted) || 0;
|
||||
totalUpdated = parseInt(queryResult.rows[0].updated) || 0;
|
||||
}
|
||||
|
||||
const skipped = deals.length - validDeals.length;
|
||||
if (skipped > 0) {
|
||||
console.log(`Skipped ${skipped} deals (PIDs not in local products table)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard delete deals older than 7 days past their deal_date
|
||||
const deleteResult = await localConnection.query(`
|
||||
DELETE FROM product_daily_deals
|
||||
WHERE deal_date < CURRENT_DATE - INTERVAL '7 days'
|
||||
`);
|
||||
const deletedCount = deleteResult.rowCount ??
|
||||
(Array.isArray(deleteResult) ? deleteResult[0]?.rowCount : 0) ?? 0;
|
||||
|
||||
// Update sync status
|
||||
await localConnection.query(`
|
||||
INSERT INTO sync_status (table_name, last_sync_timestamp)
|
||||
VALUES ('product_daily_deals', NOW())
|
||||
ON CONFLICT (table_name) DO UPDATE SET
|
||||
last_sync_timestamp = NOW()
|
||||
`);
|
||||
|
||||
await localConnection.query('COMMIT');
|
||||
|
||||
outputProgress({
|
||||
status: "complete",
|
||||
operation: "Daily deals import completed",
|
||||
message: `Inserted ${totalInserted}, updated ${totalUpdated}, deleted ${deletedCount} expired`,
|
||||
current: totalInserted + totalUpdated,
|
||||
total: totalInserted + totalUpdated,
|
||||
duration: formatElapsedTime(startTime),
|
||||
});
|
||||
|
||||
return {
|
||||
status: "complete",
|
||||
recordsAdded: totalInserted,
|
||||
recordsUpdated: totalUpdated,
|
||||
recordsDeleted: deletedCount,
|
||||
totalRecords: totalInserted + totalUpdated,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error importing daily deals:", error);
|
||||
|
||||
try {
|
||||
await localConnection.query('ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
console.error("Error during rollback:", rollbackError);
|
||||
}
|
||||
|
||||
outputProgress({
|
||||
status: "error",
|
||||
operation: "Daily deals import failed",
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = importDailyDeals;
|
||||
@@ -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
|
||||
|
||||
724
inventory-server/src/routes/newsletter.js
Normal file
724
inventory-server/src/routes/newsletter.js
Normal file
@@ -0,0 +1,724 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Shared CTE fragment for the reference date.
|
||||
// Uses MAX(last_calculated) from product_metrics so time-relative logic works
|
||||
// correctly even when the local data snapshot is behind real-time.
|
||||
const REF_DATE_CTE = `
|
||||
ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics)
|
||||
`;
|
||||
|
||||
// Category definitions matching production website logic:
|
||||
//
|
||||
// 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 (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: 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
|
||||
// - Back in stock excludes new products and preorder products
|
||||
// - Clearance is independent (a bestseller can also be clearance)
|
||||
|
||||
const CATEGORY_FILTERS = {
|
||||
new: "AND is_new = true",
|
||||
preorder: "AND is_preorder = true",
|
||||
clearance: "AND is_clearance = true",
|
||||
daily_deals: "AND is_daily_deal = true",
|
||||
back_in_stock: "AND is_back_in_stock = true",
|
||||
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",
|
||||
no_interest: "AND COALESCE(total_sold, 0) = 0 AND COALESCE(current_stock, 0) > 0 AND COALESCE(date_online, product_created_at) <= CURRENT_DATE - INTERVAL '30 days'",
|
||||
};
|
||||
|
||||
function buildScoredCTE({ forCount = false } = {}) {
|
||||
// forCount=true returns minimal columns for COUNT(*)
|
||||
const selectColumns = forCount ? `
|
||||
p.pid,
|
||||
p.created_at as product_created_at,
|
||||
p.date_online,
|
||||
p.shop_score,
|
||||
p.preorder_count,
|
||||
p.price,
|
||||
p.regular_price,
|
||||
p.total_sold,
|
||||
p.line,
|
||||
pm.current_stock,
|
||||
pm.on_order_qty,
|
||||
pm.sales_30d,
|
||||
pm.sales_7d,
|
||||
pm.date_last_received,
|
||||
pm.date_first_received,
|
||||
nh.times_featured,
|
||||
nh.last_featured_at,
|
||||
lh.line_last_featured_at,
|
||||
dd.deal_id,
|
||||
dd.deal_price
|
||||
` : `
|
||||
p.pid,
|
||||
p.title,
|
||||
p.sku,
|
||||
p.brand,
|
||||
p.vendor,
|
||||
p.price,
|
||||
p.regular_price,
|
||||
p.shop_score,
|
||||
p.image_175 as image,
|
||||
p.permalink,
|
||||
p.stock_quantity,
|
||||
p.preorder_count,
|
||||
p.tags,
|
||||
p.categories,
|
||||
p.line,
|
||||
p.created_at as product_created_at,
|
||||
p.date_online,
|
||||
p.first_received,
|
||||
p.date_last_sold,
|
||||
p.total_sold,
|
||||
p.baskets,
|
||||
p.notifies,
|
||||
pm.sales_7d,
|
||||
pm.sales_30d,
|
||||
pm.revenue_30d,
|
||||
pm.current_stock,
|
||||
pm.on_order_qty,
|
||||
pm.abc_class,
|
||||
pm.date_first_received,
|
||||
pm.date_last_received,
|
||||
pm.sales_velocity_daily,
|
||||
pm.sells_out_in_days,
|
||||
pm.sales_growth_30d_vs_prev,
|
||||
pm.margin_30d,
|
||||
-- Direct product feature history
|
||||
nh.times_featured,
|
||||
nh.last_featured_at,
|
||||
EXTRACT(DAY FROM ref.d - nh.last_featured_at)::int as days_since_featured,
|
||||
-- Line-level feature history
|
||||
lh.line_products_featured,
|
||||
lh.line_total_features,
|
||||
lh.line_last_featured_at,
|
||||
lh.line_products_featured_30d,
|
||||
lh.line_products_featured_7d,
|
||||
ls.line_product_count,
|
||||
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 - COALESCE(p.date_online, p.created_at))::int as age_days
|
||||
`;
|
||||
|
||||
return `
|
||||
${REF_DATE_CTE},
|
||||
newsletter_history AS (
|
||||
SELECT
|
||||
pid,
|
||||
COUNT(*) as times_featured,
|
||||
MAX(sent_at) as last_featured_at,
|
||||
MIN(sent_at) as first_featured_at
|
||||
FROM klaviyo_campaign_products
|
||||
GROUP BY pid
|
||||
),
|
||||
line_history AS (
|
||||
SELECT
|
||||
p2.line,
|
||||
COUNT(DISTINCT kcp.pid) as line_products_featured,
|
||||
COUNT(*) as line_total_features,
|
||||
MAX(kcp.sent_at) as line_last_featured_at,
|
||||
COUNT(DISTINCT kcp.pid) FILTER (
|
||||
WHERE kcp.sent_at > (SELECT d FROM ref) - INTERVAL '30 days'
|
||||
) as line_products_featured_30d,
|
||||
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
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
${selectColumns},
|
||||
|
||||
-- === CATEGORY FLAGS (production-accurate, mutually exclusive where needed) ===
|
||||
|
||||
-- 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 COALESCE(p.date_online, p.created_at) > ref.d - INTERVAL '31 days' THEN true
|
||||
ELSE false
|
||||
END as is_new,
|
||||
|
||||
-- PRE-ORDER: has preorder quantity
|
||||
CASE
|
||||
WHEN p.preorder_count > 0 THEN true
|
||||
ELSE false
|
||||
END as is_preorder,
|
||||
|
||||
-- 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
|
||||
THEN true
|
||||
ELSE false
|
||||
END as is_clearance,
|
||||
|
||||
-- DAILY DEALS: product has an active deal for today
|
||||
CASE WHEN dd.deal_id IS NOT NULL THEN true ELSE false END as is_daily_deal,
|
||||
dd.deal_price,
|
||||
|
||||
-- DISCOUNT %
|
||||
CASE
|
||||
WHEN p.price > 0 AND p.regular_price > 0 AND p.price < p.regular_price
|
||||
THEN ROUND(((p.regular_price - p.price) / p.regular_price * 100)::numeric, 0)
|
||||
ELSE 0
|
||||
END as discount_pct,
|
||||
|
||||
CASE WHEN pm.current_stock > 0 AND pm.current_stock <= 5 THEN true ELSE false END as is_low_stock,
|
||||
|
||||
-- BACK IN STOCK: restocked product, not new, not preorder
|
||||
-- Matches prod: date_refill within X days, date_refill > datein,
|
||||
-- NOT datein within last 30 days (excludes new products)
|
||||
-- We use date_last_received/date_first_received as our equivalents
|
||||
CASE
|
||||
WHEN p.preorder_count > 0 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'
|
||||
AND pm.current_stock > 0
|
||||
THEN true
|
||||
ELSE false
|
||||
END as is_back_in_stock,
|
||||
|
||||
-- === RECOMMENDATION SCORE ===
|
||||
(
|
||||
-- New product boost (first 31 days by date_online, not preorder)
|
||||
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
|
||||
-- Pre-order boost
|
||||
+ CASE WHEN p.preorder_count > 0 THEN 30 ELSE 0 END
|
||||
-- Clearance boost (scaled by discount depth)
|
||||
+ 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
|
||||
-- Sales velocity boost (prod's "hot" logic: recent purchase count)
|
||||
+ 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
|
||||
-- Back in stock boost (only for actual restocks, not new arrivals)
|
||||
+ 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
|
||||
-- High interest (baskets + notifies)
|
||||
+ LEAST((COALESCE(p.baskets, 0) + COALESCE(p.notifies, 0)) / 2, 15)
|
||||
-- Recency penalty: line-aware effective last featured (tuned for daily sends)
|
||||
+ 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
|
||||
-- Over-featured penalty (direct product only, tuned for daily sends)
|
||||
+ CASE
|
||||
WHEN COALESCE(nh.times_featured, 0) > 15 THEN -10
|
||||
WHEN COALESCE(nh.times_featured, 0) > 8 THEN -5
|
||||
ELSE 0
|
||||
END
|
||||
-- Line saturation penalty (uses 7-day window for daily send cadence)
|
||||
+ CASE
|
||||
WHEN lh.line_products_featured_7d IS NOT NULL
|
||||
AND ls.line_product_count IS NOT NULL
|
||||
AND ls.line_product_count > 0
|
||||
AND (lh.line_products_featured_7d::float / ls.line_product_count) > 0.7
|
||||
THEN -10
|
||||
WHEN lh.line_products_featured_7d IS NOT NULL
|
||||
AND lh.line_products_featured_7d >= 4
|
||||
THEN -5
|
||||
ELSE 0
|
||||
END
|
||||
-- Price tier adjustment (deprioritize very low-price items)
|
||||
+ 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
|
||||
-- ABC class boost
|
||||
+ CASE WHEN pm.abc_class = 'A' THEN 10
|
||||
WHEN pm.abc_class = 'B' THEN 5
|
||||
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
|
||||
LEFT JOIN product_metrics pm ON pm.pid = p.pid
|
||||
LEFT JOIN newsletter_history nh ON nh.pid = p.pid
|
||||
LEFT JOIN line_history lh ON lh.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 != ''
|
||||
LEFT JOIN product_daily_deals dd ON dd.pid = p.pid AND dd.deal_date = CURRENT_DATE
|
||||
WHERE p.visible = true
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// GET /api/newsletter/recommendations
|
||||
router.get('/recommendations', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const category = req.query.category || 'all';
|
||||
|
||||
const categoryFilter = CATEGORY_FILTERS[category] || '';
|
||||
|
||||
const query = `
|
||||
WITH ${buildScoredCTE()}
|
||||
SELECT *
|
||||
FROM scored
|
||||
WHERE score > -50
|
||||
${categoryFilter}
|
||||
ORDER BY score DESC, COALESCE(sales_7d, 0) DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
WITH ${buildScoredCTE({ forCount: true })}
|
||||
SELECT COUNT(*) FROM scored
|
||||
WHERE score > -50
|
||||
${categoryFilter}
|
||||
`;
|
||||
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
pool.query(query, [limit, offset]),
|
||||
pool.query(countQuery)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
products: dataResult.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
pages: Math.ceil(parseInt(countResult.rows[0].count) / limit),
|
||||
currentPage: page,
|
||||
limit
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching newsletter recommendations:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch newsletter recommendations' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/newsletter/history/:pid
|
||||
router.get('/history/:pid', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
const { pid } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT campaign_id, campaign_name, sent_at, product_url
|
||||
FROM klaviyo_campaign_products
|
||||
WHERE pid = $1
|
||||
ORDER BY sent_at DESC
|
||||
`, [pid]);
|
||||
|
||||
res.json({ history: rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching newsletter history:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch newsletter history' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/newsletter/stats
|
||||
router.get('/stats', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
WITH ref AS (SELECT COALESCE(MAX(last_calculated), NOW()) as d FROM product_metrics),
|
||||
featured_pids AS (
|
||||
SELECT DISTINCT pid FROM klaviyo_campaign_products
|
||||
),
|
||||
recent_pids AS (
|
||||
SELECT DISTINCT pid FROM klaviyo_campaign_products
|
||||
WHERE sent_at > (SELECT d FROM ref) - INTERVAL '2 days'
|
||||
)
|
||||
SELECT
|
||||
-- Unfeatured new products
|
||||
(SELECT COUNT(*) FROM products p, ref
|
||||
WHERE p.visible = true AND p.preorder_count = 0
|
||||
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
|
||||
(SELECT COUNT(*) FROM products p
|
||||
JOIN product_metrics pm ON pm.pid = p.pid
|
||||
CROSS JOIN ref
|
||||
WHERE p.visible = true
|
||||
AND 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
|
||||
AND p.pid NOT IN (
|
||||
SELECT pid FROM klaviyo_campaign_products
|
||||
WHERE sent_at > pm.date_last_received
|
||||
)
|
||||
) as back_in_stock_ready,
|
||||
-- High score products available (score 40+, not featured in last 2 days)
|
||||
(SELECT COUNT(*) FROM (
|
||||
WITH ${buildScoredCTE({ forCount: true })}
|
||||
SELECT pid FROM scored
|
||||
WHERE score >= 40
|
||||
AND pid NOT IN (SELECT pid FROM recent_pids)
|
||||
) hs) as high_score_available,
|
||||
-- Last campaign date
|
||||
(SELECT MAX(sent_at) FROM klaviyo_campaign_products) as last_campaign_date,
|
||||
-- Avg days since last featured (across visible in-stock catalog)
|
||||
(SELECT ROUND(AVG(days)::numeric, 1) FROM (
|
||||
SELECT EXTRACT(DAY FROM ref.d - MAX(kcp.sent_at))::int as days
|
||||
FROM products p
|
||||
CROSS JOIN ref
|
||||
JOIN klaviyo_campaign_products kcp ON kcp.pid = p.pid
|
||||
JOIN product_metrics pm ON pm.pid = p.pid
|
||||
WHERE p.visible = true AND COALESCE(pm.current_stock, 0) > 0
|
||||
GROUP BY p.pid, ref.d
|
||||
) avg_calc) as avg_days_since_featured,
|
||||
-- Never featured (visible, in stock or preorder)
|
||||
(SELECT COUNT(*) FROM products p
|
||||
LEFT JOIN product_metrics pm ON pm.pid = p.pid
|
||||
WHERE p.visible = true
|
||||
AND (COALESCE(pm.current_stock, 0) > 0 OR p.preorder_count > 0)
|
||||
AND p.pid NOT IN (SELECT pid FROM featured_pids)
|
||||
) as never_featured
|
||||
`);
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching newsletter stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch newsletter stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||
const htsLookupRouter = require('./routes/hts-lookup');
|
||||
const importSessionsRouter = require('./routes/import-sessions');
|
||||
const newsletterRouter = require('./routes/newsletter');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = '/var/www/html/inventory/.env';
|
||||
@@ -132,6 +133,7 @@ async function startServer() {
|
||||
app.use('/api/reusable-images', reusableImagesRouter);
|
||||
app.use('/api/hts-lookup', htsLookupRouter);
|
||||
app.use('/api/import-sessions', importSessionsRouter);
|
||||
app.use('/api/newsletter', newsletterRouter);
|
||||
|
||||
// Basic health check route
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
6
inventory/package-lock.json
generated
6
inventory/package-lock.json
generated
@@ -4313,9 +4313,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001739",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz",
|
||||
"integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==",
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ const Categories = lazy(() => import('./pages/Categories'));
|
||||
const Brands = lazy(() => import('./pages/Brands'));
|
||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||
|
||||
// 2. Dashboard app - separate chunk
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -215,6 +216,15 @@ function App() {
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Newsletter recommendations */}
|
||||
<Route path="/newsletter" element={
|
||||
<Protected page="newsletter">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Newsletter />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Dashboard app - separate chunk */}
|
||||
<Route path="/dashboard" element={
|
||||
<Protected page="dashboard">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FileSearch,
|
||||
ShoppingCart,
|
||||
FilePenLine,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -120,6 +121,12 @@ const toolsItems = [
|
||||
icon: FilePenLine,
|
||||
url: "/product-editor",
|
||||
permission: "access:product_editor"
|
||||
},
|
||||
{
|
||||
title: "Newsletter",
|
||||
icon: Mail,
|
||||
url: "/newsletter",
|
||||
permission: "access:newsletter"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
597
inventory/src/components/newsletter/CampaignHistoryDialog.tsx
Normal file
597
inventory/src/components/newsletter/CampaignHistoryDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
inventory/src/components/newsletter/NewsletterStats.tsx
Normal file
110
inventory/src/components/newsletter/NewsletterStats.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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"
|
||||
|
||||
interface Stats {
|
||||
unfeatured_new: number
|
||||
back_in_stock_ready: number
|
||||
high_score_available: number
|
||||
last_campaign_date: string
|
||||
avg_days_since_featured: number
|
||||
never_featured: number
|
||||
}
|
||||
|
||||
export function NewsletterStats() {
|
||||
const { data } = useQuery<Stats>({
|
||||
queryKey: ["newsletter-stats"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/newsletter/stats`)
|
||||
if (!res.ok) throw new Error("Failed to fetch stats")
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
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 = [
|
||||
{
|
||||
label: "Unfeatured New",
|
||||
value: data.unfeatured_new?.toLocaleString() ?? "—",
|
||||
icon: Sparkles,
|
||||
tooltip: "New products (< 31 days) that haven't appeared in any newsletter yet. If this is high, prioritize new arrivals today.",
|
||||
},
|
||||
{
|
||||
label: "Back in Stock Ready",
|
||||
value: data.back_in_stock_ready?.toLocaleString() ?? "—",
|
||||
icon: RotateCcw,
|
||||
tooltip: "Restocked products that haven't been featured since their restock date. These are time-sensitive — customers are waiting and stock could sell through.",
|
||||
},
|
||||
{
|
||||
label: "High Score Available",
|
||||
value: data.high_score_available?.toLocaleString() ?? "—",
|
||||
icon: TrendingUp,
|
||||
tooltip: "Products scoring 40+ that haven't been featured in the last 2 days. Shows how deep your bench of strong picks is for today's send.",
|
||||
},
|
||||
{
|
||||
label: "Last Campaign",
|
||||
value: data.last_campaign_date ? new Date(data.last_campaign_date).toLocaleDateString() : "—",
|
||||
icon: Clock,
|
||||
tooltip: "Date of the most recent synced campaign. Useful to confirm your Klaviyo sync is up to date.",
|
||||
},
|
||||
{
|
||||
label: "Avg Days Since Featured",
|
||||
value: data.avg_days_since_featured ?? "—",
|
||||
icon: CalendarClock,
|
||||
tooltip: "Average days since in-stock products were last featured. If this is climbing, you're not cycling through enough of your catalog.",
|
||||
},
|
||||
{
|
||||
label: "Never Featured",
|
||||
value: data.never_featured?.toLocaleString() ?? "—",
|
||||
icon: EyeOff,
|
||||
tooltip: "Visible, in-stock products that have never appeared in any newsletter. Your untapped opportunity pool.",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs font-medium">
|
||||
<s.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{s.label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{s.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
471
inventory/src/components/newsletter/RecommendationTable.tsx
Normal file
471
inventory/src/components/newsletter/RecommendationTable.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState, useMemo, useContext } from "react"
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
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 {
|
||||
pid: number
|
||||
title: string
|
||||
sku: string
|
||||
brand: string
|
||||
vendor: string
|
||||
price: number
|
||||
regular_price: number
|
||||
image: string
|
||||
permalink: string
|
||||
stock_quantity: number
|
||||
preorder_count: number
|
||||
sales_7d: number
|
||||
sales_30d: number
|
||||
revenue_30d: number
|
||||
current_stock: number
|
||||
on_order_qty: number
|
||||
abc_class: string
|
||||
line: string | null
|
||||
times_featured: number | null
|
||||
last_featured_at: string | null
|
||||
days_since_featured: number | null
|
||||
line_products_featured: number | null
|
||||
line_total_features: number | null
|
||||
line_last_featured_at: string | null
|
||||
line_products_featured_30d: number | null
|
||||
line_product_count: number | null
|
||||
line_days_since_featured: number | null
|
||||
effective_last_featured: string | null
|
||||
effective_days_since_featured: number | null
|
||||
age_days: number
|
||||
score: number
|
||||
is_new: boolean
|
||||
is_preorder: boolean
|
||||
is_clearance: boolean
|
||||
discount_pct: number
|
||||
is_low_stock: boolean
|
||||
is_back_in_stock: boolean
|
||||
is_daily_deal: boolean
|
||||
deal_price: number | null
|
||||
baskets: number
|
||||
notifies: number
|
||||
}
|
||||
|
||||
interface RecommendationResponse {
|
||||
products: Product[]
|
||||
pagination: {
|
||||
total: number
|
||||
pages: number
|
||||
currentPage: number
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RecommendationTableProps {
|
||||
category: string
|
||||
}
|
||||
|
||||
function FeaturedCell({ p }: { p: Product }) {
|
||||
const directCount = p.times_featured ?? 0
|
||||
const hasLineHistory = p.line && p.line_last_featured_at && !p.last_featured_at
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<span className="text-sm">{directCount}×</span>
|
||||
{hasLineHistory && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Layers className="h-3.5 w-3.5 text-blue-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-[220px]">
|
||||
<p className="text-xs font-medium">Line: {p.line}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.line_products_featured} of {p.line_product_count} products featured
|
||||
({p.line_products_featured_30d} in last 30d)
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Line last featured {p.line_days_since_featured}d ago
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastFeaturedCell({ p }: { p: Product }) {
|
||||
if (p.last_featured_at) {
|
||||
return <span>{p.days_since_featured === 0 ? "Today" : `${p.days_since_featured}d ago`}</span>
|
||||
}
|
||||
if (p.line_last_featured_at) {
|
||||
const lineLabel = p.line_days_since_featured === 0 ? "Today" : `${p.line_days_since_featured}d ago`
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center justify-center gap-1 text-blue-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span>{lineLabel}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p className="text-xs">Product never featured directly.</p>
|
||||
<p className="text-xs">Line "{p.line}" was last featured {lineLabel.toLowerCase()}.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
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 [sort, setSort] = useState<SortState>({ column: null, direction: null })
|
||||
const limit = 100
|
||||
|
||||
const { data, isLoading } = useQuery<RecommendationResponse>({
|
||||
queryKey: ["newsletter-recommendations", category, page],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/newsletter/recommendations?category=${category}&page=${page}&limit=${limit}`
|
||||
)
|
||||
if (!res.ok) throw new Error("Failed to fetch recommendations")
|
||||
return res.json()
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{products.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
|
||||
No products found for this category
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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" :
|
||||
"text-muted-foreground"
|
||||
}`}>
|
||||
{p.score}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.image ? (
|
||||
<img src={p.image} alt="" className="w-10 h-10 object-cover rounded" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-muted rounded" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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-center">
|
||||
<div>
|
||||
{p.is_daily_deal && p.deal_price ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">${Number(p.deal_price).toFixed(2)}</span>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
${Number(p.price).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-red-500 ml-1">
|
||||
-{Math.round((1 - Number(p.deal_price) / Number(p.price)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-medium">${Number(p.price).toFixed(2)}</span>
|
||||
{p.is_clearance && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
${Number(p.regular_price).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-red-500 ml-1">-{p.discount_pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<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>
|
||||
{p.on_order_qty > 0 && (
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span>
|
||||
)}
|
||||
</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 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 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} 🔔` : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
<FeaturedCell p={p} />
|
||||
</TableCell>
|
||||
<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 && (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pagination.total.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 {pagination.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
disabled={page >= pagination.pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -382,10 +382,12 @@ export function ProductEditForm({
|
||||
originalImagesRef.current = [...productImages];
|
||||
reset(data);
|
||||
} else {
|
||||
toast.error(result.message ?? "Failed to update product");
|
||||
if (result.error) {
|
||||
console.error("Edit error details:", result.error);
|
||||
}
|
||||
const errorDetail = Array.isArray(result.error)
|
||||
? result.error.filter((e) => e !== "Errors").join("; ")
|
||||
: typeof result.error === "string"
|
||||
? result.error
|
||||
: null;
|
||||
toast.error(errorDetail || result.message || "Failed to update product");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
|
||||
@@ -45,7 +45,7 @@ interface Props {
|
||||
data: Product[];
|
||||
file: File;
|
||||
onBack?: () => void;
|
||||
onSubmit: (data: Product[], file: File, options: SubmitOptions) => void | Promise<any>;
|
||||
onSubmit: (data: Product[], file: File, options: SubmitOptions) => void | Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export const ImageUploadStep = ({
|
||||
@@ -228,14 +228,16 @@ export const ImageUploadStep = ({
|
||||
showNewProduct,
|
||||
};
|
||||
|
||||
await onSubmit(updatedData, file, submitOptions);
|
||||
const success = await onSubmit(updatedData, file, submitOptions);
|
||||
|
||||
// Delete the import session on successful submit
|
||||
try {
|
||||
await deleteImportSession();
|
||||
} catch (err) {
|
||||
// Non-critical - log but don't fail the submission
|
||||
console.warn('Failed to delete import session:', err);
|
||||
// Only delete the import session after a successful submit response
|
||||
if (success) {
|
||||
try {
|
||||
await deleteImportSession();
|
||||
} catch (err) {
|
||||
// Non-critical - log but don't fail the submission
|
||||
console.warn('Failed to delete import session:', err);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
|
||||
@@ -337,7 +337,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
invalidData: [] as Data<string>[],
|
||||
all: data as Data<string>[]
|
||||
};
|
||||
onSubmit(result, file, options);
|
||||
return onSubmit(result, file, options);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -113,6 +113,14 @@ const InputCellComponent = ({
|
||||
setIsFocused(true);
|
||||
}, [cellPopoverClosedAt]);
|
||||
|
||||
// Handle Enter key to blur the field (useful for barcode scanners that send Enter after scan)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update store only on blur - this is when validation runs too
|
||||
// IMPORTANT: We store FULL precision for price fields to allow accurate calculations
|
||||
// The display formatting happens separately via displayValue
|
||||
@@ -151,6 +159,7 @@ const InputCellComponent = ({
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isValidating}
|
||||
className={cn(
|
||||
'h-8 text-sm',
|
||||
|
||||
@@ -331,12 +331,16 @@ const MultilineInputComponent = ({
|
||||
onBlur(editedSuggestion);
|
||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||
setAiSuggestionExpanded(false);
|
||||
intentionalCloseRef.current = true;
|
||||
setPopoverOpen(false);
|
||||
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
||||
|
||||
// Handle dismissing the AI suggestion
|
||||
const handleDismissSuggestion = useCallback(() => {
|
||||
onDismissAiSuggestion?.();
|
||||
setAiSuggestionExpanded(false);
|
||||
intentionalCloseRef.current = true;
|
||||
setPopoverOpen(false);
|
||||
}, [onDismissAiSuggestion]);
|
||||
|
||||
// Calculate display value
|
||||
@@ -446,7 +450,7 @@ const MultilineInputComponent = ({
|
||||
</Button>
|
||||
|
||||
{/* Main textarea */}
|
||||
<div data-col="left" className="flex flex-col min-h-0 w-full lg:w-1/2">
|
||||
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}>
|
||||
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
|
||||
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
|
||||
{hasAiSuggestion && productName && (
|
||||
|
||||
@@ -520,7 +520,7 @@ export function Import() {
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
|
||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise<boolean> => {
|
||||
try {
|
||||
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
|
||||
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
||||
@@ -579,7 +579,7 @@ export function Import() {
|
||||
setStartFromScratch(false);
|
||||
|
||||
toast.success(`[DEBUG] Skipped API submission for ${formattedRows.length} product(s)`);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await submitNewProducts({
|
||||
@@ -638,11 +638,14 @@ export function Import() {
|
||||
} else {
|
||||
toast.error(resolvedFailureMessage ?? defaultFailureMessage);
|
||||
}
|
||||
|
||||
return isSuccess;
|
||||
} catch (error) {
|
||||
console.error("Import error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -924,7 +927,7 @@ export function Import() {
|
||||
<Alert className="border-success bg-success/10">
|
||||
<CheckCircle className="h-4 w-4" style={{ color: 'hsl(var(--success))' }} />
|
||||
<AlertTitle className="text-success-foreground">Success</AlertTitle>
|
||||
<AlertDescription className="text-success-foreground">All products created successfully.</AlertDescription>
|
||||
<AlertDescription className="text-success-foreground">All products created successfully. Please note that images may take a few minutes to finish uploading before they'll be visible in backend.</AlertDescription>
|
||||
</Alert>
|
||||
) : createdProducts.length > 0 && erroredProducts.length > 0 ? (
|
||||
<Alert className="border-warning bg-warning/10">
|
||||
|
||||
51
inventory/src/pages/Newsletter.tsx
Normal file
51
inventory/src/pages/Newsletter.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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" },
|
||||
{ value: "new", label: "New Products" },
|
||||
{ value: "preorder", label: "Pre-Orders" },
|
||||
{ value: "bestsellers", label: "Bestsellers" },
|
||||
{ value: "back_in_stock", label: "Back in Stock" },
|
||||
{ value: "clearance", label: "Clearance" },
|
||||
{ value: "daily_deals", label: "Daily Deals" },
|
||||
{ value: "never_featured", label: "Never Featured" },
|
||||
{ value: "no_interest", label: "No Interest" },
|
||||
]
|
||||
|
||||
export function Newsletter() {
|
||||
const [category, setCategory] = useState("all")
|
||||
|
||||
return (
|
||||
<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 />
|
||||
|
||||
<Tabs value={category} onValueChange={setCategory}>
|
||||
<TabsList>
|
||||
{CATEGORIES.map((c) => (
|
||||
<TabsTrigger key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{CATEGORIES.map((c) => (
|
||||
<TabsContent key={c.value} value={c.value}>
|
||||
<RecommendationTable category={c.value} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Newsletter
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user