feat: Responsive admin UI, SEO pages, and click analytics
## Responsive Admin UI - Layout.tsx: Mobile sidebar drawer with hamburger menu - Dashboard.tsx: 2-col grid on mobile, responsive stats cards - OrchestratorDashboard.tsx: Responsive table with hidden columns - PagesTab.tsx: Responsive filters and table ## SEO Pages - New /admin/seo section with state landing pages - SEO page generation and management - State page content with dispensary/product counts ## Click Analytics - Product click tracking infrastructure - Click analytics dashboard ## Other Changes - Consumer features scaffolding (alerts, deals, favorites) - Health panel component - Workers dashboard improvements - Legacy DutchieAZ pages removed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
521
backend/src/routes/click-analytics.ts
Normal file
521
backend/src/routes/click-analytics.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Click Analytics API Routes
|
||||
*
|
||||
* Aggregates product click events by brand and campaign for analytics dashboards.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/analytics/clicks/brands - Top brands by click engagement
|
||||
* GET /api/analytics/clicks/campaigns - Top campaigns/specials by engagement
|
||||
* GET /api/analytics/clicks/stores/:storeId/brands - Per-store brand engagement
|
||||
* GET /api/analytics/clicks/summary - Overall click summary stats
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db/pool';
|
||||
import { authMiddleware } from '../auth/middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All click analytics endpoints require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/analytics/clicks/brands
|
||||
* Get top brands by click engagement
|
||||
*
|
||||
* Query params:
|
||||
* - state: Filter by store state (e.g., 'AZ')
|
||||
* - store_id: Filter by specific store
|
||||
* - brand_id: Filter by specific brand
|
||||
* - days: Lookback window (default 30)
|
||||
* - limit: Max results (default 25)
|
||||
*/
|
||||
router.get('/brands', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
state,
|
||||
store_id,
|
||||
brand_id,
|
||||
days = '30',
|
||||
limit = '25'
|
||||
} = req.query;
|
||||
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
|
||||
|
||||
// Build conditions and params
|
||||
const conditions: string[] = [
|
||||
'e.brand_id IS NOT NULL',
|
||||
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
|
||||
];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (state) {
|
||||
conditions.push(`d.state = $${paramIdx++}`);
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
if (store_id) {
|
||||
conditions.push(`e.store_id = $${paramIdx++}`);
|
||||
params.push(store_id);
|
||||
}
|
||||
|
||||
if (brand_id) {
|
||||
conditions.push(`e.brand_id = $${paramIdx++}`);
|
||||
params.push(brand_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Query for brand engagement
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
e.brand_id,
|
||||
e.brand_id as brand_name,
|
||||
COUNT(*) as clicks,
|
||||
COUNT(DISTINCT e.product_id) as unique_products,
|
||||
COUNT(DISTINCT e.store_id) as unique_stores,
|
||||
MIN(e.occurred_at) as first_click_at,
|
||||
MAX(e.occurred_at) as last_click_at
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
${whereClause}
|
||||
GROUP BY e.brand_id
|
||||
ORDER BY clicks DESC
|
||||
LIMIT ${limitNum}
|
||||
`, params);
|
||||
|
||||
// Try to get actual brand names from products
|
||||
const brandIds = result.rows.map(r => r.brand_id).filter(Boolean);
|
||||
let brandNamesMap: Record<string, string> = {};
|
||||
|
||||
if (brandIds.length > 0) {
|
||||
const brandNamesResult = await pool.query(`
|
||||
SELECT DISTINCT brand_name
|
||||
FROM dutchie_products
|
||||
WHERE brand_name = ANY($1)
|
||||
`, [brandIds]);
|
||||
|
||||
brandNamesResult.rows.forEach(r => {
|
||||
brandNamesMap[r.brand_name] = r.brand_name;
|
||||
});
|
||||
}
|
||||
|
||||
const brands = result.rows.map(row => ({
|
||||
brand_id: row.brand_id,
|
||||
brand_name: brandNamesMap[row.brand_id] || row.brand_id,
|
||||
clicks: parseInt(row.clicks, 10),
|
||||
unique_products: parseInt(row.unique_products, 10),
|
||||
unique_stores: parseInt(row.unique_stores, 10),
|
||||
first_click_at: row.first_click_at,
|
||||
last_click_at: row.last_click_at
|
||||
}));
|
||||
|
||||
res.json({
|
||||
filters: {
|
||||
state: state || null,
|
||||
store_id: store_id || null,
|
||||
brand_id: brand_id || null,
|
||||
days: daysNum
|
||||
},
|
||||
brands
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[ClickAnalytics] Error fetching brand analytics:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch brand analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/analytics/clicks/products
|
||||
* Get top products by click engagement
|
||||
*
|
||||
* Query params:
|
||||
* - state: Filter by store state (e.g., 'AZ')
|
||||
* - store_id: Filter by specific store
|
||||
* - brand_id: Filter by specific brand
|
||||
* - days: Lookback window (default 30)
|
||||
* - limit: Max results (default 25)
|
||||
*/
|
||||
router.get('/products', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
state,
|
||||
store_id,
|
||||
brand_id,
|
||||
days = '30',
|
||||
limit = '25'
|
||||
} = req.query;
|
||||
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
|
||||
|
||||
// Build conditions and params
|
||||
const conditions: string[] = [
|
||||
'e.product_id IS NOT NULL',
|
||||
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
|
||||
];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (state) {
|
||||
conditions.push(`d.state = $${paramIdx++}`);
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
if (store_id) {
|
||||
conditions.push(`e.store_id = $${paramIdx++}`);
|
||||
params.push(store_id);
|
||||
}
|
||||
|
||||
if (brand_id) {
|
||||
conditions.push(`e.brand_id = $${paramIdx++}`);
|
||||
params.push(brand_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Query for product engagement with product details from dutchie_products
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
e.product_id,
|
||||
e.brand_id,
|
||||
COUNT(*) as clicks,
|
||||
COUNT(DISTINCT e.store_id) as unique_stores,
|
||||
MIN(e.occurred_at) as first_click_at,
|
||||
MAX(e.occurred_at) as last_click_at
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
${whereClause}
|
||||
GROUP BY e.product_id, e.brand_id
|
||||
ORDER BY clicks DESC
|
||||
LIMIT ${limitNum}
|
||||
`, params);
|
||||
|
||||
// Try to get product details from dutchie_products
|
||||
const productIds = result.rows.map(r => r.product_id).filter(Boolean);
|
||||
let productDetailsMap: Record<string, { name: string; brand: string; type: string; subcategory: string }> = {};
|
||||
|
||||
if (productIds.length > 0) {
|
||||
// Try to match by external_id or id
|
||||
const productDetailsResult = await pool.query(`
|
||||
SELECT
|
||||
external_id,
|
||||
id::text as product_id,
|
||||
name,
|
||||
brand_name,
|
||||
type,
|
||||
subcategory
|
||||
FROM dutchie_products
|
||||
WHERE external_id = ANY($1) OR id::text = ANY($1)
|
||||
`, [productIds]);
|
||||
|
||||
productDetailsResult.rows.forEach(r => {
|
||||
productDetailsMap[r.external_id] = {
|
||||
name: r.name,
|
||||
brand: r.brand_name,
|
||||
type: r.type,
|
||||
subcategory: r.subcategory
|
||||
};
|
||||
productDetailsMap[r.product_id] = {
|
||||
name: r.name,
|
||||
brand: r.brand_name,
|
||||
type: r.type,
|
||||
subcategory: r.subcategory
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const products = result.rows.map(row => {
|
||||
const details = productDetailsMap[row.product_id];
|
||||
return {
|
||||
product_id: row.product_id,
|
||||
product_name: details?.name || `Product ${row.product_id}`,
|
||||
brand_id: row.brand_id,
|
||||
brand_name: details?.brand || row.brand_id || 'Unknown',
|
||||
category: details?.type || null,
|
||||
subcategory: details?.subcategory || null,
|
||||
clicks: parseInt(row.clicks, 10),
|
||||
unique_stores: parseInt(row.unique_stores, 10),
|
||||
first_click_at: row.first_click_at,
|
||||
last_click_at: row.last_click_at
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
filters: {
|
||||
state: state || null,
|
||||
store_id: store_id || null,
|
||||
brand_id: brand_id || null,
|
||||
days: daysNum
|
||||
},
|
||||
products
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[ClickAnalytics] Error fetching product analytics:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch product analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/analytics/clicks/campaigns
|
||||
* Get top campaigns/specials by click engagement
|
||||
*
|
||||
* Query params:
|
||||
* - state: Filter by store state
|
||||
* - store_id: Filter by specific store
|
||||
* - days: Lookback window (default 30)
|
||||
* - limit: Max results (default 25)
|
||||
*/
|
||||
router.get('/campaigns', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
state,
|
||||
store_id,
|
||||
days = '30',
|
||||
limit = '25'
|
||||
} = req.query;
|
||||
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
|
||||
|
||||
// Build conditions
|
||||
const conditions: string[] = [
|
||||
'e.campaign_id IS NOT NULL',
|
||||
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
|
||||
];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (state) {
|
||||
conditions.push(`d.state = $${paramIdx++}`);
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
if (store_id) {
|
||||
conditions.push(`e.store_id = $${paramIdx++}`);
|
||||
params.push(store_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
// Query for campaign engagement with campaign details
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
e.campaign_id,
|
||||
c.name as campaign_name,
|
||||
c.slug as campaign_slug,
|
||||
c.description as campaign_description,
|
||||
c.active as is_active,
|
||||
c.start_date,
|
||||
c.end_date,
|
||||
COUNT(*) as clicks,
|
||||
COUNT(DISTINCT e.product_id) as unique_products,
|
||||
COUNT(DISTINCT e.store_id) as unique_stores,
|
||||
MIN(e.occurred_at) as first_event_at,
|
||||
MAX(e.occurred_at) as last_event_at
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
LEFT JOIN campaigns c ON e.campaign_id = c.id
|
||||
${whereClause}
|
||||
GROUP BY e.campaign_id, c.name, c.slug, c.description, c.active, c.start_date, c.end_date
|
||||
ORDER BY clicks DESC
|
||||
LIMIT ${limitNum}
|
||||
`, params);
|
||||
|
||||
const campaigns = result.rows.map(row => ({
|
||||
campaign_id: row.campaign_id,
|
||||
campaign_name: row.campaign_name || `Campaign ${row.campaign_id}`,
|
||||
campaign_slug: row.campaign_slug,
|
||||
campaign_description: row.campaign_description,
|
||||
is_active: row.is_active,
|
||||
start_date: row.start_date,
|
||||
end_date: row.end_date,
|
||||
clicks: parseInt(row.clicks, 10),
|
||||
unique_products: parseInt(row.unique_products, 10),
|
||||
unique_stores: parseInt(row.unique_stores, 10),
|
||||
first_event_at: row.first_event_at,
|
||||
last_event_at: row.last_event_at
|
||||
}));
|
||||
|
||||
res.json({
|
||||
filters: {
|
||||
state: state || null,
|
||||
store_id: store_id || null,
|
||||
days: daysNum
|
||||
},
|
||||
campaigns
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[ClickAnalytics] Error fetching campaign analytics:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch campaign analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/analytics/clicks/stores/:storeId/brands
|
||||
* Get brand engagement for a specific store
|
||||
*
|
||||
* Query params:
|
||||
* - days: Lookback window (default 30)
|
||||
* - limit: Max results (default 25)
|
||||
*/
|
||||
router.get('/stores/:storeId/brands', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { storeId } = req.params;
|
||||
const { days = '30', limit = '25' } = req.query;
|
||||
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
|
||||
|
||||
// Get store info
|
||||
const storeResult = await pool.query(
|
||||
'SELECT id, name, dba_name, city, state FROM dispensaries WHERE id = $1',
|
||||
[storeId]
|
||||
);
|
||||
|
||||
if (storeResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Store not found' });
|
||||
}
|
||||
|
||||
const store = storeResult.rows[0];
|
||||
|
||||
// Query brand engagement for this store
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
e.brand_id,
|
||||
COUNT(*) as clicks,
|
||||
COUNT(DISTINCT e.product_id) as unique_products,
|
||||
MIN(e.occurred_at) as first_click_at,
|
||||
MAX(e.occurred_at) as last_click_at
|
||||
FROM product_click_events e
|
||||
WHERE e.store_id = $1
|
||||
AND e.brand_id IS NOT NULL
|
||||
AND e.occurred_at >= NOW() - INTERVAL '${daysNum} days'
|
||||
GROUP BY e.brand_id
|
||||
ORDER BY clicks DESC
|
||||
LIMIT ${limitNum}
|
||||
`, [storeId]);
|
||||
|
||||
const brands = result.rows.map(row => ({
|
||||
brand_id: row.brand_id,
|
||||
brand_name: row.brand_id, // Use brand_id as name for now
|
||||
clicks: parseInt(row.clicks, 10),
|
||||
unique_products: parseInt(row.unique_products, 10),
|
||||
first_click_at: row.first_click_at,
|
||||
last_click_at: row.last_click_at
|
||||
}));
|
||||
|
||||
res.json({
|
||||
store: {
|
||||
id: store.id,
|
||||
name: store.dba_name || store.name,
|
||||
city: store.city,
|
||||
state: store.state
|
||||
},
|
||||
filters: {
|
||||
days: daysNum
|
||||
},
|
||||
brands
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[ClickAnalytics] Error fetching store brand analytics:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch store brand analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/analytics/clicks/summary
|
||||
* Get overall click summary stats
|
||||
*
|
||||
* Query params:
|
||||
* - state: Filter by store state
|
||||
* - days: Lookback window (default 30)
|
||||
*/
|
||||
router.get('/summary', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { state, days = '30' } = req.query;
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
|
||||
const conditions: string[] = [`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (state) {
|
||||
conditions.push(`d.state = $${paramIdx++}`);
|
||||
params.push(state);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
// Get overall stats
|
||||
const statsResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_clicks,
|
||||
COUNT(DISTINCT e.product_id) as unique_products,
|
||||
COUNT(DISTINCT e.store_id) as unique_stores,
|
||||
COUNT(DISTINCT e.brand_id) FILTER (WHERE e.brand_id IS NOT NULL) as unique_brands,
|
||||
COUNT(*) FILTER (WHERE e.campaign_id IS NOT NULL) as campaign_clicks,
|
||||
COUNT(DISTINCT e.campaign_id) FILTER (WHERE e.campaign_id IS NOT NULL) as unique_campaigns
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
${whereClause}
|
||||
`, params);
|
||||
|
||||
// Get clicks by action
|
||||
const actionResult = await pool.query(`
|
||||
SELECT
|
||||
action,
|
||||
COUNT(*) as count
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
${whereClause}
|
||||
GROUP BY action
|
||||
ORDER BY count DESC
|
||||
`, params);
|
||||
|
||||
// Get clicks by day (last 14 days for chart)
|
||||
const dailyResult = await pool.query(`
|
||||
SELECT
|
||||
DATE(occurred_at) as date,
|
||||
COUNT(*) as clicks
|
||||
FROM product_click_events e
|
||||
LEFT JOIN dispensaries d ON e.store_id::int = d.id
|
||||
${whereClause}
|
||||
GROUP BY DATE(occurred_at)
|
||||
ORDER BY date DESC
|
||||
LIMIT 14
|
||||
`, params);
|
||||
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
res.json({
|
||||
filters: {
|
||||
state: state || null,
|
||||
days: daysNum
|
||||
},
|
||||
summary: {
|
||||
total_clicks: parseInt(stats.total_clicks, 10),
|
||||
unique_products: parseInt(stats.unique_products, 10),
|
||||
unique_stores: parseInt(stats.unique_stores, 10),
|
||||
unique_brands: parseInt(stats.unique_brands, 10),
|
||||
campaign_clicks: parseInt(stats.campaign_clicks, 10),
|
||||
unique_campaigns: parseInt(stats.unique_campaigns, 10)
|
||||
},
|
||||
by_action: actionResult.rows.map(row => ({
|
||||
action: row.action,
|
||||
count: parseInt(row.count, 10)
|
||||
})),
|
||||
daily: dailyResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
clicks: parseInt(row.clicks, 10)
|
||||
})).reverse()
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[ClickAnalytics] Error fetching click summary:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch click summary' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user