Add newsletter recommendations
This commit is contained in:
@@ -6,8 +6,9 @@
|
|||||||
* - Parses out product links (/shop/{id}) and other shop links
|
* - Parses out product links (/shop/{id}) and other shop links
|
||||||
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
|
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
|
||||||
*
|
*
|
||||||
* Usage: node scripts/poc-campaign-products.js [limit]
|
* Usage: node scripts/poc-campaign-products.js [limit] [offset]
|
||||||
* limit: number of recent campaigns to process (default: 10)
|
* 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.
|
* Requires DB_* env vars (from inventory-server .env) and KLAVIYO_API_KEY.
|
||||||
*/
|
*/
|
||||||
@@ -52,33 +53,59 @@ async function klaviyoGet(endpoint, params = {}) {
|
|||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
url.searchParams.append(k, v);
|
url.searchParams.append(k, v);
|
||||||
}
|
}
|
||||||
const res = await fetch(url.toString(), { headers });
|
return klaviyoFetch(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function klaviyoFetch(url) {
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
throw new Error(`Klaviyo ${res.status} on ${endpoint}: ${body}`);
|
throw new Error(`Klaviyo ${res.status} on ${url}: ${body}`);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentCampaigns(limit) {
|
async function getRecentCampaigns(limit, offset = 0) {
|
||||||
const data = await klaviyoGet('/campaigns', {
|
const campaigns = [];
|
||||||
|
const messageMap = {};
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
let data = await klaviyoGet('/campaigns', {
|
||||||
'filter': 'equals(messages.channel,"email")',
|
'filter': 'equals(messages.channel,"email")',
|
||||||
'sort': '-scheduled_at',
|
'sort': '-scheduled_at',
|
||||||
'include': 'campaign-messages',
|
'include': 'campaign-messages',
|
||||||
});
|
});
|
||||||
|
|
||||||
const campaigns = (data.data || [])
|
while (true) {
|
||||||
.filter(c => c.attributes?.status === 'Sent')
|
for (const c of (data.data || [])) {
|
||||||
.slice(0, limit);
|
if (c.attributes?.status === 'Sent') {
|
||||||
|
if (skipped < offset) {
|
||||||
const messageMap = {};
|
skipped++;
|
||||||
for (const inc of (data.included || [])) {
|
continue;
|
||||||
if (inc.type === 'campaign-message') {
|
}
|
||||||
messageMap[inc.id] = inc;
|
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, messageMap };
|
return { campaigns: campaigns.slice(0, limit), messageMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTemplateHtml(messageId) {
|
async function getTemplateHtml(messageId) {
|
||||||
@@ -190,12 +217,13 @@ async function insertLinks(pool, campaignId, campaignName, sentAt, links) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const limit = parseInt(process.argv[2]) || 10;
|
const limit = parseInt(process.argv[2]) || 10;
|
||||||
|
const offset = parseInt(process.argv[3]) || 0;
|
||||||
const pool = createPool();
|
const pool = createPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch campaigns
|
// Fetch campaigns
|
||||||
console.log(`Fetching up to ${limit} recent campaigns...\n`);
|
console.log(`Fetching up to ${limit} recent campaigns (offset: ${offset})...\n`);
|
||||||
const { campaigns, messageMap } = await getRecentCampaigns(limit);
|
const { campaigns, messageMap } = await getRecentCampaigns(limit, offset);
|
||||||
console.log(`Found ${campaigns.length} sent campaigns.\n`);
|
console.log(`Found ${campaigns.length} sent campaigns.\n`);
|
||||||
|
|
||||||
let totalProducts = 0;
|
let totalProducts = 0;
|
||||||
|
|||||||
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);
|
||||||
@@ -6,6 +6,7 @@ const importCategories = require('./import/categories');
|
|||||||
const { importProducts } = require('./import/products');
|
const { importProducts } = require('./import/products');
|
||||||
const importOrders = require('./import/orders');
|
const importOrders = require('./import/orders');
|
||||||
const importPurchaseOrders = require('./import/purchase-orders');
|
const importPurchaseOrders = require('./import/purchase-orders');
|
||||||
|
const importDailyDeals = require('./import/daily-deals');
|
||||||
|
|
||||||
dotenv.config({ path: path.join(__dirname, "../.env") });
|
dotenv.config({ path: path.join(__dirname, "../.env") });
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const IMPORT_CATEGORIES = true;
|
|||||||
const IMPORT_PRODUCTS = true;
|
const IMPORT_PRODUCTS = true;
|
||||||
const IMPORT_ORDERS = true;
|
const IMPORT_ORDERS = true;
|
||||||
const IMPORT_PURCHASE_ORDERS = true;
|
const IMPORT_PURCHASE_ORDERS = true;
|
||||||
|
const IMPORT_DAILY_DEALS = true;
|
||||||
|
|
||||||
// Add flag for incremental updates
|
// Add flag for incremental updates
|
||||||
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
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_CATEGORIES,
|
||||||
IMPORT_PRODUCTS,
|
IMPORT_PRODUCTS,
|
||||||
IMPORT_ORDERS,
|
IMPORT_ORDERS,
|
||||||
IMPORT_PURCHASE_ORDERS
|
IMPORT_PURCHASE_ORDERS,
|
||||||
|
IMPORT_DAILY_DEALS
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -126,10 +129,11 @@ async function main() {
|
|||||||
'categories_enabled', $2::boolean,
|
'categories_enabled', $2::boolean,
|
||||||
'products_enabled', $3::boolean,
|
'products_enabled', $3::boolean,
|
||||||
'orders_enabled', $4::boolean,
|
'orders_enabled', $4::boolean,
|
||||||
'purchase_orders_enabled', $5::boolean
|
'purchase_orders_enabled', $5::boolean,
|
||||||
|
'daily_deals_enabled', $6::boolean
|
||||||
)
|
)
|
||||||
) RETURNING id
|
) 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;
|
importHistoryId = historyResult.rows[0].id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating import history record:", error);
|
console.error("Error creating import history record:", error);
|
||||||
@@ -146,7 +150,8 @@ async function main() {
|
|||||||
categories: null,
|
categories: null,
|
||||||
products: null,
|
products: null,
|
||||||
orders: null,
|
orders: null,
|
||||||
purchaseOrders: null
|
purchaseOrders: null,
|
||||||
|
dailyDeals: null
|
||||||
};
|
};
|
||||||
|
|
||||||
let totalRecordsAdded = 0;
|
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 endTime = Date.now();
|
||||||
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
||||||
|
|
||||||
@@ -241,15 +274,17 @@ async function main() {
|
|||||||
'products_enabled', $5::boolean,
|
'products_enabled', $5::boolean,
|
||||||
'orders_enabled', $6::boolean,
|
'orders_enabled', $6::boolean,
|
||||||
'purchase_orders_enabled', $7::boolean,
|
'purchase_orders_enabled', $7::boolean,
|
||||||
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
|
'daily_deals_enabled', $8::boolean,
|
||||||
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
|
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
|
||||||
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
|
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
|
||||||
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
||||||
'total_deleted', $12::integer,
|
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
|
||||||
'total_skipped', $13::integer,
|
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
|
||||||
'step_timings', $14::jsonb
|
'total_deleted', $14::integer,
|
||||||
|
'total_skipped', $15::integer,
|
||||||
|
'step_timings', $16::jsonb
|
||||||
)
|
)
|
||||||
WHERE id = $15
|
WHERE id = $17
|
||||||
`, [
|
`, [
|
||||||
totalElapsedSeconds,
|
totalElapsedSeconds,
|
||||||
parseInt(totalRecordsAdded),
|
parseInt(totalRecordsAdded),
|
||||||
@@ -258,10 +293,12 @@ async function main() {
|
|||||||
IMPORT_PRODUCTS,
|
IMPORT_PRODUCTS,
|
||||||
IMPORT_ORDERS,
|
IMPORT_ORDERS,
|
||||||
IMPORT_PURCHASE_ORDERS,
|
IMPORT_PURCHASE_ORDERS,
|
||||||
|
IMPORT_DAILY_DEALS,
|
||||||
JSON.stringify(results.categories),
|
JSON.stringify(results.categories),
|
||||||
JSON.stringify(results.products),
|
JSON.stringify(results.products),
|
||||||
JSON.stringify(results.orders),
|
JSON.stringify(results.orders),
|
||||||
JSON.stringify(results.purchaseOrders),
|
JSON.stringify(results.purchaseOrders),
|
||||||
|
JSON.stringify(results.dailyDeals),
|
||||||
totalRecordsDeleted,
|
totalRecordsDeleted,
|
||||||
totalRecordsSkipped,
|
totalRecordsSkipped,
|
||||||
JSON.stringify(stepTimings),
|
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;
|
||||||
425
inventory-server/src/routes/newsletter.js
Normal file
425
inventory-server/src/routes/newsletter.js
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
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: created_at within 31 days, NOT preorder (mutually exclusive on prod)
|
||||||
|
// PRE-ORDER: preorder_count > 0, NOT new
|
||||||
|
// CLEARANCE: (regular_price - price) / regular_price >= 0.35, price > 0
|
||||||
|
// DAILY DEALS: product_daily_deals table
|
||||||
|
// BACK IN STOCK: date_last_received > date_first_received, received within 14d,
|
||||||
|
// first received > 30d ago, excludes new products (prod excludes datein < 30d)
|
||||||
|
// BESTSELLERS: ranked by recent order volume (prod's "hot" logic)
|
||||||
|
//
|
||||||
|
// 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 COALESCE(sales_30d, 0) > 0",
|
||||||
|
never_featured: "AND times_featured IS NULL AND line_last_featured_at IS NULL",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildScoredCTE({ forCount = false } = {}) {
|
||||||
|
// forCount=true returns minimal columns for COUNT(*)
|
||||||
|
const selectColumns = forCount ? `
|
||||||
|
p.pid,
|
||||||
|
p.created_at,
|
||||||
|
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.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.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 - 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: within 31 days of reference date, AND not on preorder
|
||||||
|
CASE
|
||||||
|
WHEN p.preorder_count > 0 THEN false
|
||||||
|
WHEN 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, 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 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, not preorder)
|
||||||
|
CASE
|
||||||
|
WHEN p.preorder_count > 0 THEN 0
|
||||||
|
WHEN p.created_at > ref.d - INTERVAL '14 days' THEN 50
|
||||||
|
WHEN p.created_at > ref.d - INTERVAL '31 days' THEN 35
|
||||||
|
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 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
|
||||||
|
-- In-stock requirement
|
||||||
|
+ CASE WHEN COALESCE(pm.current_stock, 0) <= 0 AND COALESCE(p.preorder_count, 0) = 0 THEN -100 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 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 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
|||||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||||
const htsLookupRouter = require('./routes/hts-lookup');
|
const htsLookupRouter = require('./routes/hts-lookup');
|
||||||
const importSessionsRouter = require('./routes/import-sessions');
|
const importSessionsRouter = require('./routes/import-sessions');
|
||||||
|
const newsletterRouter = require('./routes/newsletter');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -132,6 +133,7 @@ async function startServer() {
|
|||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
app.use('/api/reusable-images', reusableImagesRouter);
|
||||||
app.use('/api/hts-lookup', htsLookupRouter);
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
app.use('/api/import-sessions', importSessionsRouter);
|
app.use('/api/import-sessions', importSessionsRouter);
|
||||||
|
app.use('/api/newsletter', newsletterRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const Categories = lazy(() => import('./pages/Categories'));
|
|||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||||
|
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||||
|
|
||||||
// 2. Dashboard app - separate chunk
|
// 2. Dashboard app - separate chunk
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -215,6 +216,15 @@ function App() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Newsletter recommendations */}
|
||||||
|
<Route path="/newsletter" element={
|
||||||
|
<Protected page="newsletter">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<Newsletter />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Dashboard app - separate chunk */}
|
{/* Dashboard app - separate chunk */}
|
||||||
<Route path="/dashboard" element={
|
<Route path="/dashboard" element={
|
||||||
<Protected page="dashboard">
|
<Protected page="dashboard">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +121,12 @@ const toolsItems = [
|
|||||||
icon: FilePenLine,
|
icon: FilePenLine,
|
||||||
url: "/product-editor",
|
url: "/product-editor",
|
||||||
permission: "access:product_editor"
|
permission: "access:product_editor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Newsletter",
|
||||||
|
icon: Mail,
|
||||||
|
url: "/newsletter",
|
||||||
|
permission: "access:newsletter"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
92
inventory/src/components/newsletter/NewsletterStats.tsx
Normal file
92
inventory/src/components/newsletter/NewsletterStats.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
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 null
|
||||||
|
|
||||||
|
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 md:grid-cols-3 lg: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>{s.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold mt-1">{s.value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
inventory/src/components/newsletter/RecommendationTable.tsx
Normal file
307
inventory/src/components/newsletter/RecommendationTable.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { useState } 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 } from "lucide-react"
|
||||||
|
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-end 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 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const limit = 50
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-center py-12 text-muted-foreground">Loading recommendations…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = data?.products ?? []
|
||||||
|
const pagination = data?.pagination
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">Score</TableHead>
|
||||||
|
<TableHead className="w-[60px]">Image</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>Brand</TableHead>
|
||||||
|
<TableHead className="text-right">Price</TableHead>
|
||||||
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
|
<TableHead className="text-right">7d Sales</TableHead>
|
||||||
|
<TableHead className="text-right">30d Sales</TableHead>
|
||||||
|
<TableHead>Tags</TableHead>
|
||||||
|
<TableHead className="text-right">Featured</TableHead>
|
||||||
|
<TableHead>Last Featured</TableHead>
|
||||||
|
<TableHead className="w-[40px]"></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>
|
||||||
|
<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-[250px]">
|
||||||
|
<p className="font-medium text-sm truncate">{p.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{p.sku}</p>
|
||||||
|
{p.line && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 truncate">{p.line}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{p.brand}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<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-right">
|
||||||
|
<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-right text-sm">{p.sales_7d ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{p.sales_30d ?? 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{p.is_new && <Badge variant="default" className="text-[10px] px-1.5 py-0">New</Badge>}
|
||||||
|
{p.is_preorder && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">Pre-Order</Badge>}
|
||||||
|
{p.is_clearance && <Badge variant="destructive" className="text-[10px] px-1.5 py-0">Clearance</Badge>}
|
||||||
|
{p.is_daily_deal && <Badge variant="destructive" className="text-[10px] px-1.5 py-0 bg-orange-500">Deal</Badge>}
|
||||||
|
{p.is_back_in_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0">Back in Stock</Badge>}
|
||||||
|
{p.is_low_stock && <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-yellow-500">Low Stock</Badge>}
|
||||||
|
{(p.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-right text-sm">
|
||||||
|
<FeaturedCell p={p} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<LastFeaturedCell p={p} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.permalink && (
|
||||||
|
<a href={p.permalink} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
inventory/src/pages/Newsletter.tsx
Normal file
48
inventory/src/pages/Newsletter.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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
|
||||||
Reference in New Issue
Block a user