## 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>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
/**
|
|
* Consumer Saved Searches API Routes
|
|
* Handles saved searches for findagram.co (products) and findadispo.com (dispensaries)
|
|
*/
|
|
|
|
import { Router, Request, Response } from 'express';
|
|
import { pool } from '../db/pool';
|
|
import { authenticateConsumer } from './consumer-auth';
|
|
|
|
const router = Router();
|
|
|
|
// All routes require authentication
|
|
router.use(authenticateConsumer);
|
|
|
|
/**
|
|
* GET /api/consumer/saved-searches
|
|
* Get user's saved searches
|
|
*/
|
|
router.get('/', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
const domain = (req as any).domain;
|
|
|
|
if (domain === 'findagram.co') {
|
|
const result = await pool.query(
|
|
`SELECT * FROM findagram_saved_searches
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC`,
|
|
[userId]
|
|
);
|
|
|
|
res.json({
|
|
savedSearches: result.rows.map(row => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
query: row.query,
|
|
category: row.category,
|
|
brand: row.brand,
|
|
strainType: row.strain_type,
|
|
minPrice: row.min_price,
|
|
maxPrice: row.max_price,
|
|
minThc: row.min_thc,
|
|
maxThc: row.max_thc,
|
|
city: row.city,
|
|
state: row.state,
|
|
notifyOnNew: row.notify_on_new,
|
|
notifyOnPriceDrop: row.notify_on_price_drop,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}))
|
|
});
|
|
} else if (domain === 'findadispo.com') {
|
|
const result = await pool.query(
|
|
`SELECT * FROM findadispo_saved_searches
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC`,
|
|
[userId]
|
|
);
|
|
|
|
res.json({
|
|
savedSearches: result.rows.map(row => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
query: row.query,
|
|
city: row.city,
|
|
state: row.state,
|
|
minRating: row.min_rating,
|
|
maxDistance: row.max_distance,
|
|
amenities: row.amenities || [],
|
|
notifyOnNewDispensary: row.notify_on_new_dispensary,
|
|
notifyOnDeals: row.notify_on_deals,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}))
|
|
});
|
|
} else {
|
|
res.status(400).json({ error: 'Invalid domain' });
|
|
}
|
|
} catch (error) {
|
|
console.error('[Consumer Saved Searches] Get error:', error);
|
|
res.status(500).json({ error: 'Failed to get saved searches' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/consumer/saved-searches
|
|
* Create a saved search
|
|
*/
|
|
router.post('/', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
const domain = (req as any).domain;
|
|
|
|
if (domain === 'findagram.co') {
|
|
const {
|
|
name,
|
|
query,
|
|
category,
|
|
brand,
|
|
strainType,
|
|
minPrice,
|
|
maxPrice,
|
|
minThc,
|
|
maxThc,
|
|
city,
|
|
state,
|
|
notifyOnNew = false,
|
|
notifyOnPriceDrop = false
|
|
} = req.body;
|
|
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO findagram_saved_searches
|
|
(user_id, name, query, category, brand, strain_type, min_price, max_price,
|
|
min_thc, max_thc, city, state, notify_on_new, notify_on_price_drop)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id`,
|
|
[
|
|
userId, name, query || null, category || null, brand || null,
|
|
strainType || null, minPrice || null, maxPrice || null,
|
|
minThc || null, maxThc || null, city || null, state || null,
|
|
notifyOnNew, notifyOnPriceDrop
|
|
]
|
|
);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
savedSearchId: result.rows[0].id,
|
|
message: 'Search saved'
|
|
});
|
|
|
|
} else if (domain === 'findadispo.com') {
|
|
const {
|
|
name,
|
|
query,
|
|
city,
|
|
state,
|
|
minRating,
|
|
maxDistance,
|
|
amenities,
|
|
notifyOnNewDispensary = false,
|
|
notifyOnDeals = false
|
|
} = req.body;
|
|
|
|
if (!name) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO findadispo_saved_searches
|
|
(user_id, name, query, city, state, min_rating, max_distance, amenities,
|
|
notify_on_new_dispensary, notify_on_deals)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING id`,
|
|
[
|
|
userId, name, query || null, city || null, state || null,
|
|
minRating || null, maxDistance || null, amenities || null,
|
|
notifyOnNewDispensary, notifyOnDeals
|
|
]
|
|
);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
savedSearchId: result.rows[0].id,
|
|
message: 'Search saved'
|
|
});
|
|
|
|
} else {
|
|
res.status(400).json({ error: 'Invalid domain' });
|
|
}
|
|
} catch (error) {
|
|
console.error('[Consumer Saved Searches] Create error:', error);
|
|
res.status(500).json({ error: 'Failed to save search' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/consumer/saved-searches/:id
|
|
* Update a saved search
|
|
*/
|
|
router.put('/:id', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
const domain = (req as any).domain;
|
|
const searchId = parseInt(req.params.id);
|
|
|
|
if (isNaN(searchId)) {
|
|
return res.status(400).json({ error: 'Invalid search ID' });
|
|
}
|
|
|
|
if (domain === 'findagram.co') {
|
|
const {
|
|
name,
|
|
query,
|
|
category,
|
|
brand,
|
|
strainType,
|
|
minPrice,
|
|
maxPrice,
|
|
minThc,
|
|
maxThc,
|
|
city,
|
|
state,
|
|
notifyOnNew,
|
|
notifyOnPriceDrop
|
|
} = req.body;
|
|
|
|
const result = await pool.query(
|
|
`UPDATE findagram_saved_searches SET
|
|
name = COALESCE($1, name),
|
|
query = COALESCE($2, query),
|
|
category = COALESCE($3, category),
|
|
brand = COALESCE($4, brand),
|
|
strain_type = COALESCE($5, strain_type),
|
|
min_price = COALESCE($6, min_price),
|
|
max_price = COALESCE($7, max_price),
|
|
min_thc = COALESCE($8, min_thc),
|
|
max_thc = COALESCE($9, max_thc),
|
|
city = COALESCE($10, city),
|
|
state = COALESCE($11, state),
|
|
notify_on_new = COALESCE($12, notify_on_new),
|
|
notify_on_price_drop = COALESCE($13, notify_on_price_drop),
|
|
updated_at = NOW()
|
|
WHERE id = $14 AND user_id = $15
|
|
RETURNING id`,
|
|
[
|
|
name, query, category, brand, strainType, minPrice, maxPrice,
|
|
minThc, maxThc, city, state, notifyOnNew, notifyOnPriceDrop,
|
|
searchId, userId
|
|
]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Saved search not found' });
|
|
}
|
|
|
|
res.json({ success: true, message: 'Search updated' });
|
|
|
|
} else if (domain === 'findadispo.com') {
|
|
const {
|
|
name,
|
|
query,
|
|
city,
|
|
state,
|
|
minRating,
|
|
maxDistance,
|
|
amenities,
|
|
notifyOnNewDispensary,
|
|
notifyOnDeals
|
|
} = req.body;
|
|
|
|
const result = await pool.query(
|
|
`UPDATE findadispo_saved_searches SET
|
|
name = COALESCE($1, name),
|
|
query = COALESCE($2, query),
|
|
city = COALESCE($3, city),
|
|
state = COALESCE($4, state),
|
|
min_rating = COALESCE($5, min_rating),
|
|
max_distance = COALESCE($6, max_distance),
|
|
amenities = COALESCE($7, amenities),
|
|
notify_on_new_dispensary = COALESCE($8, notify_on_new_dispensary),
|
|
notify_on_deals = COALESCE($9, notify_on_deals),
|
|
updated_at = NOW()
|
|
WHERE id = $10 AND user_id = $11
|
|
RETURNING id`,
|
|
[
|
|
name, query, city, state, minRating, maxDistance,
|
|
amenities, notifyOnNewDispensary, notifyOnDeals,
|
|
searchId, userId
|
|
]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Saved search not found' });
|
|
}
|
|
|
|
res.json({ success: true, message: 'Search updated' });
|
|
|
|
} else {
|
|
res.status(400).json({ error: 'Invalid domain' });
|
|
}
|
|
} catch (error) {
|
|
console.error('[Consumer Saved Searches] Update error:', error);
|
|
res.status(500).json({ error: 'Failed to update search' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/consumer/saved-searches/:id
|
|
* Delete a saved search
|
|
*/
|
|
router.delete('/:id', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
const domain = (req as any).domain;
|
|
const searchId = parseInt(req.params.id);
|
|
|
|
if (isNaN(searchId)) {
|
|
return res.status(400).json({ error: 'Invalid search ID' });
|
|
}
|
|
|
|
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
|
|
|
|
const result = await pool.query(
|
|
`DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`,
|
|
[searchId, userId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Saved search not found' });
|
|
}
|
|
|
|
res.json({ success: true, message: 'Search deleted' });
|
|
} catch (error) {
|
|
console.error('[Consumer Saved Searches] Delete error:', error);
|
|
res.status(500).json({ error: 'Failed to delete search' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/consumer/saved-searches/:id/run
|
|
* Execute a saved search and return results
|
|
* This builds the search URL/params that the frontend can use
|
|
*/
|
|
router.post('/:id/run', async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as any).userId;
|
|
const domain = (req as any).domain;
|
|
const searchId = parseInt(req.params.id);
|
|
|
|
if (isNaN(searchId)) {
|
|
return res.status(400).json({ error: 'Invalid search ID' });
|
|
}
|
|
|
|
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
|
|
|
|
const result = await pool.query(
|
|
`SELECT * FROM ${table} WHERE id = $1 AND user_id = $2`,
|
|
[searchId, userId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Saved search not found' });
|
|
}
|
|
|
|
const search = result.rows[0];
|
|
|
|
// Build search parameters for frontend to use
|
|
if (domain === 'findagram.co') {
|
|
const params: Record<string, any> = {};
|
|
if (search.query) params.q = search.query;
|
|
if (search.category) params.category = search.category;
|
|
if (search.brand) params.brand = search.brand;
|
|
if (search.strain_type) params.strainType = search.strain_type;
|
|
if (search.min_price) params.minPrice = search.min_price;
|
|
if (search.max_price) params.maxPrice = search.max_price;
|
|
if (search.min_thc) params.minThc = search.min_thc;
|
|
if (search.max_thc) params.maxThc = search.max_thc;
|
|
if (search.city) params.city = search.city;
|
|
if (search.state) params.state = search.state;
|
|
|
|
res.json({
|
|
searchParams: params,
|
|
searchUrl: `/products?${new URLSearchParams(params as any).toString()}`
|
|
});
|
|
} else {
|
|
const params: Record<string, any> = {};
|
|
if (search.query) params.q = search.query;
|
|
if (search.city) params.city = search.city;
|
|
if (search.state) params.state = search.state;
|
|
if (search.min_rating) params.minRating = search.min_rating;
|
|
if (search.max_distance) params.maxDistance = search.max_distance;
|
|
if (search.amenities?.length) params.amenities = search.amenities.join(',');
|
|
|
|
res.json({
|
|
searchParams: params,
|
|
searchUrl: `/?${new URLSearchParams(params as any).toString()}`
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('[Consumer Saved Searches] Run error:', error);
|
|
res.status(500).json({ error: 'Failed to run search' });
|
|
}
|
|
});
|
|
|
|
export default router;
|