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:
Kelly
2025-12-07 22:48:21 -07:00
parent 38d3ea1408
commit 3bc0effa33
74 changed files with 12295 additions and 807 deletions

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