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

@@ -301,10 +301,19 @@ function getScopedDispensaryId(req: PublicApiRequest): { dispensaryId: number |
* Query params:
* - category: Filter by product type (e.g., 'flower', 'edible')
* - brand: Filter by brand name
* - strain_type: Filter by strain type (indica, sativa, hybrid)
* - min_price: Minimum price filter (in dollars)
* - max_price: Maximum price filter (in dollars)
* - min_thc: Minimum THC percentage filter
* - max_thc: Maximum THC percentage filter
* - on_special: Only return products on special (true/false)
* - search: Search by name or brand
* - in_stock_only: Only return in-stock products (default: false)
* - limit: Max products to return (default: 100, max: 500)
* - offset: Pagination offset (default: 0)
* - dispensary_id: (internal keys only) Filter by specific dispensary
* - sort_by: Sort field (name, price, thc, updated) (default: name)
* - sort_dir: Sort direction (asc, desc) (default: asc)
*/
router.get('/products', async (req: PublicApiRequest, res: Response) => {
try {
@@ -322,9 +331,18 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
const {
category,
brand,
strain_type,
min_price,
max_price,
min_thc,
max_thc,
on_special,
search,
in_stock_only = 'false',
limit = '100',
offset = '0'
offset = '0',
sort_by = 'name',
sort_dir = 'asc'
} = req.query;
// Build query
@@ -364,12 +382,63 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
paramIndex++;
}
// Filter by strain type (indica, sativa, hybrid)
if (strain_type) {
whereClause += ` AND LOWER(p.strain_type) = LOWER($${paramIndex})`;
params.push(strain_type);
paramIndex++;
}
// Filter by THC range
if (min_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) >= $${paramIndex}`;
params.push(parseFloat(min_thc as string));
paramIndex++;
}
if (max_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) <= $${paramIndex}`;
params.push(parseFloat(max_thc as string));
paramIndex++;
}
// Filter by on special
if (on_special === 'true' || on_special === '1') {
whereClause += ` AND s.special = TRUE`;
}
// Search by name or brand
if (search) {
whereClause += ` AND (LOWER(p.name) LIKE LOWER($${paramIndex}) OR LOWER(p.brand_name) LIKE LOWER($${paramIndex}))`;
params.push(`%${search}%`);
paramIndex++;
}
// Enforce limits
const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500);
const offsetNum = parseInt(offset as string, 10) || 0;
// Build ORDER BY clause
const sortDirection = sort_dir === 'desc' ? 'DESC' : 'ASC';
let orderBy = 'p.name ASC';
switch (sort_by) {
case 'price':
orderBy = `s.rec_min_price_cents ${sortDirection} NULLS LAST`;
break;
case 'thc':
orderBy = `CAST(NULLIF(p.thc, '') AS NUMERIC) ${sortDirection} NULLS LAST`;
break;
case 'updated':
orderBy = `p.updated_at ${sortDirection}`;
break;
case 'name':
default:
orderBy = `p.name ${sortDirection}`;
}
params.push(limitNum, offsetNum);
// Query products with latest snapshot data
// Note: Price filters use HAVING clause since they reference the snapshot subquery
const { rows: products } = await dutchieAzQuery(`
SELECT
p.id,
@@ -406,13 +475,24 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
LIMIT 1
) s ON true
${whereClause}
ORDER BY p.name ASC
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
ORDER BY ${orderBy}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count for pagination
// Get total count for pagination (include price filters if specified)
const { rows: countRows } = await dutchieAzQuery(`
SELECT COUNT(*) as total FROM dutchie_products p ${whereClause}
SELECT COUNT(*) as total FROM dutchie_products p
LEFT JOIN LATERAL (
SELECT rec_min_price_cents, special FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
${whereClause}
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
`, params.slice(0, -2));
// Transform products to backward-compatible format