Fix category-crawler-jobs store lookup query
- Fix column name from s.dutchie_plus_url to s.dutchie_url - Add availability tracking and product freshness APIs - Add crawl script for sequential dispensary processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,10 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import { Package, Tag, Zap, TrendingUp, Calendar, DollarSign } from 'lucide-react';
|
||||
import {
|
||||
Package, Tag, Zap, Clock, ExternalLink, CheckCircle, XCircle,
|
||||
AlertCircle, Building, MapPin, RefreshCw, Calendar, Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
export function StoreDetail() {
|
||||
const { slug } = useParams();
|
||||
@@ -14,7 +17,7 @@ export function StoreDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>('');
|
||||
const [view, setView] = useState<'products' | 'brands' | 'specials'>('products');
|
||||
const [view, setView] = useState<'products' | 'brands' | 'specials' | 'crawl-history'>('products');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,19 +33,22 @@ export function StoreDetail() {
|
||||
const loadStoreData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First, find store by slug to get its ID
|
||||
const allStores = await api.getStores();
|
||||
const storeData = allStores.stores.find((s: any) => s.slug === slug);
|
||||
const basicStore = allStores.stores.find((s: any) => s.slug === slug);
|
||||
|
||||
if (!storeData) {
|
||||
if (!basicStore) {
|
||||
throw new Error('Store not found');
|
||||
}
|
||||
|
||||
const [categoriesData, brandsData] = await Promise.all([
|
||||
api.getCategories(storeData.id),
|
||||
api.getStoreBrands(storeData.id)
|
||||
// Fetch full store details using the enhanced endpoint
|
||||
const [fullStoreData, categoriesData, brandsData] = await Promise.all([
|
||||
api.getStore(basicStore.id),
|
||||
api.getCategories(basicStore.id),
|
||||
api.getStoreBrands(basicStore.id)
|
||||
]);
|
||||
|
||||
setStore(storeData);
|
||||
setStore(fullStoreData);
|
||||
setCategories(categoriesData.categories || []);
|
||||
setBrands(brandsData.brands || []);
|
||||
} catch (error) {
|
||||
@@ -101,6 +107,43 @@ export function StoreDetail() {
|
||||
return 'https://via.placeholder.com/300x300?text=No+Image';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getProviderBadgeColor = (provider: string) => {
|
||||
switch (provider?.toLowerCase()) {
|
||||
case 'dutchie': return 'bg-green-100 text-green-700';
|
||||
case 'jane': return 'bg-purple-100 text-purple-700';
|
||||
case 'treez': return 'bg-blue-100 text-blue-700';
|
||||
case 'weedmaps': return 'bg-orange-100 text-orange-700';
|
||||
case 'leafly': return 'bg-emerald-100 text-emerald-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const getJobStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Completed</span>;
|
||||
case 'running':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full flex items-center gap-1"><RefreshCw className="w-3 h-3 animate-spin" /> Running</span>;
|
||||
case 'failed':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full flex items-center gap-1"><XCircle className="w-3 h-3" /> Failed</span>;
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-700 rounded-full flex items-center gap-1"><Clock className="w-3 h-3" /> Pending</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded-full">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
@@ -127,33 +170,112 @@ export function StoreDetail() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
{/* Header with Store Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/stores')}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
className="text-gray-600 hover:text-gray-900 mt-1"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{store.name}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{products.length} products • {categories.length} categories • {brands.length} brands
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{store.name}</h1>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getProviderBadgeColor(store.provider)}`}>
|
||||
{store.provider || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">Store ID: {store.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={store.dutchie_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View on Dutchie →
|
||||
View Menu <ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs mb-1">
|
||||
<Package className="w-4 h-4" />
|
||||
Products
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-gray-900">{store.product_count || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs mb-1">
|
||||
<Tag className="w-4 h-4" />
|
||||
Categories
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-gray-900">{store.category_count || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 text-xs mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
In Stock
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-green-700">{store.in_stock_count || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-red-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-600 text-xs mb-1">
|
||||
<XCircle className="w-4 h-4" />
|
||||
Out of Stock
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-red-700">{store.out_of_stock_count || 0}</p>
|
||||
</div>
|
||||
<div className={`p-4 rounded-lg ${store.is_stale ? 'bg-yellow-50' : 'bg-blue-50'}`}>
|
||||
<div className={`flex items-center gap-2 text-xs mb-1 ${store.is_stale ? 'text-yellow-600' : 'text-blue-600'}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
Freshness
|
||||
</div>
|
||||
<p className={`text-sm font-semibold ${store.is_stale ? 'text-yellow-700' : 'text-blue-700'}`}>
|
||||
{store.freshness || 'Never scraped'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs mb-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Next Crawl
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
{store.schedule?.next_run_at ? formatDate(store.schedule.next_run_at) : 'Not scheduled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Dispensary */}
|
||||
{store.linked_dispensary && (
|
||||
<div className="p-4 bg-indigo-50 rounded-lg mb-6">
|
||||
<div className="flex items-center gap-2 text-indigo-600 text-xs mb-2">
|
||||
<Building className="w-4 h-4" />
|
||||
Linked Dispensary
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-indigo-900">{store.linked_dispensary.name}</p>
|
||||
<p className="text-sm text-indigo-700 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{store.linked_dispensary.city}, {store.linked_dispensary.state}
|
||||
{store.linked_dispensary.address && ` - ${store.linked_dispensary.address}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/dispensaries/${store.linked_dispensary.slug}`)}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
View Dispensary →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Tabs */}
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
<button
|
||||
@@ -195,9 +317,75 @@ export function StoreDetail() {
|
||||
Specials
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('crawl-history')}
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
view === 'crawl-history'
|
||||
? 'border-blue-600 text-blue-600 font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Crawl History ({store.recent_jobs?.length || 0})
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawl History View */}
|
||||
{view === 'crawl-history' && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Crawl Jobs</h2>
|
||||
<p className="text-sm text-gray-500">Last 10 crawl jobs for this store</p>
|
||||
</div>
|
||||
{store.recent_jobs && store.recent_jobs.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Started</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Completed</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Found</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">New</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Updated</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">In Stock</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Out of Stock</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{store.recent_jobs.map((job: any) => (
|
||||
<tr key={job.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">{getJobStatusBadge(job.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">{job.job_type || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">{formatDate(job.started_at)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">{formatDate(job.completed_at)}</td>
|
||||
<td className="px-4 py-3 text-center text-sm font-medium text-gray-900">{job.products_found ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm font-medium text-green-600">{job.products_new ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm font-medium text-blue-600">{job.products_updated ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm font-medium text-green-600">{job.in_stock_count ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm font-medium text-red-600">{job.out_of_stock_count ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600 max-w-xs truncate" title={job.error_message || ''}>
|
||||
{job.error_message || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No crawl history available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products View */}
|
||||
{view === 'products' && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user