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
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
0f9386c1a5
commit
b68ab81a53
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
14
backend/migrations/009_add_main_image_and_ratings.sql
Normal file
14
backend/migrations/009_add_main_image_and_ratings.sql
Normal 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);
|
||||
@ -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 (
|
||||
<Link to={`/product/${product.id}`} className="product-card-link">
|
||||
|
||||
@ -91,6 +91,7 @@ export default function ProductFilters({ onFilter }) {
|
||||
<option value="">All</option>
|
||||
<option value="men">Men</option>
|
||||
<option value="women">Women</option>
|
||||
<option value="Unisex">Unisex</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label>
|
||||
Sizes (comma separated)
|
||||
Sizes (space or comma separated)
|
||||
{formData.model_id ? ' - Override model default' : ' *'}
|
||||
</label>
|
||||
<input
|
||||
@ -1024,7 +1037,7 @@ export default function Admin() {
|
||||
name="sizes"
|
||||
value={formData.sizes}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@ -1104,6 +1117,46 @@ export default function Admin() {
|
||||
</details>
|
||||
</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">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input type="checkbox" name="is_featured" checked={formData.is_featured} onChange={handleChange} />
|
||||
@ -1132,7 +1185,22 @@ export default function Admin() {
|
||||
)}
|
||||
|
||||
<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 ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
@ -1152,7 +1220,7 @@ export default function Admin() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map(product => (
|
||||
{products.slice(0, itemsPerPage).map(product => (
|
||||
<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.name}</td>
|
||||
|
||||
@ -30,24 +30,28 @@ export default function Products() {
|
||||
}
|
||||
|
||||
// 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.max_price) params.max_price = parseFloat(activeFilters.max_price)
|
||||
if (activeFilters.onSale) params.on_sale = true
|
||||
if (activeFilters.model_id) params.model_id = parseInt(activeFilters.model_id)
|
||||
|
||||
const response = await api.get('/products', { params })
|
||||
let sorted = [...response.data]
|
||||
|
||||
// Apply client-side sorting
|
||||
// Map sortBy to backend parameter
|
||||
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') {
|
||||
sorted.sort((a, b) => (b.discount_price || b.price) - (a.discount_price || a.price))
|
||||
params.sort_by = 'price_desc'
|
||||
} 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) {
|
||||
console.error('Error fetching products:', error)
|
||||
} finally {
|
||||
@ -71,9 +75,11 @@ export default function Products() {
|
||||
<label>Sort by:</label>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<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-high">Price: High to Low</option>
|
||||
<option value="name">Name: A to Z</option>
|
||||
</select>
|
||||
<span className="product-count">{products.length} products</span>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user