From b68ab81a536301faaf19f4ce172ec5fa31a098b9 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Mon, 11 May 2026 06:54:37 +0300 Subject: [PATCH] Add product improvements: main image, ratings, sorting, pagination, Unisex gender, space-separated sizes, fix brand filter --- backend/app/models/product.py | 5 +- backend/app/routers/products.py | 17 +++- backend/app/schemas/product.py | 11 ++- .../009_add_main_image_and_ratings.sql | 14 +++ frontend/src/components/ProductCard.jsx | 10 ++- frontend/src/components/ProductFilters.jsx | 1 + frontend/src/pages/Admin.jsx | 90 ++++++++++++++++--- frontend/src/pages/Products.jsx | 24 +++-- 8 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/009_add_main_image_and_ratings.sql diff --git a/backend/app/models/product.py b/backend/app/models/product.py index c36f30a..e72a278 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -15,12 +15,15 @@ class Product(Base): discount_price = Column(Float, nullable=True) category_id = Column(Integer, ForeignKey("category.id")) model_id = Column(Integer, ForeignKey("model.id", ondelete="SET NULL"), nullable=True) - gender = Column(String) # men, women + gender = Column(String, default='Unisex') # men, women, unisex brand = Column(String) sizes = Column(JSON) # ["S", "M", "L", "XL", ...] colors = Column(JSON) # ["Red", "Blue", ...] stock = Column(Integer, nullable=True, default=None) images = Column(JSON) # Array of image URLs + main_image_url = Column(Text, nullable=True) # Main image for product listings + rating_average = Column(DECIMAL(2, 1), default=0.0) # Average rating (0.0-5.0) + rating_count = Column(Integer, default=0) # Number of ratings is_featured = Column(Boolean, default=False) is_on_sale = Column(Boolean, default=False) override_price = Column(DECIMAL(10, 2), nullable=True) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 97c7006..28dea1b 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -35,6 +35,7 @@ def list_products( max_price: Optional[float] = None, on_sale: Optional[bool] = None, featured: Optional[bool] = None, + sort_by: Optional[str] = None, # brand, name, price_asc, price_desc, popular skip: int = 0, limit: int = 20, db: Session = Depends(get_db), @@ -46,7 +47,7 @@ def list_products( if model_id: query = query.filter(Product.model_id == model_id) if brand: - query = query.filter(Product.brand.ilike(f"%{brand}%")) + query = query.filter(Product.brand == brand) # Exact match for brand filter if gender: query = query.filter(Product.gender == gender) if min_price is not None: @@ -58,6 +59,20 @@ def list_products( if featured is not None: query = query.filter(Product.is_featured == featured) + # Apply sorting + if sort_by == 'brand': + query = query.order_by(Product.brand.asc()) + elif sort_by == 'name': + query = query.order_by(Product.name.asc()) + elif sort_by == 'price_asc': + query = query.order_by(Product.price.asc()) + elif sort_by == 'price_desc': + query = query.order_by(Product.price.desc()) + elif sort_by == 'popular': + query = query.order_by(Product.rating_average.desc(), Product.rating_count.desc()) + else: + query = query.order_by(Product.created_at.desc()) # Default: latest first + products = query.offset(skip).limit(limit).all() # Inherit sizes and price from model if product doesn't have them diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 98d9ea4..2ca4f7d 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -12,12 +12,15 @@ class ProductCreate(BaseModel): discount_price: Optional[float] = None category_id: int model_id: Optional[int] = None - gender: str # men, women + gender: str = 'Unisex' # men, women, unisex brand: str sizes: Optional[List[str]] = [] colors: Optional[List[str]] = [] stock: Optional[int] = None images: Optional[List[str]] = [] + main_image_url: Optional[str] = None # Main image for listings + rating_average: Optional[float] = 0.0 + rating_count: Optional[int] = 0 is_featured: bool = False is_on_sale: bool = False override_price: Optional[Decimal] = None @@ -38,6 +41,9 @@ class ProductUpdate(BaseModel): colors: Optional[List[str]] = None stock: Optional[int] = None images: Optional[List[str]] = None + main_image_url: Optional[str] = None + rating_average: Optional[float] = None + rating_count: Optional[int] = None is_featured: Optional[bool] = None is_on_sale: Optional[bool] = None override_price: Optional[Decimal] = None @@ -59,6 +65,9 @@ class ProductResponse(BaseModel): colors: Optional[List[str]] stock: Optional[int] images: Optional[List[str]] + main_image_url: Optional[str] + rating_average: Optional[float] + rating_count: Optional[int] is_featured: bool is_on_sale: bool override_price: Optional[Decimal] diff --git a/backend/migrations/009_add_main_image_and_ratings.sql b/backend/migrations/009_add_main_image_and_ratings.sql new file mode 100644 index 0000000..e622d01 --- /dev/null +++ b/backend/migrations/009_add_main_image_and_ratings.sql @@ -0,0 +1,14 @@ +-- Migration: Add main_image_url and rating fields to product table, change default gender to Unisex + +-- Add main_image_url field (optional, falls back to images[0] if not set) +ALTER TABLE product ADD COLUMN IF NOT EXISTS main_image_url TEXT DEFAULT NULL; + +-- Add rating fields for popularity sorting +ALTER TABLE product ADD COLUMN IF NOT EXISTS rating_average DECIMAL(2,1) DEFAULT 0.0; +ALTER TABLE product ADD COLUMN IF NOT EXISTS rating_count INTEGER DEFAULT 0; + +-- Update existing products with NULL or empty gender to 'Unisex' +UPDATE product SET gender = 'Unisex' WHERE gender IS NULL OR gender = ''; + +-- Create index for better query performance on ratings +CREATE INDEX IF NOT EXISTS idx_product_rating ON product(rating_average DESC); diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx index a9e0ded..cf47d74 100644 --- a/frontend/src/components/ProductCard.jsx +++ b/frontend/src/components/ProductCard.jsx @@ -13,9 +13,13 @@ export default function ProductCard({ product }) { : 0 const fallbackImage = `https://via.placeholder.com/400x400?text=${encodeURIComponent(product.name)}` - const imageUrl = (product.images && product.images.length > 0 && !imageError) - ? product.images[0] - : fallbackImage + + // Use main_image_url if set, otherwise use first image, otherwise use fallback + const imageUrl = !imageError && product.main_image_url + ? product.main_image_url + : (product.images && product.images.length > 0 && !imageError) + ? product.images[0] + : fallbackImage return ( diff --git a/frontend/src/components/ProductFilters.jsx b/frontend/src/components/ProductFilters.jsx index 8ef933e..8a613f4 100644 --- a/frontend/src/components/ProductFilters.jsx +++ b/frontend/src/components/ProductFilters.jsx @@ -91,6 +91,7 @@ export default function ProductFilters({ onFilter }) { + diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 41fd52c..dccbbe1 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -22,11 +22,14 @@ export default function Admin() { discount_price: '', category_id: '', model_id: '', - gender: 'men', + gender: 'Unisex', brand: '', sizes: '', stock: '', images: '', + main_image_url: '', + rating_average: '', + rating_count: '', is_featured: false, is_on_sale: false, override_price: '', @@ -50,6 +53,7 @@ export default function Admin() { const [filterModel, setFilterModel] = useState('') const [brands, setBrands] = useState([]) const [allProducts, setAllProducts] = useState([]) // Store all products for filtering + const [itemsPerPage, setItemsPerPage] = useState(25) // Products per page in admin list const [showBrandForm, setShowBrandForm] = useState(false) const [editingBrand, setEditingBrand] = useState(null) const [brandFormData, setBrandFormData] = useState({ name: '' }) @@ -253,10 +257,13 @@ export default function Admin() { category_id: parseInt(formData.category_id), model_id: formData.model_id ? parseInt(formData.model_id) : null, stock: formData.stock ? parseInt(formData.stock) : null, - sizes: formData.sizes ? formData.sizes.split(',').map(s => s.trim()).filter(s => s) : [], + sizes: formData.sizes ? formData.sizes.split(/[\s,]+/).map(s => s.trim()).filter(s => s) : [], images: formData.images.split(',').map(i => i.trim()).filter(i => i), + main_image_url: formData.main_image_url || null, + rating_average: formData.rating_average ? parseFloat(formData.rating_average) : 0.0, + rating_count: formData.rating_count ? parseInt(formData.rating_count) : 0, override_price: formData.override_price ? parseFloat(formData.override_price) : null, - override_sizes: formData.override_sizes ? formData.override_sizes.split(',').map(s => s.trim()) : null, + override_sizes: formData.override_sizes ? formData.override_sizes.split(/[\s,]+/).map(s => s.trim()) : null, } try { @@ -292,15 +299,18 @@ export default function Admin() { discount_price: product.discount_price || '', category_id: product.category_id || '', model_id: product.model_id || '', - gender: product.gender || 'men', + gender: product.gender || 'Unisex', brand: product.brand || '', - sizes: Array.isArray(product.sizes) ? product.sizes.join(', ') : '', + sizes: Array.isArray(product.sizes) ? product.sizes.join(' ') : '', stock: product.stock || '', images: imageList.join(', '), + main_image_url: product.main_image_url || '', + rating_average: product.rating_average || '', + rating_count: product.rating_count || '', is_featured: product.is_featured || false, is_on_sale: product.is_on_sale || false, override_price: product.override_price || '', - override_sizes: Array.isArray(product.override_sizes) ? product.override_sizes.join(', ') : '', + override_sizes: Array.isArray(product.override_sizes) ? product.override_sizes.join(' ') : '', }) setUploadedImages(imageList) console.log('Showing form...') @@ -339,11 +349,14 @@ export default function Admin() { discount_price: '', category_id: '', model_id: '', - gender: 'men', + gender: 'Unisex', brand: '', sizes: '', stock: '', images: '', + main_image_url: '', + rating_average: '', + rating_count: '', is_featured: false, is_on_sale: false, override_price: '', @@ -1016,7 +1029,7 @@ export default function Admin() {
@@ -1104,6 +1117,46 @@ export default function Admin() { +
+ + + + This image will be shown in product listings. If not set, the first image from the images list will be used. + +
+ +
+ + +
+ +
+ + +
+