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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user