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:
238
backend/src/routes/seo.ts
Normal file
238
backend/src/routes/seo.ts
Normal 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;
|
||||
Reference in New Issue
Block a user