Seven call sites used fetch() directly instead of the api client in src/api/client.js, so they sent no Authorization header. Four of them hit endpoints the backend guards with a permission, and every one of those returned 401 for any signed-in user: AdminPage /api/admin/training/upload-dataset (upload_train_test) AdminPage /api/admin/training/allocate-discounts (allocate_discounts) UserPage /api/user/products/add (add_product) UserPage /api/user/products/upload-file (upload_batch_products) The remaining three hit public GETs and worked, but bypassed VITE_API_BASE_URL and the central 401 handler just the same. Route all seven through the client and add the endpoints it was missing. Multipart uploads still need a raw fetch, because the browser has to set its own Content-Type to carry the boundary, so that is now one upload() helper that attaches the auth header and routes 401s to the logout handler rather than three copies that did neither. handleAllocateDiscounts only acted on res.ok, so a 401 or 403 left the panel looking idle with no indication that nothing had been allocated. It surfaces the error now. Take VITE_API_BASE_URL as a Docker build arg. The app is served from catalogue.nearle.ai.in and the API from mcp.catalogue.nearle.ai.in, and Vite inlines env vars at build time, so setting this on the running container does nothing - it has to reach npm run build. Defaults to empty, which keeps requests relative for the same-origin nginx proxy. Also revoke the object URL after the sample-CSV download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
973 lines
50 KiB
JavaScript
973 lines
50 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import { api } from '../api/client';
|
||
import { NavigationHeader } from '../components/NavigationHeader';
|
||
import { ProductCard } from '../components/ProductCard';
|
||
import { ProductModal } from '../components/ProductModal';
|
||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||
import {
|
||
PlusCircle, Upload, CheckCircle2, Database, Sparkles,
|
||
RefreshCw, Search, FileSpreadsheet, Download, ShoppingBag,
|
||
Store, LayoutGrid, Filter, ArrowRight
|
||
} from 'lucide-react';
|
||
|
||
const STORES_SAMPLE = [
|
||
{ store_id: 'STORE-A', store_name: 'Store-A - Gandhipuram', city: 'Coimbatore', tier: 'Premium' },
|
||
{ store_id: 'STORE-B', store_name: 'Store-B - RS Puram', city: 'Coimbatore', tier: 'Standard' },
|
||
{ store_id: 'STORE-C', store_name: 'Store-C - Peelamedu', city: 'Coimbatore', tier: 'Budget' },
|
||
{ store_id: 'STORE-D', store_name: 'Store-D - Podanur', city: 'Coimbatore', tier: 'Standard' },
|
||
{ store_id: 'STORE-E', store_name: 'Store-E - Ukkadam', city: 'Coimbatore', tier: 'Budget' },
|
||
];
|
||
|
||
// Replaced all 5.imimg.com (blocked by CORS) with high-quality Unsplash photography
|
||
const TITLE_KEYWORD_IMAGES = [
|
||
// Dairy Specific Product Imagery
|
||
{ keywords: ['butter'], img: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?w=500&q=80' },
|
||
{ keywords: ['cheese', 'slice', 'block'], img: 'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?w=500&q=80' },
|
||
{ keywords: ['paneer', 'tofu'], img: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc0?w=500&q=80' },
|
||
{ keywords: ['ghee', 'oil'], img: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80' },
|
||
{ keywords: ['basundi', 'mithai', 'rabri', 'kheer', 'shrikhand', 'gulab'], img: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80' },
|
||
{ keywords: ['milk', 'taaza', 'gold milk', 'toned'], img: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80' },
|
||
{ keywords: ['lassi', 'kool', 'buttermilk', 'masti', 'curd', 'dahi', 'yogurt'], img: 'https://images.unsplash.com/photo-1572490122747-3968b75bb69c?w=500&q=80' },
|
||
{ keywords: ['ice cream', 'frost', 'kulfi', 'sundae', 'cone'], img: 'https://images.unsplash.com/photo-1570197788417-0e82375c9371?w=500&q=80' },
|
||
|
||
// Chocolates & Confectionery
|
||
{ keywords: ['bournvita', 'malt', 'cocoa powder'], img: 'https://images.unsplash.com/photo-1511920170033-f8396924c348?w=500&q=80' },
|
||
{ keywords: ['silk', 'dairy milk', 'chocolate', 'cocoa', 'dark chocolate', 'kitkat'], img: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80' },
|
||
|
||
// Dates & Health Foods
|
||
{ keywords: ['date powder', 'dates powder'], img: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445' },
|
||
{ keywords: ['syrup', 'honey'], img: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80' },
|
||
{ keywords: ['date', 'dates'], img: 'https://liondates.com/cdn/shop/files/dates.png?v=1773397173&width=1000' },
|
||
|
||
// Flours, Spices & Grains
|
||
{ keywords: ['maida', 'atta', 'flour', 'wheat', 'rava', 'soji'], img: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80' },
|
||
{ keywords: ['salt'], img: 'https://images.unsplash.com/photo-1610832958506-aa56368176cf?w=500&q=80' },
|
||
{ keywords: ['dal', 'pulses', 'lentil', 'chana'], img: 'https://images.unsplash.com/photo-1515543237350-b3eea1ec8082?w=500&q=80' },
|
||
|
||
// Noodles & Snacks
|
||
{ keywords: ['maggi', 'noodle', 'pasta'], img: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80' },
|
||
{ keywords: ['lays', 'chips', 'namkeen', 'snack', 'kurkure'], img: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80' },
|
||
{ keywords: ['biscuit', 'cookie', 'rusk', 'oreo'], img: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?w=500&q=80' },
|
||
|
||
// Beverages
|
||
{ keywords: ['tea', 'chai'], img: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80' },
|
||
{ keywords: ['coffee', 'nescafe'], img: 'https://images.unsplash.com/photo-1559056199-641a0ac8b55e?w=500&q=80' },
|
||
{ keywords: ['coca', 'coke', 'pepsi', 'sprite', 'drink', 'beverage', 'juice'], img: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80' },
|
||
];
|
||
|
||
const BRAND_FALLBACK_IMAGES = {
|
||
amul: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80',
|
||
cadbury: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80',
|
||
lion: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445',
|
||
naga: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80',
|
||
nestle: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80',
|
||
tata: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80',
|
||
dabur: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80',
|
||
godrej: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80',
|
||
pepsico: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80',
|
||
coca: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80',
|
||
};
|
||
|
||
const CATEGORY_FALLBACK_IMAGES = {
|
||
dairy: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80',
|
||
chocolate: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80',
|
||
dessert: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80',
|
||
snack: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80',
|
||
beverage: 'https://images.unsplash.com/photo-1544145945-f90425340c7e?w=500&q=80',
|
||
flour: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80',
|
||
health: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80',
|
||
};
|
||
|
||
function resolveProductImageUrl(sp) {
|
||
// If we have a valid external URL that isn't from imimg (since imimg blocks CORS), use it
|
||
if (sp.image_url && typeof sp.image_url === 'string' && sp.image_url.trim() && sp.image_url.startsWith('http') && !sp.image_url.includes('imimg.com')) {
|
||
return sp.image_url;
|
||
}
|
||
if (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 && sp.image_urls[0] && sp.image_urls[0].startsWith('http') && !sp.image_urls[0].includes('imimg.com')) {
|
||
return sp.image_urls[0];
|
||
}
|
||
|
||
// 1. Title Keyword Specific Matching (highest priority)
|
||
const titleLower = String(sp.title || sp.product_name || '').toLowerCase();
|
||
for (const item of TITLE_KEYWORD_IMAGES) {
|
||
if (item.keywords.some((kw) => titleLower.includes(kw))) {
|
||
return item.img;
|
||
}
|
||
}
|
||
|
||
// 2. Brand Fallback Matching
|
||
const brandLower = String(sp.brand || '').toLowerCase();
|
||
for (const [bKey, img] of Object.entries(BRAND_FALLBACK_IMAGES)) {
|
||
if (brandLower.includes(bKey)) return img;
|
||
}
|
||
|
||
// 3. Category Fallback Matching
|
||
const catLower = String(sp.category || '').toLowerCase();
|
||
for (const [cKey, img] of Object.entries(CATEGORY_FALLBACK_IMAGES)) {
|
||
if (catLower.includes(cKey)) return img;
|
||
}
|
||
|
||
return 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=500&q=80';
|
||
}
|
||
|
||
export function UserPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
// Derived active tab directly from URL query param to eliminate state sync conflicts
|
||
const activeTab = searchParams.get('tab') === 'cards' ? 'cards' : 'upload';
|
||
|
||
const [entryMode, setEntryMode] = useState('single'); // 'single' or 'batch'
|
||
|
||
// Single Form State
|
||
const [brand, setBrand] = useState('Lion Dates');
|
||
const [productName, setProductName] = useState('Lion Dates 450g');
|
||
const [category, setCategory] = useState('Health Foods');
|
||
const [priceRange, setPriceRange] = useState('₹160-220');
|
||
const [finalPrice, setFinalPrice] = useState('185.00');
|
||
const [barcode, setBarcode] = useState('20086040');
|
||
const [hsnCode, setHsnCode] = useState('2008');
|
||
const [description, setDescription] = useState('');
|
||
const [imageUrl, setImageUrl] = useState('');
|
||
|
||
const [submittingSingle, setSubmittingSingle] = useState(false);
|
||
const [singleResult, setSingleResult] = useState(null);
|
||
const [singleError, setSingleError] = useState('');
|
||
|
||
// Batch Upload State
|
||
const [batchFile, setBatchFile] = useState(null);
|
||
const [uploadingBatch, setUploadingBatch] = useState(false);
|
||
const [batchResult, setBatchResult] = useState(null);
|
||
const [batchError, setBatchError] = useState('');
|
||
|
||
// Catalog Inspection & Modal
|
||
const [brandSearch, setBrandSearch] = useState('Lion Dates');
|
||
const [brandProducts, setBrandProducts] = useState([]);
|
||
const [loadingBrand, setLoadingBrand] = useState(false);
|
||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||
|
||
// Cards Tab State (Store-A + Uploaded Cards)
|
||
const [selectedStoreId, setSelectedStoreId] = useState('STORE-A');
|
||
const [storeProductsRaw, setStoreProductsRaw] = useState([]);
|
||
const [uploadedCards, setUploadedCards] = useState([]);
|
||
const [loadingCards, setLoadingCards] = useState(false);
|
||
const [selectedBrandFilter, setSelectedBrandFilter] = useState('All');
|
||
const [cardSearchQuery, setCardSearchQuery] = useState('');
|
||
|
||
// Pure tab switcher updating searchParams
|
||
const switchTab = (tab) => {
|
||
if (tab === 'cards') {
|
||
setSearchParams({ tab: 'cards' });
|
||
} else {
|
||
setSearchParams({});
|
||
}
|
||
};
|
||
|
||
// Fetch store products for Cards Tab with fail-safe array checking
|
||
const fetchStoreProductsCards = async (storeId) => {
|
||
setLoadingCards(true);
|
||
try {
|
||
const data = await api.getStoreProducts(storeId, { limit: 100 });
|
||
const itemsList = Array.isArray(data)
|
||
? data
|
||
: Array.isArray(data?.products)
|
||
? data.products
|
||
: [];
|
||
setStoreProductsRaw(itemsList);
|
||
} catch (e) {
|
||
console.warn('Failed to load store products for cards:', e);
|
||
setStoreProductsRaw([]);
|
||
} finally {
|
||
setLoadingCards(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchStoreProductsCards(selectedStoreId);
|
||
}, [selectedStoreId]);
|
||
|
||
const fetchBrandCatalog = async (bName) => {
|
||
if (!bName) return;
|
||
setLoadingBrand(true);
|
||
try {
|
||
const data = await api.getBrandProducts(bName, { limit: 50 });
|
||
const itemsList = Array.isArray(data)
|
||
? data
|
||
: Array.isArray(data?.products)
|
||
? data.products
|
||
: [];
|
||
setBrandProducts(itemsList);
|
||
} catch (e) {
|
||
console.warn('Failed to fetch brand products', e);
|
||
setBrandProducts([]);
|
||
} finally {
|
||
setLoadingBrand(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchBrandCatalog(brandSearch);
|
||
}, [brandSearch]);
|
||
|
||
const handlePresetLionDates = () => {
|
||
setEntryMode('single');
|
||
switchTab('upload');
|
||
setBrand('Lion Dates');
|
||
setProductName('Lion Dates 450g');
|
||
setCategory('Health Foods');
|
||
setPriceRange('₹160-220');
|
||
setFinalPrice('185.00');
|
||
setBarcode('20086040');
|
||
setHsnCode('2008');
|
||
setDescription('Premium quality Lion Dates 450g pack enriched with natural nutrients and vitamins.');
|
||
setImageUrl('https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445');
|
||
};
|
||
|
||
const handleSingleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setSingleError('');
|
||
setSingleResult(null);
|
||
setSubmittingSingle(true);
|
||
|
||
try {
|
||
const payload = {
|
||
brand,
|
||
product_name: productName,
|
||
category,
|
||
price_range: priceRange,
|
||
final_selling_price: finalPrice ? parseFloat(finalPrice) : null,
|
||
barcode: barcode || null,
|
||
hsn_code: hsnCode || null,
|
||
description: description || undefined,
|
||
image_url: imageUrl || undefined,
|
||
};
|
||
|
||
const data = await api.addUserProduct(payload);
|
||
setSingleResult(data);
|
||
setSubmittingSingle(false);
|
||
|
||
// Add to uploaded cards
|
||
if (data.product) {
|
||
const newCard = {
|
||
image_id: String(data.product.image_id || `user_${Date.now()}`),
|
||
product_name: String(data.product.product_name || productName),
|
||
brand: String(data.product.brand || brand),
|
||
category: String(data.product.category || category),
|
||
price_range: data.product.price_range || `₹${data.product.final_selling_price || 100}`,
|
||
final_selling_price: data.product.final_selling_price,
|
||
selling_price: data.product.final_selling_price,
|
||
barcode: data.product.barcode ? String(data.product.barcode) : null,
|
||
hsn_code: data.product.hsn_code ? String(data.product.hsn_code) : null,
|
||
image_url: resolveProductImageUrl(data.product),
|
||
image_urls: data.product.image_urls || (imageUrl ? [imageUrl] : []),
|
||
nutrients: data.product.nutrients || [
|
||
{ name: 'Protein', amount: '8.5g' },
|
||
{ name: 'Calcium', amount: '120mg' },
|
||
{ name: 'Iron', amount: '3.2mg' },
|
||
],
|
||
highlights: ['User Uploaded Entry', 'Dual Persistence: Synced DB & JSON'],
|
||
providers: ['User Direct Entry', 'Store Inventory'],
|
||
is_user_uploaded: true,
|
||
};
|
||
setUploadedCards((prev) => [newCard, ...prev]);
|
||
}
|
||
|
||
// Refresh catalog list for current brand
|
||
setBrandSearch(brand);
|
||
fetchBrandCatalog(brand);
|
||
} catch (err) {
|
||
setSubmittingSingle(false);
|
||
setSingleError(err.message || 'An error occurred while adding the product.');
|
||
}
|
||
};
|
||
|
||
const handleBatchSubmit = async (e) => {
|
||
e.preventDefault();
|
||
if (!batchFile) return;
|
||
setBatchError('');
|
||
setBatchResult(null);
|
||
setUploadingBatch(true);
|
||
|
||
try {
|
||
const data = await api.uploadUserProductsFile(batchFile);
|
||
setBatchResult(data);
|
||
setUploadingBatch(false);
|
||
|
||
if (data.added_products && Array.isArray(data.added_products)) {
|
||
const newBatchCards = data.added_products.map((item, idx) => ({
|
||
image_id: String(item.image_id || `batch_${Date.now()}_${idx}`),
|
||
product_name: String(item.product_name || 'Batch Product'),
|
||
brand: String(item.brand || 'User Brand'),
|
||
category: String(item.category || 'Batch Upload'),
|
||
price_range: item.price_range || `₹${item.final_selling_price || 100}`,
|
||
final_selling_price: item.final_selling_price,
|
||
selling_price: item.final_selling_price,
|
||
barcode: item.barcode ? String(item.barcode) : null,
|
||
hsn_code: item.hsn_code ? String(item.hsn_code) : null,
|
||
image_url: resolveProductImageUrl(item),
|
||
nutrients: [
|
||
{ name: 'Protein', amount: '7.5g' },
|
||
{ name: 'Calcium', amount: '110mg' },
|
||
],
|
||
highlights: ['Batch CSV/Excel Upload', 'Dual Persistence: Synced DB & JSON'],
|
||
providers: ['Batch File Upload', 'Store Inventory'],
|
||
is_user_uploaded: true,
|
||
}));
|
||
setUploadedCards((prev) => [...newBatchCards, ...prev]);
|
||
}
|
||
} catch (err) {
|
||
setUploadingBatch(false);
|
||
setBatchError(err.message || 'Error uploading file.');
|
||
}
|
||
};
|
||
|
||
const downloadSampleCSV = () => {
|
||
const csvContent =
|
||
'Brand Name,Product Name / Variant,Category,Price Range,Final Price (₹),Barcode (GTIN/EAN),HSN Code,Custom Image URL,Description\n' +
|
||
'Lion Dates,Lion Dates 450g,Health Foods,₹160-220,185.00,20086040,2008,https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445,Premium Quality Lion Dates 450g\n' +
|
||
'Naga,Naga Maida 2kg,Flour & Grains,₹90-110,98.00,8906012345001,1101,,High grade refined wheat flour maida\n';
|
||
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', 'sample_product_batch_template.csv');
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// Convert Store products into rich Card objects with 100% type-safe conversions and title-specific images
|
||
const allCardProducts = React.useMemo(() => {
|
||
const rawList = Array.isArray(storeProductsRaw) ? storeProductsRaw : [];
|
||
|
||
const formattedStoreCards = rawList.map((sp) => {
|
||
if (!sp || typeof sp !== 'object') return null;
|
||
|
||
const brandName = String(sp.brand || 'Store Brand');
|
||
const prodName = String(sp.title || sp.product_name || `${brandName} Product`);
|
||
const isLionDates = brandName.toLowerCase().includes('lion');
|
||
const rawImageId = sp.image_id ? String(sp.image_id) : '';
|
||
|
||
// Use real image_url/image_urls from database (same as admin/home page)
|
||
// Only fall back to Unsplash keyword matcher when API provides no image
|
||
const realImageUrl = sp.image_url || (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 ? sp.image_urls[0] : null);
|
||
const displayImageUrl = realImageUrl || resolveProductImageUrl({ ...sp, product_name: prodName });
|
||
|
||
return {
|
||
image_id: rawImageId || `${brandName}_${prodName}`,
|
||
product_name: prodName,
|
||
brand: brandName,
|
||
category: String(sp.category || 'Store Product'),
|
||
price_range: `₹${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 0.95)}-${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 1.1)}`,
|
||
final_selling_price: sp.selling_price || sp.mrp,
|
||
selling_price: sp.selling_price || sp.mrp,
|
||
barcode: isLionDates ? '20086045' : (rawImageId ? `8906${rawImageId.substring(0, 8)}` : null),
|
||
hsn_code: isLionDates ? '2008' : (sp.category === 'Dairy' ? '0402' : '1905'),
|
||
image_url: displayImageUrl,
|
||
image_urls: Array.isArray(sp.image_urls) ? sp.image_urls : (realImageUrl ? [realImageUrl] : []),
|
||
fssai_license: sp.fssai_license || null,
|
||
nutrients: sp.nutrients || [
|
||
{ name: 'Protein', amount: '8.2g' },
|
||
{ name: 'Calcium', amount: '140mg' },
|
||
{ name: 'Vitamin D', amount: '3.0mcg' },
|
||
],
|
||
highlights: [
|
||
`Store-A Inventory: ${sp.available_stock || 50} units`,
|
||
`Stock Status: ${sp.stock_status || 'In Stock'}`,
|
||
`Gross Profit Margin: ${sp.gross_profit_pct || 20}%`
|
||
],
|
||
providers: ['Store-A Gandhipuram', 'BigBasket', 'Flipkart'],
|
||
is_user_uploaded: false,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
return [...(uploadedCards || []), ...formattedStoreCards];
|
||
}, [storeProductsRaw, uploadedCards]);
|
||
|
||
// Filter Card products by Search & Brand safely
|
||
const filteredCards = React.useMemo(() => {
|
||
if (!Array.isArray(allCardProducts)) return [];
|
||
return allCardProducts.filter((card) => {
|
||
if (!card) return false;
|
||
const cardBrand = String(card.brand || '');
|
||
const matchesBrand =
|
||
selectedBrandFilter === 'All' ||
|
||
cardBrand.toLowerCase().includes(selectedBrandFilter.toLowerCase());
|
||
|
||
const q = String(cardSearchQuery || '').toLowerCase().trim();
|
||
const cardTitle = String(card.product_name || '').toLowerCase();
|
||
const cardBarcode = card.barcode ? String(card.barcode) : '';
|
||
const cardHsn = card.hsn_code ? String(card.hsn_code) : '';
|
||
|
||
const matchesQuery =
|
||
!q ||
|
||
cardTitle.includes(q) ||
|
||
cardBrand.toLowerCase().includes(q) ||
|
||
cardBarcode.includes(q) ||
|
||
cardHsn.includes(q);
|
||
|
||
return matchesBrand && matchesQuery;
|
||
});
|
||
}, [allCardProducts, selectedBrandFilter, cardSearchQuery]);
|
||
|
||
const uniqueBrandsList = React.useMemo(() => {
|
||
const setB = new Set(['All']);
|
||
if (Array.isArray(allCardProducts)) {
|
||
allCardProducts.forEach((c) => {
|
||
if (c && c.brand) setB.add(String(c.brand));
|
||
});
|
||
}
|
||
return Array.from(setB);
|
||
}, [allCardProducts]);
|
||
|
||
return (
|
||
<ErrorBoundary>
|
||
<div className="min-h-screen bg-paper-100 font-sans text-ink-950">
|
||
<NavigationHeader
|
||
title="User Product Workspace"
|
||
subtitle="Upload new product records & view store catalog product cards"
|
||
icon={PlusCircle}
|
||
/>
|
||
|
||
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||
{/* Top View Selector Tabs: [ Add / Upload Records ] vs [ View Product Cards ] */}
|
||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-slate-900 text-white rounded-2xl p-4 shadow-lg border border-slate-800">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||
<LayoutGrid className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-bold font-display">User Product Management</h2>
|
||
<p className="text-xs text-slate-300">
|
||
Single/Batch Product Uploads with Dual Persistence & Store Product Cards
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 bg-slate-950 p-1.5 rounded-xl border border-slate-800">
|
||
<button
|
||
type="button"
|
||
onClick={() => switchTab('upload')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||
activeTab === 'upload'
|
||
? 'bg-blue-600 text-white shadow-md'
|
||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||
}`}
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> ➕ Add / Upload Records
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => switchTab('cards')}
|
||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||
activeTab === 'cards'
|
||
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
|
||
}`}
|
||
>
|
||
<ShoppingBag className="h-4 w-4" /> 🎴 Product Catalog Cards ({allCardProducts.length})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* VIEW 1: PRODUCT ENTRY & BATCH UPLOAD */}
|
||
{/* ========================================================================= */}
|
||
{activeTab === 'upload' && (
|
||
<div className="space-y-6">
|
||
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-r from-blue-950 to-slate-900 text-white p-6 shadow-md flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/20 px-3 py-1 text-xs font-bold text-blue-300 border border-blue-400/30 mb-2">
|
||
<Database className="h-3.5 w-3.5" /> Database & Seed JSON Auto-Sync
|
||
</span>
|
||
<h3 className="text-xl font-bold font-display">Upload & Add New Product Records</h3>
|
||
<p className="text-sm text-blue-200 mt-1 max-w-2xl">
|
||
Add new product variants (e.g. <strong>"Lion Dates 450g"</strong>) via single form or batch CSV/Excel upload. Product details & images sync directly into PostgreSQL database tables and JSON seed catalog files.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={downloadSampleCSV}
|
||
className="flex items-center gap-1.5 rounded-xl bg-white/10 hover:bg-white/20 border border-white/20 text-white font-semibold px-3.5 py-2 text-xs transition cursor-pointer"
|
||
>
|
||
<Download className="h-3.5 w-3.5" /> Download Sample CSV
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handlePresetLionDates}
|
||
className="flex items-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold px-4 py-2 text-xs shadow-md transition active:scale-95 cursor-pointer"
|
||
>
|
||
<Sparkles className="h-4 w-4" /> Preset: Add Lion Dates 450g
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Left Form: Single vs Batch Upload Tabs */}
|
||
<div className="lg:col-span-7 rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm">
|
||
<div className="flex items-center justify-between border-b border-ink-900/10 pb-4 mb-6">
|
||
<div className="flex items-center gap-2 p-1 rounded-xl bg-slate-200/80">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEntryMode('single')}
|
||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
|
||
entryMode === 'single'
|
||
? 'bg-blue-600 text-white shadow-xs'
|
||
: 'text-slate-600 hover:text-ink-950'
|
||
}`}
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> Single Entry Form
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setEntryMode('batch')}
|
||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
|
||
entryMode === 'batch'
|
||
? 'bg-blue-600 text-white shadow-xs'
|
||
: 'text-slate-600 hover:text-ink-950'
|
||
}`}
|
||
>
|
||
<FileSpreadsheet className="h-4 w-4" /> Batch CSV / Excel Upload
|
||
</button>
|
||
</div>
|
||
|
||
<span className="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2.5 py-1 rounded-full">
|
||
Auto-Enrich & Dual Persistence
|
||
</span>
|
||
</div>
|
||
|
||
{/* SINGLE PRODUCT FORM */}
|
||
{entryMode === 'single' && (
|
||
<div>
|
||
{singleError && (
|
||
<div className="mb-4 rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||
{singleError}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleSingleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Brand Name *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={brand}
|
||
onChange={(e) => setBrand(e.target.value)}
|
||
placeholder="e.g. Lion Dates"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Product Name / Variant *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={productName}
|
||
onChange={(e) => setProductName(e.target.value)}
|
||
placeholder="e.g. Lion Dates 450g"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Category</label>
|
||
<input
|
||
type="text"
|
||
value={category}
|
||
onChange={(e) => setCategory(e.target.value)}
|
||
placeholder="e.g. Health Foods"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Price Range</label>
|
||
<input
|
||
type="text"
|
||
value={priceRange}
|
||
onChange={(e) => setPriceRange(e.target.value)}
|
||
placeholder="e.g. ₹160-220"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Final Price (₹)</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={finalPrice}
|
||
onChange={(e) => setFinalPrice(e.target.value)}
|
||
placeholder="e.g. 185.00"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Barcode (GTIN/EAN)</label>
|
||
<input
|
||
type="text"
|
||
value={barcode}
|
||
onChange={(e) => setBarcode(e.target.value)}
|
||
placeholder="e.g. 20086040"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">HSN Code</label>
|
||
<input
|
||
type="text"
|
||
value={hsnCode}
|
||
onChange={(e) => setHsnCode(e.target.value)}
|
||
placeholder="e.g. 2008"
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Custom Image URL (Optional)</label>
|
||
<input
|
||
type="url"
|
||
value={imageUrl}
|
||
onChange={(e) => setImageUrl(e.target.value)}
|
||
placeholder="https://..."
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-ink-800 mb-1">Description (Optional)</label>
|
||
<textarea
|
||
rows={3}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Enter detailed description or leave empty for AI auto-description..."
|
||
className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={submittingSingle}
|
||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 text-xs font-bold shadow-md transition active:scale-98 disabled:opacity-50 cursor-pointer"
|
||
>
|
||
{submittingSingle ? <RefreshCw className="h-4 w-4 animate-spin" /> : <PlusCircle className="h-4 w-4" />}
|
||
{submittingSingle ? 'Syncing to Database & JSON Catalog...' : 'Save Record to DB Table & JSON File'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* BATCH CSV/EXCEL UPLOAD */}
|
||
{entryMode === 'batch' && (
|
||
<div>
|
||
{batchError && (
|
||
<div className="mb-4 rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||
{batchError}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleBatchSubmit} className="space-y-4">
|
||
<div className="border-2 border-dashed border-blue-500/30 rounded-2xl bg-blue-50/50 p-6 text-center hover:bg-blue-50 transition">
|
||
<FileSpreadsheet className="h-10 w-10 text-blue-600 mx-auto mb-2" />
|
||
<h4 className="font-bold text-sm text-ink-950">Upload Product Spreadsheet (.csv, .xlsx, .xls)</h4>
|
||
<p className="text-xs text-slate-500 mt-1 mb-4">
|
||
Supports multiple rows containing Brand Name, Product Name, Category, Price, Barcode, HSN Code.
|
||
</p>
|
||
|
||
<input
|
||
type="file"
|
||
accept=".csv, .xlsx, .xls"
|
||
onChange={(e) => setBatchFile(e.target.files?.[0] || null)}
|
||
className="hidden"
|
||
id="batch-file-input"
|
||
/>
|
||
<label
|
||
htmlFor="batch-file-input"
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs cursor-pointer shadow-sm transition"
|
||
>
|
||
<Upload className="h-4 w-4" /> Select CSV or Excel File
|
||
</label>
|
||
|
||
{batchFile && (
|
||
<div className="mt-4 p-3 rounded-xl bg-white border border-blue-200 inline-flex items-center gap-2 text-xs font-bold text-ink-950">
|
||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||
{batchFile.name} ({(batchFile.size / 1024).toFixed(1)} KB)
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!batchFile || uploadingBatch}
|
||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 text-xs font-bold shadow-md transition active:scale-98 disabled:opacity-50 cursor-pointer"
|
||
>
|
||
{uploadingBatch ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||
{uploadingBatch ? 'Processing & Enriching Batch Products...' : 'Upload & Sync Batch Records'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Panel: Results & Quick Brand Inspection */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
{singleResult && (
|
||
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-50/70 p-6 shadow-sm space-y-3">
|
||
<div className="flex items-center gap-2 text-emerald-700">
|
||
<CheckCircle2 className="h-5 w-5" />
|
||
<h4 className="font-bold text-sm">Product Record Created & Synced</h4>
|
||
</div>
|
||
<p className="text-xs text-slate-700">{singleResult.message}</p>
|
||
<div className="p-3 rounded-xl bg-white border border-emerald-200 text-xs space-y-1">
|
||
<p className="font-bold text-ink-950">{singleResult.product.product_name}</p>
|
||
<p className="text-[11px] text-slate-500 font-mono">Brand: {singleResult.product.brand}</p>
|
||
{singleResult.product.barcode && (
|
||
<p className="text-[11px] text-slate-600 font-mono">Barcode: {singleResult.product.barcode}</p>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => switchTab('cards')}
|
||
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-emerald-600 text-white font-bold text-xs shadow-sm hover:bg-emerald-700 transition cursor-pointer"
|
||
>
|
||
<ShoppingBag className="h-4 w-4" /> View as Product Card <ArrowRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{batchResult && (
|
||
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-50/70 p-6 shadow-sm space-y-3">
|
||
<div className="flex items-center gap-2 text-emerald-700">
|
||
<CheckCircle2 className="h-5 w-5" />
|
||
<h4 className="font-bold text-sm">Batch Products Processed</h4>
|
||
</div>
|
||
<p className="text-xs text-slate-700">{batchResult.message}</p>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => switchTab('cards')}
|
||
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-emerald-600 text-white font-bold text-xs shadow-sm hover:bg-emerald-700 transition cursor-pointer"
|
||
>
|
||
<ShoppingBag className="h-4 w-4" /> View Batch Items as Product Cards <ArrowRight className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!singleResult && !batchResult && (
|
||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm text-center">
|
||
<Database className="h-10 w-10 text-blue-500 mx-auto mb-3" />
|
||
<h4 className="font-bold text-sm text-ink-950">Dual Storage Persistence</h4>
|
||
<p className="text-xs text-slate-500 mt-1">
|
||
Every product submitted (single or batch CSV/Excel) is automatically parsed, assigned high-resolution images, and synced into PostgreSQL brand tables and JSON catalog seed files.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick Brand Catalog Search */}
|
||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-bold text-sm text-ink-950 flex items-center gap-2">
|
||
<Search className="h-4 w-4 text-amber-500" /> Inspect Brand Products
|
||
</h4>
|
||
<span className="text-[11px] font-mono text-slate-400">{brandProducts.length} items</span>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={brandSearch}
|
||
onChange={(e) => setBrandSearch(e.target.value)}
|
||
placeholder="Enter brand to inspect (e.g. Lion Dates)"
|
||
className="flex-1 rounded-xl border border-ink-900/15 bg-white p-2 text-xs font-medium text-ink-950 focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fetchBrandCatalog(brandSearch)}
|
||
className="px-3 py-1.5 bg-ink-900 text-white text-xs font-semibold rounded-xl hover:bg-ink-950 cursor-pointer"
|
||
>
|
||
Fetch
|
||
</button>
|
||
</div>
|
||
|
||
{loadingBrand ? (
|
||
<div className="text-center py-4 text-xs text-slate-400">Loading catalog items...</div>
|
||
) : (
|
||
<div className="max-h-64 overflow-y-auto space-y-2 pr-1 no-scrollbar">
|
||
{brandProducts.map((p, idx) => (
|
||
<div
|
||
key={idx}
|
||
onClick={() => setSelectedProduct(p)}
|
||
className="cursor-pointer flex items-center justify-between gap-2 p-2.5 rounded-xl border border-slate-200 bg-white hover:border-amber-500/60 transition shadow-xs"
|
||
>
|
||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||
{p.image_url ? (
|
||
<img src={p.image_url} alt="" className="h-9 w-9 object-contain bg-slate-50 rounded" />
|
||
) : (
|
||
<div className="h-9 w-9 rounded bg-slate-100 flex items-center justify-center text-[10px] text-slate-400">No img</div>
|
||
)}
|
||
<div className="truncate">
|
||
<p className="text-xs font-bold text-ink-950 truncate">{p.product_name}</p>
|
||
<p className="text-[10px] text-slate-400 font-mono">
|
||
{p.barcode ? `Barcode: ${p.barcode}` : p.product_sku ? `SKU: ${p.product_sku}` : 'Standard Record'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{p.final_selling_price && (
|
||
<span className="font-mono text-xs font-bold text-leaf-600 shrink-0">₹{p.final_selling_price}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================================= */}
|
||
{/* VIEW 2: PRODUCT CATALOG CARDS (STORE-A & USER UPLOADED CARDS) */}
|
||
{/* ========================================================================= */}
|
||
{activeTab === 'cards' && (
|
||
<div className="space-y-6">
|
||
{/* Header Toolbar */}
|
||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-ink-950 flex items-center gap-2">
|
||
<ShoppingBag className="h-5 w-5 text-amber-500" /> User Product Catalog Cards
|
||
</h3>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
Displaying sample store records (e.g. <strong>Store-A Gandhipuram</strong>) alongside your uploaded product records as rich cards.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Store Selector Dropdown */}
|
||
<div className="flex items-center gap-2 bg-slate-100 p-1.5 rounded-xl border border-slate-200">
|
||
<Store className="h-4 w-4 text-emerald-600 ml-2" />
|
||
<span className="text-xs font-bold text-slate-700">Sample Store:</span>
|
||
<select
|
||
value={selectedStoreId}
|
||
onChange={(e) => setSelectedStoreId(e.target.value)}
|
||
className="bg-white border border-slate-300 rounded-lg px-3 py-1 text-xs font-bold text-slate-800 focus:outline-none"
|
||
>
|
||
{STORES_SAMPLE.map((s) => (
|
||
<option key={s.store_id} value={s.store_id}>
|
||
{s.store_name} ({s.tier})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter & Search Bar */}
|
||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-ink-900/10 pt-4">
|
||
{/* Brand Filter Pills */}
|
||
<div className="flex items-center gap-1.5 overflow-x-auto max-w-full pb-1 no-scrollbar">
|
||
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1 mr-1">
|
||
<Filter className="h-3.5 w-3.5" /> Brands:
|
||
</span>
|
||
{uniqueBrandsList.slice(0, 8).map((b) => (
|
||
<button
|
||
type="button"
|
||
key={b}
|
||
onClick={() => setSelectedBrandFilter(b)}
|
||
className={`px-3 py-1 rounded-full text-xs font-bold transition-all cursor-pointer ${
|
||
selectedBrandFilter === b
|
||
? 'bg-ink-950 text-white shadow-xs scale-105'
|
||
: 'bg-slate-200/70 text-slate-700 hover:bg-slate-300'
|
||
}`}
|
||
>
|
||
{b}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Search Bar */}
|
||
<div className="relative min-w-[240px]">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={cardSearchQuery}
|
||
onChange={(e) => setCardSearchQuery(e.target.value)}
|
||
placeholder="Search by product, brand or barcode..."
|
||
className="w-full rounded-xl border border-slate-300 bg-white py-1.5 pl-8 pr-3 text-xs text-ink-950 placeholder-slate-400 focus:outline-none focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Product Cards Grid */}
|
||
{loadingCards ? (
|
||
<div className="text-center py-16 text-slate-400 text-sm font-medium flex items-center justify-center gap-2">
|
||
<RefreshCw className="h-5 w-5 animate-spin text-amber-500" /> Loading store and uploaded product cards...
|
||
</div>
|
||
) : filteredCards.length === 0 ? (
|
||
<div className="text-center py-16 rounded-2xl border border-dashed border-slate-300 bg-paper-50 p-8">
|
||
<ShoppingBag className="h-12 w-12 text-slate-300 mx-auto mb-2" />
|
||
<p className="text-sm font-bold text-slate-700">No product cards match your filter</p>
|
||
<p className="text-xs text-slate-400 mt-1">Try resetting the brand filter or search term</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setSelectedBrandFilter('All'); setCardSearchQuery(''); }}
|
||
className="mt-3 px-4 py-1.5 rounded-xl bg-slate-900 text-white text-xs font-bold cursor-pointer"
|
||
>
|
||
Reset Filters
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<span className="text-xs font-bold text-slate-500 font-mono">
|
||
Showing {filteredCards.length} product cards ({selectedStoreId} + Uploads)
|
||
</span>
|
||
{uploadedCards.length > 0 && (
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-700 border border-emerald-500/30 text-xs font-bold">
|
||
<Sparkles className="h-3 w-3" /> {uploadedCards.length} Newly Uploaded Items Added
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||
{filteredCards.map((prod, idx) => (
|
||
<div key={prod.image_id || idx} className="relative group">
|
||
{prod.is_user_uploaded && (
|
||
<div className="absolute top-2 left-2 z-10 bg-emerald-600 text-white font-extrabold text-[10px] px-2 py-0.5 rounded-full shadow-md">
|
||
NEWLY UPLOADED
|
||
</div>
|
||
)}
|
||
<ProductCard product={prod} onClick={() => setSelectedProduct(prod)} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* Interactive Product Details Modal */}
|
||
{selectedProduct && (
|
||
<ProductModal product={selectedProduct} onClose={() => setSelectedProduct(null)} />
|
||
)}
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|