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:
209
backend/src/routes/events.ts
Normal file
209
backend/src/routes/events.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Events API Routes - Product click tracking
|
||||
*
|
||||
* Tracks user interactions with products for analytics and campaign measurement.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/events/product-click - Record a product click event
|
||||
* GET /api/events/product-clicks - Get product click events (admin)
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db/pool';
|
||||
import { authMiddleware, optionalAuthMiddleware } from '../auth/middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Valid action types
|
||||
const VALID_ACTIONS = ['view', 'open_store', 'open_product', 'compare', 'other'];
|
||||
|
||||
interface ProductClickEventPayload {
|
||||
product_id: string;
|
||||
store_id?: string;
|
||||
brand_id?: string;
|
||||
campaign_id?: string;
|
||||
action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other';
|
||||
source: string;
|
||||
page_type?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence)
|
||||
url_path?: string; // URL path for debugging
|
||||
occurred_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/events/product-click
|
||||
* Record a product click event
|
||||
*
|
||||
* Fire-and-forget from frontend - returns quickly with minimal validation
|
||||
*/
|
||||
router.post('/product-click', optionalAuthMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const payload: ProductClickEventPayload = req.body;
|
||||
|
||||
// Basic validation
|
||||
if (!payload.product_id || typeof payload.product_id !== 'string') {
|
||||
return res.status(400).json({ status: 'error', error: 'product_id is required' });
|
||||
}
|
||||
|
||||
if (!payload.action || !VALID_ACTIONS.includes(payload.action)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!payload.source || typeof payload.source !== 'string') {
|
||||
return res.status(400).json({ status: 'error', error: 'source is required' });
|
||||
}
|
||||
|
||||
// Get user ID from auth context if available
|
||||
const userId = (req as any).user?.id || null;
|
||||
|
||||
// Get IP and user agent from request
|
||||
const ipAddress = req.ip || req.headers['x-forwarded-for'] || null;
|
||||
const userAgent = req.headers['user-agent'] || null;
|
||||
|
||||
// Parse occurred_at or use current time
|
||||
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date();
|
||||
|
||||
// Detect device type from user agent (simple heuristic)
|
||||
let deviceType = 'desktop';
|
||||
if (userAgent) {
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) {
|
||||
deviceType = /ipad|tablet/i.test(ua) ? 'tablet' : 'mobile';
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the event with enhanced fields
|
||||
await pool.query(
|
||||
`INSERT INTO product_click_events
|
||||
(product_id, store_id, brand_id, campaign_id, action, source, user_id, ip_address, user_agent, occurred_at, event_type, page_type, url_path, device_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
[
|
||||
payload.product_id,
|
||||
payload.store_id || null,
|
||||
payload.brand_id || null,
|
||||
payload.campaign_id || null,
|
||||
payload.action,
|
||||
payload.source,
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
occurredAt,
|
||||
'product_click', // event_type
|
||||
payload.page_type || null,
|
||||
payload.url_path || null,
|
||||
deviceType
|
||||
]
|
||||
);
|
||||
|
||||
res.json({ status: 'ok' });
|
||||
} catch (error: any) {
|
||||
console.error('[Events] Error recording product click:', error.message);
|
||||
// Still return ok to not break frontend - events are non-critical
|
||||
res.json({ status: 'ok' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/events/product-clicks
|
||||
* Get product click events (admin only)
|
||||
*
|
||||
* Query params:
|
||||
* - product_id: Filter by product
|
||||
* - store_id: Filter by store
|
||||
* - brand_id: Filter by brand
|
||||
* - campaign_id: Filter by campaign
|
||||
* - action: Filter by action type
|
||||
* - source: Filter by source
|
||||
* - from: Start date (ISO)
|
||||
* - to: End date (ISO)
|
||||
* - limit: Max results (default 100)
|
||||
* - offset: Pagination offset
|
||||
*/
|
||||
router.get('/product-clicks', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
product_id,
|
||||
store_id,
|
||||
brand_id,
|
||||
campaign_id,
|
||||
action,
|
||||
source,
|
||||
from,
|
||||
to,
|
||||
limit = '100',
|
||||
offset = '0'
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (product_id) {
|
||||
conditions.push(`product_id = $${paramIndex++}`);
|
||||
params.push(product_id);
|
||||
}
|
||||
if (store_id) {
|
||||
conditions.push(`store_id = $${paramIndex++}`);
|
||||
params.push(store_id);
|
||||
}
|
||||
if (brand_id) {
|
||||
conditions.push(`brand_id = $${paramIndex++}`);
|
||||
params.push(brand_id);
|
||||
}
|
||||
if (campaign_id) {
|
||||
conditions.push(`campaign_id = $${paramIndex++}`);
|
||||
params.push(campaign_id);
|
||||
}
|
||||
if (action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(action);
|
||||
}
|
||||
if (source) {
|
||||
conditions.push(`source = $${paramIndex++}`);
|
||||
params.push(source);
|
||||
}
|
||||
if (from) {
|
||||
conditions.push(`occurred_at >= $${paramIndex++}`);
|
||||
params.push(new Date(from as string));
|
||||
}
|
||||
if (to) {
|
||||
conditions.push(`occurred_at <= $${paramIndex++}`);
|
||||
params.push(new Date(to as string));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM product_click_events ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
// Get events
|
||||
params.push(parseInt(limit as string, 10));
|
||||
params.push(parseInt(offset as string, 10));
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM product_click_events
|
||||
${whereClause}
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({
|
||||
events: result.rows,
|
||||
total,
|
||||
limit: parseInt(limit as string, 10),
|
||||
offset: parseInt(offset as string, 10)
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Events] Error fetching product clicks:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch product click events' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user