feat: Add server-side brand search to Intelligence page

- Backend: Add 'search' param to /api/admin/intelligence/brands
- Frontend: Debounced search triggers server-side query
- Now searches ALL brands, not just top 500

🤖 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-13 01:14:02 -07:00
parent 271faf0f00
commit 7067db68fc
4 changed files with 44 additions and 24 deletions

View File

@@ -21,18 +21,30 @@ router.use(authMiddleware);
*/
router.get('/brands', async (req: Request, res: Response) => {
try {
const { limit = '500', offset = '0', state } = req.query;
const { limit = '500', offset = '0', state, search } = req.query;
const limitNum = Math.min(parseInt(limit as string, 10), 1000);
const offsetNum = parseInt(offset as string, 10);
// Build WHERE clause based on state filter
let stateFilter = '';
// Build WHERE clause based on filters
const conditions: string[] = ["sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''"];
const params: any[] = [limitNum, offsetNum];
let paramIndex = 3;
if (state && state !== 'all') {
stateFilter = 'AND d.state = $3';
conditions.push(`d.state = $${paramIndex}`);
params.push(state);
paramIndex++;
}
// Server-side search - case-insensitive partial match
if (search && typeof search === 'string' && search.trim()) {
conditions.push(`sp.brand_name_raw ILIKE $${paramIndex}`);
params.push(`%${search.trim()}%`);
paramIndex++;
}
const whereClause = conditions.join(' AND ');
const { rows } = await pool.query(`
SELECT
sp.brand_name_raw as brand_name,
@@ -43,26 +55,22 @@ router.get('/brands', async (req: Request, res: Response) => {
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
FROM store_products sp
JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
${stateFilter}
WHERE ${whereClause}
GROUP BY sp.brand_name_raw
ORDER BY store_count DESC, sku_count DESC
LIMIT $1 OFFSET $2
`, params);
// Get total count with same state filter
const countParams: any[] = [];
let countStateFilter = '';
if (state && state !== 'all') {
countStateFilter = 'AND d.state = $1';
countParams.push(state);
}
// Get total count with same filters (excluding limit/offset)
const countParams = params.slice(2); // Remove limit and offset
const countConditions = conditions.map((c, i) =>
c.replace(/\$\d+/g, (match) => `$${parseInt(match.slice(1)) - 2}`)
);
const { rows: countRows } = await pool.query(`
SELECT COUNT(DISTINCT sp.brand_name_raw) as total
FROM store_products sp
JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
${countStateFilter}
WHERE ${countConditions.join(' AND ')}
`, countParams);
res.json({

View File

@@ -7,7 +7,7 @@
<title>CannaIQ - Cannabis Menu Intelligence Platform</title>
<meta name="description" content="CannaIQ provides real-time cannabis dispensary menu data, product tracking, and analytics for dispensaries across Arizona." />
<meta name="keywords" content="cannabis, dispensary, menu, products, analytics, Arizona" />
<script type="module" crossorigin src="/assets/index-Db080HYK.js"></script>
<script type="module" crossorigin src="/assets/index-5TyJIiu6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0KNyXCG.css">
</head>
<body>

View File

@@ -1566,11 +1566,12 @@ class ApiClient {
}
// Intelligence API
async getIntelligenceBrands(params?: { limit?: number; offset?: number; state?: string }) {
async getIntelligenceBrands(params?: { limit?: number; offset?: number; state?: string; search?: string }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
if (params?.state) searchParams.append('state', params.state);
if (params?.search) searchParams.append('search', params.search);
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
brands: Array<{

View File

@@ -31,11 +31,21 @@ export function IntelligenceBrands() {
const [brands, setBrands] = useState<BrandData[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name' | 'states'>('stores');
// Debounce search term
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Load brands when state or search changes
useEffect(() => {
loadBrands();
}, [stateParam]);
}, [stateParam, debouncedSearch]);
useEffect(() => {
// Load available states
@@ -47,7 +57,11 @@ export function IntelligenceBrands() {
const loadBrands = async () => {
try {
setLoading(true);
const data = await api.getIntelligenceBrands({ limit: 500, state: stateParam });
const data = await api.getIntelligenceBrands({
limit: 500,
state: stateParam,
search: debouncedSearch || undefined,
});
setBrands(data.brands || []);
} catch (error) {
console.error('Failed to load brands:', error);
@@ -56,11 +70,8 @@ export function IntelligenceBrands() {
}
};
const filteredBrands = brands
.filter(brand =>
brand.brandName.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => {
// Sort only (search is now server-side)
const filteredBrands = brands.sort((a, b) => {
switch (sortBy) {
case 'stores':
return b.storeCount - a.storeCount;