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) => {
|
router.get('/brands', async (req: Request, res: Response) => {
|
||||||
try {
|
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 limitNum = Math.min(parseInt(limit as string, 10), 1000);
|
||||||
const offsetNum = parseInt(offset as string, 10);
|
const offsetNum = parseInt(offset as string, 10);
|
||||||
|
|
||||||
// Build WHERE clause based on state filter
|
// Build WHERE clause based on filters
|
||||||
let stateFilter = '';
|
const conditions: string[] = ["sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''"];
|
||||||
const params: any[] = [limitNum, offsetNum];
|
const params: any[] = [limitNum, offsetNum];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
if (state && state !== 'all') {
|
if (state && state !== 'all') {
|
||||||
stateFilter = 'AND d.state = $3';
|
conditions.push(`d.state = $${paramIndex}`);
|
||||||
params.push(state);
|
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(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name_raw as brand_name,
|
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
|
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE ${whereClause}
|
||||||
${stateFilter}
|
|
||||||
GROUP BY sp.brand_name_raw
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY store_count DESC, sku_count DESC
|
ORDER BY store_count DESC, sku_count DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $1 OFFSET $2
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
// Get total count with same state filter
|
// Get total count with same filters (excluding limit/offset)
|
||||||
const countParams: any[] = [];
|
const countParams = params.slice(2); // Remove limit and offset
|
||||||
let countStateFilter = '';
|
const countConditions = conditions.map((c, i) =>
|
||||||
if (state && state !== 'all') {
|
c.replace(/\$\d+/g, (match) => `$${parseInt(match.slice(1)) - 2}`)
|
||||||
countStateFilter = 'AND d.state = $1';
|
);
|
||||||
countParams.push(state);
|
|
||||||
}
|
|
||||||
const { rows: countRows } = await pool.query(`
|
const { rows: countRows } = await pool.query(`
|
||||||
SELECT COUNT(DISTINCT sp.brand_name_raw) as total
|
SELECT COUNT(DISTINCT sp.brand_name_raw) as total
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE ${countConditions.join(' AND ')}
|
||||||
${countStateFilter}
|
|
||||||
`, countParams);
|
`, countParams);
|
||||||
|
|
||||||
res.json({
|
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>
|
<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="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" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-B0KNyXCG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1566,11 +1566,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Intelligence API
|
// 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();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||||
if (params?.offset) searchParams.append('offset', params.offset.toString());
|
if (params?.offset) searchParams.append('offset', params.offset.toString());
|
||||||
if (params?.state) searchParams.append('state', params.state);
|
if (params?.state) searchParams.append('state', params.state);
|
||||||
|
if (params?.search) searchParams.append('search', params.search);
|
||||||
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||||
return this.request<{
|
return this.request<{
|
||||||
brands: Array<{
|
brands: Array<{
|
||||||
|
|||||||
@@ -31,11 +31,21 @@ export function IntelligenceBrands() {
|
|||||||
const [brands, setBrands] = useState<BrandData[]>([]);
|
const [brands, setBrands] = useState<BrandData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name' | 'states'>('stores');
|
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(() => {
|
useEffect(() => {
|
||||||
loadBrands();
|
loadBrands();
|
||||||
}, [stateParam]);
|
}, [stateParam, debouncedSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load available states
|
// Load available states
|
||||||
@@ -47,7 +57,11 @@ export function IntelligenceBrands() {
|
|||||||
const loadBrands = async () => {
|
const loadBrands = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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 || []);
|
setBrands(data.brands || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load brands:', error);
|
console.error('Failed to load brands:', error);
|
||||||
@@ -56,11 +70,8 @@ export function IntelligenceBrands() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredBrands = brands
|
// Sort only (search is now server-side)
|
||||||
.filter(brand =>
|
const filteredBrands = brands.sort((a, b) => {
|
||||||
brand.brandName.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'stores':
|
case 'stores':
|
||||||
return b.storeCount - a.storeCount;
|
return b.storeCount - a.storeCount;
|
||||||
|
|||||||
Reference in New Issue
Block a user