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:
Kelly
2025-12-01 00:07:00 -07:00
parent 20a7b69537
commit 9d8972aa86
15 changed files with 11604 additions and 42 deletions

View File

@@ -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' && (
<>