diff --git a/backend/src/routes/intelligence.ts b/backend/src/routes/intelligence.ts index ff48f4ef..e877a1e6 100644 --- a/backend/src/routes/intelligence.ts +++ b/backend/src/routes/intelligence.ts @@ -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({ diff --git a/cannaiq/dist/index.html b/cannaiq/dist/index.html index 8bde23cc..bbf3c50c 100644 --- a/cannaiq/dist/index.html +++ b/cannaiq/dist/index.html @@ -7,7 +7,7 @@ CannaIQ - Cannabis Menu Intelligence Platform - + diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 388171f9..d6c99df7 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -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<{ diff --git a/cannaiq/src/pages/IntelligenceBrands.tsx b/cannaiq/src/pages/IntelligenceBrands.tsx index ab4d751f..df5be343 100644 --- a/cannaiq/src/pages/IntelligenceBrands.tsx +++ b/cannaiq/src/pages/IntelligenceBrands.tsx @@ -31,11 +31,21 @@ export function IntelligenceBrands() { const [brands, setBrands] = useState([]); 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;