## 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>
553 lines
23 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|