Add Dutchie AZ data pipeline and public API v1
- Add dutchie-az module with GraphQL product crawler, scheduler, and admin UI - Add public API v1 endpoints (/api/v1/products, /categories, /brands, /specials, /menu) - API key auth maps dispensary to dutchie_az store for per-dispensary data access - Add frontend pages for Dutchie AZ stores, store details, and schedule management - Update Layout with Dutchie AZ navigation section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
620
frontend/src/pages/DutchieAZStoreDetail.tsx
Normal file
620
frontend/src/pages/DutchieAZStoreDetail.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import {
|
||||
Building2,
|
||||
Phone,
|
||||
MapPin,
|
||||
ExternalLink,
|
||||
ArrowLeft,
|
||||
Package,
|
||||
Tag,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
export function DutchieAZStoreDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'products' | 'brands' | 'categories'>('products');
|
||||
const [showUpdateDropdown, setShowUpdateDropdown] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalProducts, setTotalProducts] = useState(0);
|
||||
const [itemsPerPage] = useState(25);
|
||||
const [stockFilter, setStockFilter] = useState<string>('');
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadStoreSummary();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && activeTab === 'products') {
|
||||
loadProducts();
|
||||
}
|
||||
}, [id, currentPage, searchQuery, stockFilter, activeTab]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, stockFilter]);
|
||||
|
||||
const loadStoreSummary = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getDutchieAZStoreSummary(parseInt(id!, 10));
|
||||
setSummary(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load store summary:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProducts = async () => {
|
||||
if (!id) return;
|
||||
setProductsLoading(true);
|
||||
try {
|
||||
const data = await api.getDutchieAZStoreProducts(parseInt(id, 10), {
|
||||
search: searchQuery || undefined,
|
||||
stockStatus: stockFilter || undefined,
|
||||
limit: itemsPerPage,
|
||||
offset: (currentPage - 1) * itemsPerPage,
|
||||
});
|
||||
setProducts(data.products);
|
||||
setTotalProducts(data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load products:', error);
|
||||
} finally {
|
||||
setProductsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrawl = async () => {
|
||||
setShowUpdateDropdown(false);
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await api.triggerDutchieAZCrawl(parseInt(id!, 10));
|
||||
alert('Crawl started! Refresh the page in a few minutes to see updated data.');
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger crawl:', error);
|
||||
alert('Failed to start crawl. Please try again.');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalProducts / itemsPerPage);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading store...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Store not found</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const { dispensary, brands, categories, lastCrawl } = summary;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/dutchie-az')}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Dutchie AZ Stores
|
||||
</button>
|
||||
|
||||
{/* Update Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUpdateDropdown(!showUpdateDropdown)}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
{isUpdating ? 'Crawling...' : 'Crawl Now'}
|
||||
{!isUpdating && <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{showUpdateDropdown && !isUpdating && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<button
|
||||
onClick={handleCrawl}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Start Full Crawl
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store Header */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Building2 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{dispensary.dba_name || dispensary.name}
|
||||
</h1>
|
||||
{dispensary.company_name && (
|
||||
<p className="text-sm text-gray-600 mt-1">{dispensary.company_name}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Platform ID: {dispensary.platform_dispensary_id || 'Not resolved'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div>
|
||||
<span className="font-medium">Last Crawl:</span>
|
||||
<span className="ml-2">
|
||||
{lastCrawl?.completed_at
|
||||
? new Date(lastCrawl.completed_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'Never'}
|
||||
</span>
|
||||
{lastCrawl?.status && (
|
||||
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
||||
lastCrawl.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
lastCrawl.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{lastCrawl.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{dispensary.address && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>
|
||||
{dispensary.address}, {dispensary.city}, {dispensary.state} {dispensary.zip}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dispensary.phone && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>{dispensary.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{dispensary.website && (
|
||||
<a
|
||||
href={dispensary.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Metrics */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('products');
|
||||
setStockFilter('');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'products' && !stockFilter ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-50 rounded-lg">
|
||||
<Package className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Products</p>
|
||||
<p className="text-xl font-bold text-gray-900">{summary.totalProducts}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('products');
|
||||
setStockFilter('in_stock');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'in_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">In Stock</p>
|
||||
<p className="text-xl font-bold text-gray-900">{summary.inStockCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('products');
|
||||
setStockFilter('out_of_stock');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'out_of_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-50 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Out of Stock</p>
|
||||
<p className="text-xl font-bold text-gray-900">{summary.outOfStockCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('brands')}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'brands' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 rounded-lg">
|
||||
<Tag className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Brands</p>
|
||||
<p className="text-xl font-bold text-gray-900">{summary.brandCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'categories' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Categories</p>
|
||||
<p className="text-xl font-bold text-gray-900">{summary.categoryCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Tabs */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex gap-4 px-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('products');
|
||||
setStockFilter('');
|
||||
}}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'products'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Products ({summary.totalProducts})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('brands')}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'brands'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Brands ({summary.brandCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'categories'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Categories ({summary.categoryCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'products' && (
|
||||
<div className="space-y-4">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products by name or brand..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input input-bordered input-sm flex-1"
|
||||
/>
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value)}
|
||||
className="select select-bordered select-sm"
|
||||
>
|
||||
<option value="">All Stock</option>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
{(searchQuery || stockFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setStockFilter('');
|
||||
}}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<div className="text-sm text-gray-600">
|
||||
{totalProducts} products
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading products...</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-500">No products found</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto -mx-6 px-6">
|
||||
<table className="table table-xs table-zebra table-pin-rows w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Product Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Type</th>
|
||||
<th className="text-right">Price</th>
|
||||
<th className="text-center">THC %</th>
|
||||
<th className="text-center">Stock</th>
|
||||
<th className="text-center">Qty</th>
|
||||
<th>Last Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td className="whitespace-nowrap">
|
||||
{product.image_url ? (
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="w-12 h-12 object-cover rounded"
|
||||
onError={(e) => e.currentTarget.style.display = 'none'}
|
||||
/>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="font-medium max-w-[200px]">
|
||||
<div className="line-clamp-2" title={product.name}>{product.name}</div>
|
||||
</td>
|
||||
<td className="max-w-[120px]">
|
||||
<div className="line-clamp-2" title={product.brand || '-'}>{product.brand || '-'}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
<span className="badge badge-ghost badge-sm">{product.type || '-'}</span>
|
||||
{product.subcategory && (
|
||||
<span className="badge badge-ghost badge-sm ml-1">{product.subcategory}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right font-semibold whitespace-nowrap">
|
||||
{product.sale_price ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-error">${product.sale_price}</span>
|
||||
<span className="text-gray-400 line-through text-xs">${product.regular_price}</span>
|
||||
</div>
|
||||
) : product.regular_price ? (
|
||||
`$${product.regular_price}`
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.thc_percentage ? (
|
||||
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.stock_status === 'in_stock' ? (
|
||||
<span className="badge badge-success badge-sm">In Stock</span>
|
||||
) : product.stock_status === 'out_of_stock' ? (
|
||||
<span className="badge badge-error badge-sm">Out</span>
|
||||
) : (
|
||||
<span className="badge badge-warning badge-sm">Unknown</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.total_quantity != null ? product.total_quantity : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-xs text-gray-500">
|
||||
{product.updated_at ? formatDate(product.updated_at) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-sm btn-outline"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`btn btn-sm ${
|
||||
currentPage === page ? 'btn-primary' : 'btn-outline'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-sm btn-outline"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'brands' && (
|
||||
<div className="space-y-4">
|
||||
{brands.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-500">No brands found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{brands.map((brand: any) => (
|
||||
<button
|
||||
key={brand.brand_name}
|
||||
onClick={() => {
|
||||
setActiveTab('products');
|
||||
setSearchQuery(brand.brand_name);
|
||||
setStockFilter('');
|
||||
}}
|
||||
className="border border-gray-200 rounded-lg p-4 text-center hover:border-blue-300 hover:shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<p className="font-medium text-gray-900 line-clamp-2">{brand.brand_name}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{brand.product_count} product{brand.product_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'categories' && (
|
||||
<div className="space-y-4">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-500">No categories found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{categories.map((cat: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-200 rounded-lg p-4 text-center"
|
||||
>
|
||||
<p className="font-medium text-gray-900">{cat.type}</p>
|
||||
{cat.subcategory && (
|
||||
<p className="text-sm text-gray-600">{cat.subcategory}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{cat.product_count} product{cat.product_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user