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:
@@ -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({
|
||||
|
||||
2
cannaiq/dist/index.html
vendored
2
cannaiq/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user