Add product improvements: main image, ratings, sorting, pagination, Unisex gender, space-separated sizes, fix brand filter
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
dvirlabs 2026-05-11 06:54:37 +03:00
parent 0f9386c1a5
commit b68ab81a53
8 changed files with 146 additions and 26 deletions

View File

@ -15,12 +15,15 @@ class Product(Base):
discount_price = Column(Float, nullable=True) discount_price = Column(Float, nullable=True)
category_id = Column(Integer, ForeignKey("category.id")) category_id = Column(Integer, ForeignKey("category.id"))
model_id = Column(Integer, ForeignKey("model.id", ondelete="SET NULL"), nullable=True) 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) brand = Column(String)
sizes = Column(JSON) # ["S", "M", "L", "XL", ...] sizes = Column(JSON) # ["S", "M", "L", "XL", ...]
colors = Column(JSON) # ["Red", "Blue", ...] colors = Column(JSON) # ["Red", "Blue", ...]
stock = Column(Integer, nullable=True, default=None) stock = Column(Integer, nullable=True, default=None)
images = Column(JSON) # Array of image URLs 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_featured = Column(Boolean, default=False)
is_on_sale = Column(Boolean, default=False) is_on_sale = Column(Boolean, default=False)
override_price = Column(DECIMAL(10, 2), nullable=True) override_price = Column(DECIMAL(10, 2), nullable=True)

View File

@ -35,6 +35,7 @@ def list_products(
max_price: Optional[float] = None, max_price: Optional[float] = None,
on_sale: Optional[bool] = None, on_sale: Optional[bool] = None,
featured: Optional[bool] = None, featured: Optional[bool] = None,
sort_by: Optional[str] = None, # brand, name, price_asc, price_desc, popular
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -46,7 +47,7 @@ def list_products(
if model_id: if model_id:
query = query.filter(Product.model_id == model_id) query = query.filter(Product.model_id == model_id)
if brand: if brand:
query = query.filter(Product.brand.ilike(f"%{brand}%")) query = query.filter(Product.brand == brand) # Exact match for brand filter
if gender: if gender:
query = query.filter(Product.gender == gender) query = query.filter(Product.gender == gender)
if min_price is not None: if min_price is not None:
@ -58,6 +59,20 @@ def list_products(
if featured is not None: if featured is not None:
query = query.filter(Product.is_featured == featured) 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() products = query.offset(skip).limit(limit).all()
# Inherit sizes and price from model if product doesn't have them # Inherit sizes and price from model if product doesn't have them

View File

@ -12,12 +12,15 @@ class ProductCreate(BaseModel):
discount_price: Optional[float] = None discount_price: Optional[float] = None
category_id: int category_id: int
model_id: Optional[int] = None model_id: Optional[int] = None
gender: str # men, women gender: str = 'Unisex' # men, women, unisex
brand: str brand: str
sizes: Optional[List[str]] = [] sizes: Optional[List[str]] = []
colors: Optional[List[str]] = [] colors: Optional[List[str]] = []
stock: Optional[int] = None stock: Optional[int] = None
images: Optional[List[str]] = [] 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_featured: bool = False
is_on_sale: bool = False is_on_sale: bool = False
override_price: Optional[Decimal] = None override_price: Optional[Decimal] = None
@ -38,6 +41,9 @@ class ProductUpdate(BaseModel):
colors: Optional[List[str]] = None colors: Optional[List[str]] = None
stock: Optional[int] = None stock: Optional[int] = None
images: Optional[List[str]] = 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_featured: Optional[bool] = None
is_on_sale: Optional[bool] = None is_on_sale: Optional[bool] = None
override_price: Optional[Decimal] = None override_price: Optional[Decimal] = None
@ -59,6 +65,9 @@ class ProductResponse(BaseModel):
colors: Optional[List[str]] colors: Optional[List[str]]
stock: Optional[int] stock: Optional[int]
images: Optional[List[str]] images: Optional[List[str]]
main_image_url: Optional[str]
rating_average: Optional[float]
rating_count: Optional[int]
is_featured: bool is_featured: bool
is_on_sale: bool is_on_sale: bool
override_price: Optional[Decimal] override_price: Optional[Decimal]

View File

@ -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);

View File

@ -13,9 +13,13 @@ export default function ProductCard({ product }) {
: 0 : 0
const fallbackImage = `https://via.placeholder.com/400x400?text=${encodeURIComponent(product.name)}` const fallbackImage = `https://via.placeholder.com/400x400?text=${encodeURIComponent(product.name)}`
const imageUrl = (product.images && product.images.length > 0 && !imageError)
? product.images[0] // Use main_image_url if set, otherwise use first image, otherwise use fallback
: fallbackImage const imageUrl = !imageError && product.main_image_url
? product.main_image_url
: (product.images && product.images.length > 0 && !imageError)
? product.images[0]
: fallbackImage
return ( return (
<Link to={`/product/${product.id}`} className="product-card-link"> <Link to={`/product/${product.id}`} className="product-card-link">

View File

@ -91,6 +91,7 @@ export default function ProductFilters({ onFilter }) {
<option value="">All</option> <option value="">All</option>
<option value="men">Men</option> <option value="men">Men</option>
<option value="women">Women</option> <option value="women">Women</option>
<option value="Unisex">Unisex</option>
</select> </select>
</div> </div>

View File

@ -22,11 +22,14 @@ export default function Admin() {
discount_price: '', discount_price: '',
category_id: '', category_id: '',
model_id: '', model_id: '',
gender: 'men', gender: 'Unisex',
brand: '', brand: '',
sizes: '', sizes: '',
stock: '', stock: '',
images: '', images: '',
main_image_url: '',
rating_average: '',
rating_count: '',
is_featured: false, is_featured: false,
is_on_sale: false, is_on_sale: false,
override_price: '', override_price: '',
@ -50,6 +53,7 @@ export default function Admin() {
const [filterModel, setFilterModel] = useState('') const [filterModel, setFilterModel] = useState('')
const [brands, setBrands] = useState([]) const [brands, setBrands] = useState([])
const [allProducts, setAllProducts] = useState([]) // Store all products for filtering 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 [showBrandForm, setShowBrandForm] = useState(false)
const [editingBrand, setEditingBrand] = useState(null) const [editingBrand, setEditingBrand] = useState(null)
const [brandFormData, setBrandFormData] = useState({ name: '' }) const [brandFormData, setBrandFormData] = useState({ name: '' })
@ -253,10 +257,13 @@ export default function Admin() {
category_id: parseInt(formData.category_id), category_id: parseInt(formData.category_id),
model_id: formData.model_id ? parseInt(formData.model_id) : null, model_id: formData.model_id ? parseInt(formData.model_id) : null,
stock: formData.stock ? parseInt(formData.stock) : 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), 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_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 { try {
@ -292,15 +299,18 @@ export default function Admin() {
discount_price: product.discount_price || '', discount_price: product.discount_price || '',
category_id: product.category_id || '', category_id: product.category_id || '',
model_id: product.model_id || '', model_id: product.model_id || '',
gender: product.gender || 'men', gender: product.gender || 'Unisex',
brand: product.brand || '', brand: product.brand || '',
sizes: Array.isArray(product.sizes) ? product.sizes.join(', ') : '', sizes: Array.isArray(product.sizes) ? product.sizes.join(' ') : '',
stock: product.stock || '', stock: product.stock || '',
images: imageList.join(', '), 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_featured: product.is_featured || false,
is_on_sale: product.is_on_sale || false, is_on_sale: product.is_on_sale || false,
override_price: product.override_price || '', 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) setUploadedImages(imageList)
console.log('Showing form...') console.log('Showing form...')
@ -339,11 +349,14 @@ export default function Admin() {
discount_price: '', discount_price: '',
category_id: '', category_id: '',
model_id: '', model_id: '',
gender: 'men', gender: 'Unisex',
brand: '', brand: '',
sizes: '', sizes: '',
stock: '', stock: '',
images: '', images: '',
main_image_url: '',
rating_average: '',
rating_count: '',
is_featured: false, is_featured: false,
is_on_sale: false, is_on_sale: false,
override_price: '', override_price: '',
@ -1016,7 +1029,7 @@ export default function Admin() {
<div className="form-group" style={{ gridColumn: '1 / -1' }}> <div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label> <label>
Sizes (comma separated) Sizes (space or comma separated)
{formData.model_id ? ' - Override model default' : ' *'} {formData.model_id ? ' - Override model default' : ' *'}
</label> </label>
<input <input
@ -1024,7 +1037,7 @@ export default function Admin() {
name="sizes" name="sizes"
value={formData.sizes} value={formData.sizes}
onChange={handleChange} onChange={handleChange}
placeholder={formData.model_id ? "Leave empty to use model sizes" : "S, M, L, XL"} placeholder={formData.model_id ? "Leave empty to use model sizes" : "S M L XL or S, M, L, XL"}
required={!formData.model_id} required={!formData.model_id}
/> />
</div> </div>
@ -1104,6 +1117,46 @@ export default function Admin() {
</details> </details>
</div> </div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label>Main Image URL (for product listings)</label>
<input
type="text"
name="main_image_url"
value={formData.main_image_url}
onChange={handleChange}
placeholder="Leave empty to use first image from images list above"
/>
<small style={{ color: '#666', fontSize: '0.85em' }}>
This image will be shown in product listings. If not set, the first image from the images list will be used.
</small>
</div>
<div className="form-group">
<label>Rating Average (0.0 - 5.0)</label>
<input
type="number"
step="0.1"
min="0"
max="5"
name="rating_average"
value={formData.rating_average}
onChange={handleChange}
placeholder="0.0"
/>
</div>
<div className="form-group">
<label>Rating Count</label>
<input
type="number"
min="0"
name="rating_count"
value={formData.rating_count}
onChange={handleChange}
placeholder="0"
/>
</div>
<div className="form-group"> <div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" name="is_featured" checked={formData.is_featured} onChange={handleChange} /> <input type="checkbox" name="is_featured" checked={formData.is_featured} onChange={handleChange} />
@ -1132,7 +1185,22 @@ export default function Admin() {
)} )}
<div> <div>
<h2>Products ({products.length})</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2>Products ({products.length})</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ fontSize: '0.9rem', color: '#666' }}>Items per page:</label>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(e.target.value === 'all' ? products.length : parseInt(e.target.value))}
style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">All</option>
</select>
</div>
</div>
{loading ? ( {loading ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
@ -1152,7 +1220,7 @@ export default function Admin() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{products.map(product => ( {products.slice(0, itemsPerPage).map(product => (
<tr key={product.id}> <tr key={product.id}>
<td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.id}</td> <td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.id}</td>
<td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.name}</td> <td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.name}</td>

View File

@ -30,24 +30,28 @@ export default function Products() {
} }
// Convert filter values to API parameters // Convert filter values to API parameters
if (activeFilters.gender) params.gender = activeFilters.gender
if (activeFilters.brand) params.brand = activeFilters.brand
if (activeFilters.min_price) params.min_price = parseFloat(activeFilters.min_price) if (activeFilters.min_price) params.min_price = parseFloat(activeFilters.min_price)
if (activeFilters.max_price) params.max_price = parseFloat(activeFilters.max_price) if (activeFilters.max_price) params.max_price = parseFloat(activeFilters.max_price)
if (activeFilters.onSale) params.on_sale = true if (activeFilters.onSale) params.on_sale = true
if (activeFilters.model_id) params.model_id = parseInt(activeFilters.model_id) if (activeFilters.model_id) params.model_id = parseInt(activeFilters.model_id)
const response = await api.get('/products', { params }) // Map sortBy to backend parameter
let sorted = [...response.data]
// Apply client-side sorting
if (sortBy === 'price-low') { if (sortBy === 'price-low') {
sorted.sort((a, b) => (a.discount_price || a.price) - (b.discount_price || b.price)) params.sort_by = 'price_asc'
} else if (sortBy === 'price-high') { } else if (sortBy === 'price-high') {
sorted.sort((a, b) => (b.discount_price || b.price) - (a.discount_price || a.price)) params.sort_by = 'price_desc'
} else if (sortBy === 'name') { } else if (sortBy === 'name') {
sorted.sort((a, b) => a.name.localeCompare(b.name)) params.sort_by = 'name'
} else if (sortBy === 'brand') {
params.sort_by = 'brand'
} else if (sortBy === 'popular') {
params.sort_by = 'popular'
} }
setProducts(sorted) const response = await api.get('/products', { params })
setProducts(response.data)
} catch (error) { } catch (error) {
console.error('Error fetching products:', error) console.error('Error fetching products:', error)
} finally { } finally {
@ -71,9 +75,11 @@ export default function Products() {
<label>Sort by:</label> <label>Sort by:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}> <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="latest">Latest</option> <option value="latest">Latest</option>
<option value="popular">Most Popular</option>
<option value="brand">Brand</option>
<option value="name">Name: A to Z</option>
<option value="price-low">Price: Low to High</option> <option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option> <option value="price-high">Price: High to Low</option>
<option value="name">Name: A to Z</option>
</select> </select>
<span className="product-count">{products.length} products</span> <span className="product-count">{products.length} products</span>
</div> </div>