feat: Responsive admin UI, SEO pages, and click analytics

## 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>
This commit is contained in:
Kelly
2025-12-07 22:48:21 -07:00
parent 38d3ea1408
commit 3bc0effa33
74 changed files with 12295 additions and 807 deletions

238
backend/src/routes/seo.ts Normal file
View File

@@ -0,0 +1,238 @@
/**
* SEO API Routes - Content generation and management for CannaiQ marketing pages
*
* All content returned by these endpoints is sanitized to ensure
* enterprise-safe phrasing with no forbidden terminology.
*/
import { Router, Request, Response } from 'express';
import { getPool } from '../db/pool';
import { authMiddleware } from '../auth/middleware';
import { ContentValidator } from '../utils/ContentValidator';
import { generateSeoPageWithClaude } from '../services/seoGenerator';
const router = Router();
/**
* GET /api/seo/page - Get SEO page content by slug (public, sanitized)
*/
router.get('/page', async (req: Request, res: Response) => {
try {
const { slug } = req.query;
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'slug query parameter required' });
}
const pool = getPool();
const result = await pool.query(
`SELECT id, slug, type, meta_title, meta_description, status, updated_at
FROM seo_pages WHERE slug = $1 AND status = 'live'`,
[slug]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Page not found' });
}
const page = result.rows[0];
// Always sanitize content before returning (safety net)
const content = {
metaTitle: page.meta_title,
metaDescription: page.meta_description,
};
const sanitizedContent = ContentValidator.sanitizeContent(content);
res.json({
id: page.id,
slug: page.slug,
type: page.type,
content: sanitizedContent,
updatedAt: page.updated_at,
});
} catch (error: any) {
console.error('[SEO] Error fetching page:', error.message);
res.status(500).json({ error: 'Failed to fetch SEO page' });
}
});
/**
* POST /api/seo/page - Create/update SEO page (admin, auto-sanitizes)
*/
router.post('/page', authMiddleware, async (req: Request, res: Response) => {
try {
const { slug, type, metaTitle, metaDescription, status = 'draft' } = req.body;
if (!slug || !type) {
return res.status(400).json({ error: 'slug and type required' });
}
// Validate and sanitize content
const content = { metaTitle, metaDescription };
const validation = ContentValidator.validate(content);
if (!validation.valid) {
console.warn(`[SEO] Forbidden terms sanitized for ${slug}:`, validation.forbiddenTerms);
}
const sanitized = validation.sanitized as { metaTitle?: string; metaDescription?: string };
const pool = getPool();
// Always store sanitized content
const result = await pool.query(
`INSERT INTO seo_pages (slug, type, page_key, meta_title, meta_description, status, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (slug) DO UPDATE SET
type = EXCLUDED.type, meta_title = EXCLUDED.meta_title,
meta_description = EXCLUDED.meta_description,
status = EXCLUDED.status, updated_at = NOW()
RETURNING id, slug, type, status, updated_at`,
[slug, type, slug, sanitized.metaTitle || null, sanitized.metaDescription || null, status]
);
res.json({
success: true,
page: result.rows[0],
sanitized: !validation.valid,
forbiddenTermsRemoved: validation.forbiddenTerms,
});
} catch (error: any) {
console.error('[SEO] Error saving page:', error.message);
res.status(500).json({ error: 'Failed to save SEO page' });
}
});
/**
* POST /api/seo/validate - Validate content for forbidden terms
*/
router.post('/validate', async (req: Request, res: Response) => {
try {
const { content } = req.body;
if (!content) {
return res.status(400).json({ error: 'content is required' });
}
const validation = ContentValidator.validate(content);
res.json({
valid: validation.valid,
forbiddenTerms: validation.forbiddenTerms,
sanitized: validation.sanitized,
approvedPhrases: ContentValidator.getApprovedPhrases(),
});
} catch (error: any) {
res.status(500).json({ error: 'Failed to validate content' });
}
});
/**
* GET /api/seo/state/:stateCode - State SEO data with metrics
*/
router.get('/state/:stateCode', async (req: Request, res: Response) => {
try {
const { stateCode } = req.params;
const code = stateCode.toUpperCase();
const pool = getPool();
const metricsResult = await pool.query(`
SELECT COUNT(DISTINCT d.id) as dispensary_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT p.brand_name) as brand_count
FROM dispensaries d
LEFT JOIN dutchie_products p ON p.dispensary_id = d.id
WHERE d.state = $1`, [code]);
const brandsResult = await pool.query(`
SELECT brand_name, COUNT(*) as product_count
FROM dutchie_products p JOIN dispensaries d ON p.dispensary_id = d.id
WHERE d.state = $1 AND p.brand_name IS NOT NULL
GROUP BY brand_name ORDER BY product_count DESC LIMIT 10`, [code]);
const metrics = metricsResult.rows[0];
const response = ContentValidator.sanitizeContent({
stateCode: code,
metrics: {
dispensaryCount: parseInt(metrics.dispensary_count, 10) || 0,
productCount: parseInt(metrics.product_count, 10) || 0,
brandCount: parseInt(metrics.brand_count, 10) || 0,
},
topBrands: brandsResult.rows.map(r => ({
name: r.brand_name,
productCount: parseInt(r.product_count, 10),
})),
});
res.json(response);
} catch (error: any) {
console.error('[SEO] Error fetching state data:', error.message);
res.status(500).json({ error: 'Failed to fetch state SEO data' });
}
});
/**
* POST /api/seo/pages/:id/generate - Generate SEO content for a page
*/
router.post('/pages/:id/generate', authMiddleware, async (req: Request, res: Response) => {
try {
const { id } = req.params;
const pageId = parseInt(id, 10);
if (isNaN(pageId)) {
return res.status(400).json({ error: 'Invalid page ID' });
}
const content = await generateSeoPageWithClaude(pageId);
res.json({ success: true, content });
} catch (error: any) {
console.error('[SEO] Error generating page:', error.message);
res.status(500).json({ error: error.message || 'Failed to generate SEO content' });
}
});
/**
* GET /api/seo/public/content - Get full SEO page content by slug (public)
*/
router.get('/public/content', async (req: Request, res: Response) => {
try {
const { slug } = req.query;
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'slug query parameter required' });
}
const pool = getPool();
// Find page and content
const result = await pool.query(`
SELECT p.id, p.slug, p.type, p.status,
c.blocks, c.meta_title, c.meta_description, c.h1,
c.canonical_url, c.og_title, c.og_description, c.og_image_url
FROM seo_pages p
LEFT JOIN seo_page_contents c ON c.page_id = p.id
WHERE p.slug = $1 AND p.status = 'live'
`, [slug]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Page not found or not published' });
}
const row = result.rows[0];
const sanitized = ContentValidator.sanitizeContent({
meta: {
title: row.meta_title,
description: row.meta_description,
h1: row.h1,
canonicalUrl: row.canonical_url,
ogTitle: row.og_title,
ogDescription: row.og_description,
ogImageUrl: row.og_image_url
},
blocks: row.blocks || []
});
res.json({
slug: row.slug,
type: row.type,
...sanitized
});
} catch (error: any) {
console.error('[SEO] Error fetching public content:', error.message);
res.status(500).json({ error: 'Failed to fetch SEO content' });
}
});
export default router;