Files
cannaiq/cannaiq/src/pages/Dashboard.tsx
Kelly 2f483b3084 feat: SEO template library, discovery pipeline, and orchestrator enhancements
## SEO Template Library
- Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration)
- Add Template Library tab in SEO Orchestrator with accordion-based editors
- Add template preview, validation, and variable injection engine
- Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate

## Discovery Pipeline
- Add promotion.ts for discovery location validation and promotion
- Add discover-all-states.ts script for multi-state discovery
- Add promotion log migration (067)
- Enhance discovery routes and types

## Orchestrator & Admin
- Add crawl_enabled filter to stores page
- Add API permissions page
- Add job queue management
- Add price analytics routes
- Add markets and intelligence routes
- Enhance dashboard and worker monitoring

## Infrastructure
- Add migrations for worker definitions, SEO settings, field alignment
- Add canonical pipeline for scraper v2
- Update hydration and sync orchestrator
- Enhance multi-state query service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 00:05:34 -07:00

553 lines
23 KiB
TypeScript
Executable File

import { useEffect, useState } from 'react';
import { Layout } from '../components/Layout';
import { HealthPanel } from '../components/HealthPanel';
import { api } from '../lib/api';
import { useNavigate } from 'react-router-dom';
import {
Store,
Package,
Target,
Image as ImageIcon,
Tag,
TrendingUp,
RefreshCw,
Activity,
Clock,
X,
AlertTriangle,
Globe,
MapPin,
ArrowRight,
BarChart3
} from 'lucide-react';
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
export function Dashboard() {
const navigate = useNavigate();
const [stats, setStats] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [nationalStats, setNationalStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [pendingChangesCount, setPendingChangesCount] = useState(0);
const [showNotification, setShowNotification] = useState(false);
useEffect(() => {
loadData();
checkNotificationStatus();
}, []);
const checkNotificationStatus = async () => {
try {
// Fetch real pending changes count from API
const stats = await api.getChangeStats();
const count = stats.pending_count;
setPendingChangesCount(count);
// Check if notification was dismissed for this count
const dismissedCount = localStorage.getItem('dismissedPendingChangesCount');
const isDismissed = dismissedCount && parseInt(dismissedCount) >= count;
// Only show if there are pending changes AND notification hasn't been dismissed for this count
setShowNotification(count > 0 && !isDismissed);
} catch (error) {
console.error('Failed to load change stats:', error);
// On error, hide notification
setShowNotification(false);
}
};
const loadData = async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
}
try {
// Fetch dashboard data (primary data source)
const dashboard = await api.getMarketDashboard();
// Map dashboard data to the expected stats format
setStats({
products: {
total: dashboard.productCount,
in_stock: dashboard.productCount, // All products are in stock by default
with_images: 0 // Not tracked in pipeline
},
stores: {
total: dashboard.dispensaryCount,
active: dashboard.dispensaryCount // All are active
},
brands: {
total: dashboard.brandCount
},
campaigns: {
active: 0,
total: 0
},
clicks: {
clicks_24h: dashboard.snapshotCount24h // Use snapshots as activity metric
},
failedJobs: dashboard.failedJobCount,
lastCrawlTime: dashboard.lastCrawlTime
});
// Try to fetch activity data (may fail if not authenticated)
try {
const activityData = await api.getDashboardActivity();
setActivity(activityData);
} catch {
// Activity data requires auth, just skip it
setActivity(null);
}
// Fetch national analytics summary
try {
const response = await api.get('/api/analytics/national/summary');
if (response.data?.success && response.data?.data) {
setNationalStats(response.data.data);
}
} catch {
// National stats not critical, just skip
setNationalStats(null);
}
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const handleDismissNotification = () => {
// Store the current count in localStorage
localStorage.setItem('dismissedPendingChangesCount', pendingChangesCount.toString());
setShowNotification(false);
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
</div>
</Layout>
);
}
const imagePercentage = Math.round((stats?.products?.with_images / stats?.products?.total) * 100) || 0;
const activeStorePercentage = Math.round((stats?.stores?.active / stats?.stores?.total) * 100) || 0;
// Mock data for charts - replace with real API data later
const productTrendData = [
{ date: 'Mon', products: 120 },
{ date: 'Tue', products: 145 },
{ date: 'Wed', products: 132 },
{ date: 'Thu', products: 178 },
{ date: 'Fri', products: 195 },
{ date: 'Sat', products: 210 },
{ date: 'Sun', products: stats?.products?.total || 225 }
];
const scrapeTrendData = [
{ time: '00:00', scrapes: 5 },
{ time: '04:00', scrapes: 12 },
{ time: '08:00', scrapes: 18 },
{ time: '12:00', scrapes: 25 },
{ time: '16:00', scrapes: 30 },
{ time: '20:00', scrapes: 22 },
{ time: '24:00', scrapes: activity?.recent_scrapes?.length || 15 }
];
return (
<Layout>
{/* Pending Changes Notification */}
{showNotification && (
<div className="mb-6 bg-amber-50 border-l-4 border-amber-500 rounded-lg p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center gap-3 flex-1">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5 sm:mt-0" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-amber-900">
{pendingChangesCount} pending change{pendingChangesCount !== 1 ? 's' : ''} require review
</h3>
<p className="text-xs sm:text-sm text-amber-700 mt-0.5">
Proposed changes to dispensary data are waiting for approval
</p>
</div>
</div>
<div className="flex items-center gap-2 pl-8 sm:pl-0">
<button className="btn btn-sm bg-amber-600 hover:bg-amber-700 text-white border-none">
Review
</button>
<button
onClick={handleDismissNotification}
className="btn btn-sm btn-ghost text-amber-900 hover:bg-amber-100"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
</div>
<button
onClick={() => loadData(true)}
disabled={refreshing}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{/* System Health */}
<HealthPanel showQueues={false} refreshInterval={60000} />
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
{/* Products */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-blue-50 rounded-lg">
<Package className="w-5 h-5 text-blue-600" />
</div>
<Activity className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Total Products</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.products?.total?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.products?.in_stock?.toLocaleString() || 0} in stock</p>
</div>
</div>
{/* Stores */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-emerald-50 rounded-lg">
<Store className="w-5 h-5 text-emerald-600" />
</div>
<Activity className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Dispensaries</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.stores?.total?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.stores?.active?.toLocaleString() || 0} active</p>
</div>
</div>
{/* Campaigns */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-purple-50 rounded-lg">
<Target className="w-5 h-5 text-purple-600" />
</div>
<Activity className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Campaigns</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.campaigns?.active || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.campaigns?.total || 0} total</p>
</div>
</div>
{/* Images */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-amber-50 rounded-lg">
<ImageIcon className="w-5 h-5 text-amber-600" />
</div>
<span className="text-xs font-medium text-gray-600">{imagePercentage}%</span>
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Images</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.products?.with_images?.toLocaleString() || 0}</p>
<div className="mt-2 sm:mt-3">
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-amber-500 h-1.5 rounded-full transition-all"
style={{ width: `${imagePercentage}%` }}
></div>
</div>
</div>
</div>
</div>
{/* Snapshots (24h) */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-cyan-50 rounded-lg">
<Activity className="w-5 h-5 text-cyan-600" />
</div>
<Clock className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Snapshots (24h)</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.clicks?.clicks_24h?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">Product snapshots</p>
</div>
</div>
{/* Brands */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-indigo-50 rounded-lg">
<Tag className="w-5 h-5 text-indigo-600" />
</div>
<Activity className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-xs sm:text-sm font-medium text-gray-600">Brands</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.brands?.total || stats?.products?.unique_brands || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">Unique brands</p>
</div>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Product Growth Chart */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="mb-4 sm:mb-6">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Product Growth</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Weekly product count trend</p>
</div>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={productTrendData}>
<defs>
<linearGradient id="productGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
dataKey="date"
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#e2e8f0' }}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#e2e8f0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '12px'
}}
/>
<Area
type="monotone"
dataKey="products"
stroke="#3b82f6"
strokeWidth={2}
fill="url(#productGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Scrape Activity Chart */}
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="mb-4 sm:mb-6">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Scrape Activity</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Scrapes over the last 24 hours</p>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={scrapeTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
dataKey="time"
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#e2e8f0' }}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#e2e8f0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '12px'
}}
/>
<Line
type="monotone"
dataKey="scrapes"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: '#10b981', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* National Analytics Quick Access */}
{nationalStats && (
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-50 rounded-lg">
<Globe className="w-5 h-5 text-emerald-600" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-gray-900">National Analytics</h3>
<p className="text-xs text-gray-500">Multi-state market intelligence</p>
</div>
</div>
<button
onClick={() => navigate('/national')}
className="flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-700"
>
View Dashboard
<ArrowRight className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500">Active States</div>
<div className="text-xl font-bold text-gray-900">{nationalStats.activeStates}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500">Total Stores</div>
<div className="text-xl font-bold text-gray-900">{nationalStats.totalStores?.toLocaleString()}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500">Total Products</div>
<div className="text-xl font-bold text-gray-900">{nationalStats.totalProducts?.toLocaleString()}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500">Avg Price</div>
<div className="text-xl font-bold text-emerald-600">
{nationalStats.avgPriceNational ? `$${Number(nationalStats.avgPriceNational).toFixed(2)}` : '-'}
</div>
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-3 gap-3 mt-4">
<button
onClick={() => navigate('/national/heatmap')}
className="flex items-center gap-2 p-2 text-sm text-gray-600 hover:bg-gray-50 rounded-lg border border-gray-100"
>
<MapPin className="w-4 h-4" />
Heatmap
</button>
<button
onClick={() => navigate('/national/compare')}
className="flex items-center gap-2 p-2 text-sm text-gray-600 hover:bg-gray-50 rounded-lg border border-gray-100"
>
<BarChart3 className="w-4 h-4" />
Compare
</button>
<button
onClick={() => navigate('/analytics')}
className="flex items-center gap-2 p-2 text-sm text-gray-600 hover:bg-gray-50 rounded-lg border border-gray-100"
>
<TrendingUp className="w-4 h-4" />
Pricing
</button>
</div>
</div>
)}
{/* Activity Lists */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Recent Scrapes */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Recent Scrapes</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Latest data collection activities</p>
</div>
<div className="divide-y divide-gray-100">
{activity?.recent_scrapes?.length > 0 ? (
activity.recent_scrapes.slice(0, 5).map((scrape: any, i: number) => (
<div key={i} className="px-4 sm:px-6 py-3 sm:py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">{scrape.name}</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(scrape.last_scraped_at).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</p>
</div>
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{scrape.product_count}
</span>
</div>
</div>
</div>
))
) : (
<div className="px-4 sm:px-6 py-8 sm:py-12 text-center">
<Activity className="w-6 h-6 sm:w-8 sm:h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs sm:text-sm text-gray-500">No recent scrapes</p>
</div>
)}
</div>
</div>
{/* Recent Products */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Recent Products</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Newly added to inventory</p>
</div>
<div className="divide-y divide-gray-100">
{activity?.recent_products?.length > 0 ? (
activity.recent_products.slice(0, 5).map((product: any, i: number) => (
<div key={i} className="px-4 sm:px-6 py-3 sm:py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">{product.name}</p>
<p className="text-xs text-gray-500 mt-1 truncate">{product.store_name}</p>
</div>
{product.price && (
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700">
${product.price}
</span>
</div>
)}
</div>
</div>
))
) : (
<div className="px-4 sm:px-6 py-8 sm:py-12 text-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs sm:text-sm text-gray-500">No recent products</p>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}