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)
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
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
|
: 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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user