Try fix brand-master
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
dvirlabs 2026-05-26 07:34:08 +03:00
parent ad96ec33e6
commit f02f8106f9
7 changed files with 210 additions and 50 deletions

View File

@ -43,3 +43,8 @@ class Product(Base):
return float(self.model.base_price)
else:
return 0.0 # Default fallback
@property
def category_name(self):
"""Return the name of the associated category, or None if not set."""
return self.category.name if self.category else None

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from typing import List, Optional
from pathlib import Path
@ -40,7 +41,7 @@ def list_products(
limit: int = 20,
db: Session = Depends(get_db),
):
query = db.query(Product).options(joinedload(Product.model))
query = db.query(Product).options(joinedload(Product.model), joinedload(Product.category))
if category_id:
query = query.filter(Product.category_id == category_id)
@ -101,9 +102,42 @@ def search(q: str, skip: int = 0, limit: int = 20, db: Session = Depends(get_db)
return products
@router.get("/count")
def get_products_count(
category_id: Optional[int] = None,
model_id: Optional[int] = None,
brand: Optional[str] = None,
gender: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
on_sale: Optional[bool] = None,
featured: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""Return the total number of products matching the given filters."""
query = db.query(func.count(Product.id))
if category_id:
query = query.filter(Product.category_id == category_id)
if model_id:
query = query.filter(Product.model_id == model_id)
if brand:
query = query.filter(Product.brand == brand)
if gender:
query = query.filter(Product.gender == gender)
if min_price is not None:
query = query.filter(Product.price >= min_price)
if max_price is not None:
query = query.filter(Product.price <= max_price)
if on_sale is not None:
query = query.filter(Product.is_on_sale == on_sale)
if featured is not None:
query = query.filter(Product.is_featured == featured)
return {"count": query.scalar()}
@router.get("/{product_id}", response_model=ProductResponse)
def get_product(product_id: int, db: Session = Depends(get_db)):
product = get_product_by_id(db, product_id)
product = db.query(Product).options(joinedload(Product.model), joinedload(Product.category)).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")

View File

@ -72,6 +72,7 @@ class ProductResponse(BaseModel):
is_on_sale: bool
override_price: Optional[Decimal]
override_sizes: Optional[List[str]]
category_name: Optional[str] = None
created_at: datetime
class Config:

View File

@ -13,7 +13,7 @@ def get_products(
skip: int = 0,
limit: int = 10,
) -> List[Product]:
query = db.query(Product).options(joinedload(Product.model))
query = db.query(Product).options(joinedload(Product.model), joinedload(Product.category))
if category_id:
query = query.filter(Product.category_id == category_id)
@ -67,7 +67,7 @@ def delete_product(db: Session, product_id: int) -> bool:
def search_products(db: Session, query: str, skip: int = 0, limit: int = 10) -> List[Product]:
return (
db.query(Product)
.options(joinedload(Product.model))
.options(joinedload(Product.model), joinedload(Product.category))
.filter(
Product.name.ilike(f"%{query}%") | Product.brand.ilike(f"%{query}%")
)

View File

@ -53,7 +53,8 @@ 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 [itemsPerPage, setItemsPerPage] = useState(25) // 25, 50, 100, or 'all'
const [currentPage, setCurrentPage] = useState(1)
const [showBrandForm, setShowBrandForm] = useState(false)
const [editingBrand, setEditingBrand] = useState(null)
const [brandFormData, setBrandFormData] = useState({ name: '' })
@ -97,7 +98,8 @@ export default function Admin() {
const fetchProducts = async () => {
try {
const response = await api.get('/products')
// Fetch all products so admin can see and filter the full catalogue
const response = await api.get('/products', { params: { limit: 10000, skip: 0 } })
setAllProducts(response.data)
setProducts(response.data)
} catch (error) {
@ -192,6 +194,7 @@ export default function Admin() {
}
setProducts(filtered)
setCurrentPage(1) // Reset to first page whenever filters change
}, [searchQuery, filterBrand, filterCategory, filterModel, allProducts])
const handleChange = (e) => {
@ -1185,18 +1188,35 @@ export default function Admin() {
)}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2>Products ({products.length})</h2>
{(() => {
const pageSize = itemsPerPage === 'all' ? products.length : itemsPerPage
const totalPages = pageSize === 0 ? 1 : Math.ceil(products.length / (pageSize || 1))
const safePage = Math.min(currentPage, totalPages || 1)
const startIdx = (safePage - 1) * (pageSize || products.length)
const endIdx = itemsPerPage === 'all' ? products.length : startIdx + pageSize
const pagedProducts = products.slice(startIdx, endIdx)
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<h2>
Products {allProducts.length} total
{products.length !== allProducts.length && ` (${products.length} matching filters)`}
</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))}
onChange={(e) => {
setItemsPerPage(e.target.value === 'all' ? 'all' : parseInt(e.target.value))
setCurrentPage(1)
}}
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={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value="all">All</option>
</select>
</div>
@ -1204,6 +1224,7 @@ export default function Admin() {
{loading ? (
<p>Loading...</p>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
<thead>
@ -1211,6 +1232,7 @@ export default function Admin() {
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>ID</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Name</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Brand</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Category</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Slug</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Price</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Stock</th>
@ -1220,11 +1242,14 @@ export default function Admin() {
</tr>
</thead>
<tbody>
{products.slice(0, itemsPerPage).map(product => (
{pagedProducts.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>
<td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.brand}</td>
<td style={{ padding: '0.75rem', border: '1px solid #ddd', fontSize: '0.85em' }}>
{product.category_name || categories.find(c => c.id === product.category_id)?.name || product.category_id}
</td>
<td style={{ padding: '0.75rem', border: '1px solid #ddd', fontSize: '0.85em', color: '#666' }}>
{product.slug || '-'}
</td>
@ -1251,7 +1276,47 @@ export default function Admin() {
</tbody>
</table>
</div>
{/* Pagination controls */}
{itemsPerPage !== 'all' && totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={safePage <= 1}
style={{ padding: '0.4rem 0.8rem', borderRadius: '4px', border: '1px solid #ddd', cursor: safePage <= 1 ? 'not-allowed' : 'pointer', opacity: safePage <= 1 ? 0.5 : 1 }}
>
Prev
</button>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
const page = totalPages <= 10 ? i + 1 : safePage <= 5 ? i + 1 : safePage + i - 4
if (page < 1 || page > totalPages) return null
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
style={{ padding: '0.4rem 0.8rem', borderRadius: '4px', border: '1px solid #ddd', cursor: 'pointer', backgroundColor: page === safePage ? '#333' : 'white', color: page === safePage ? 'white' : 'inherit' }}
>
{page}
</button>
)
})}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
style={{ padding: '0.4rem 0.8rem', borderRadius: '4px', border: '1px solid #ddd', cursor: safePage >= totalPages ? 'not-allowed' : 'pointer', opacity: safePage >= totalPages ? 0.5 : 1 }}
>
Next
</button>
<span style={{ fontSize: '0.85rem', color: '#666', marginLeft: '0.5rem' }}>
Page {safePage} of {totalPages} showing {startIdx + 1}{Math.min(endIdx, products.length)} of {products.length}
</span>
</div>
)}
</>
)}
</>
)
})()}
</div>
</>
)}

View File

@ -208,7 +208,7 @@ export default function ProductDetail() {
<div className="product-details">
<h3>Product Details</h3>
<ul>
<li><strong>Category:</strong> Shoes</li>
<li><strong>Category:</strong> {product.category_name || 'N/A'}</li>
<li><strong>Gender:</strong> {product.gender}</li>
<li><strong>Brand:</strong> {product.brand}</li>
<li><strong>SKU:</strong> {product.id}</li>

View File

@ -5,61 +5,68 @@ import ProductCard from '../components/ProductCard'
import ProductFilters from '../components/ProductFilters'
import '../styles/global.css'
const PAGE_SIZE = 24
export default function Products() {
const [searchParams] = useSearchParams()
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
const [sortBy, setSortBy] = useState('latest')
const [activeFilters, setActiveFilters] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [showAll, setShowAll] = useState(false)
useEffect(() => {
fetchProducts()
}, [searchParams, activeFilters, sortBy])
}, [searchParams, activeFilters, sortBy, currentPage, showAll])
const buildFilterParams = () => {
const params = {}
const categorySlug = searchParams.get('category')
// category_id resolved later via async call; passed in fetchProducts
if (activeFilters.gender && activeFilters.gender !== '') params.gender = activeFilters.gender
if (activeFilters.brand && activeFilters.brand !== '') params.brand = activeFilters.brand
if (activeFilters.model_id && activeFilters.model_id !== '') params.model_id = parseInt(activeFilters.model_id)
if (activeFilters.min_price && activeFilters.min_price !== '') params.min_price = parseFloat(activeFilters.min_price)
if (activeFilters.max_price && activeFilters.max_price !== '') params.max_price = parseFloat(activeFilters.max_price)
if (activeFilters.onSale === true) params.on_sale = true
if (sortBy === 'price-low') params.sort_by = 'price_asc'
else if (sortBy === 'price-high') params.sort_by = 'price_desc'
else if (sortBy === 'name') params.sort_by = 'name'
else if (sortBy === 'brand') params.sort_by = 'brand'
else if (sortBy === 'popular') params.sort_by = 'popular'
return params
}
const fetchProducts = async () => {
try {
setLoading(true)
const categorySlug = searchParams.get('category')
const params = { limit: 50 }
const params = buildFilterParams()
const categorySlug = searchParams.get('category')
if (categorySlug) {
// Get category by slug
const catRes = await api.get('/categories')
const category = catRes.data.find((c) => c.slug === categorySlug)
if (category) params.category_id = category.id
}
// Only add non-empty filter values to params
if (activeFilters.gender && activeFilters.gender !== '') {
params.gender = activeFilters.gender
}
if (activeFilters.brand && activeFilters.brand !== '') {
params.brand = activeFilters.brand
}
if (activeFilters.model_id && activeFilters.model_id !== '') {
params.model_id = parseInt(activeFilters.model_id)
}
if (activeFilters.min_price && activeFilters.min_price !== '') {
params.min_price = parseFloat(activeFilters.min_price)
}
if (activeFilters.max_price && activeFilters.max_price !== '') {
params.max_price = parseFloat(activeFilters.max_price)
}
if (activeFilters.onSale === true) {
params.on_sale = true
}
// Fetch total count for pagination
const countRes = await api.get('/products/count', { params })
const total = countRes.data.count
setTotalCount(total)
// Map sortBy to backend parameter
if (sortBy === 'price-low') {
params.sort_by = 'price_asc'
} else if (sortBy === 'price-high') {
params.sort_by = 'price_desc'
} else if (sortBy === 'name') {
params.sort_by = 'name'
} else if (sortBy === 'brand') {
params.sort_by = 'brand'
} else if (sortBy === 'popular') {
params.sort_by = 'popular'
// Fetch the page
if (showAll) {
params.skip = 0
params.limit = total || 1000
} else {
params.skip = (currentPage - 1) * PAGE_SIZE
params.limit = PAGE_SIZE
}
const response = await api.get('/products', { params })
@ -73,8 +80,11 @@ export default function Products() {
const handleFilter = (filters) => {
setActiveFilters(filters)
setCurrentPage(1)
}
const totalPages = showAll ? 1 : Math.ceil(totalCount / PAGE_SIZE)
return (
<div className="products-page">
<h1>Our Products</h1>
@ -85,7 +95,7 @@ export default function Products() {
<div className="products-content">
<div className="sort-bar">
<label>Sort by:</label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<select value={sortBy} onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1) }}>
<option value="latest">Latest</option>
<option value="popular">Most Popular</option>
<option value="brand">Brand</option>
@ -93,17 +103,62 @@ export default function Products() {
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
</select>
<span className="product-count">{products.length} products</span>
<span className="product-count">
{showAll
? `${totalCount} products (all)`
: `${totalCount} products — page ${currentPage} of ${totalPages || 1}`}
</span>
<button
onClick={() => { setShowAll(s => !s); setCurrentPage(1) }}
style={{ marginLeft: '1rem', padding: '0.3rem 0.8rem', borderRadius: '4px', border: '1px solid #ccc', cursor: 'pointer', fontSize: '0.85rem' }}
>
{showAll ? 'Paginate' : 'Show All'}
</button>
</div>
{loading ? (
<div className="loading">Loading products...</div>
) : products.length > 0 ? (
<>
<div className="grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Pagination controls (hidden when Show All is active) */}
{!showAll && totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', marginTop: '2rem', flexWrap: 'wrap' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage <= 1}
style={{ padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ccc', cursor: currentPage <= 1 ? 'not-allowed' : 'pointer', opacity: currentPage <= 1 ? 0.5 : 1 }}
>
Prev
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const page = totalPages <= 7 ? i + 1 : currentPage <= 4 ? i + 1 : currentPage + i - 3
if (page < 1 || page > totalPages) return null
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
style={{ padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ccc', cursor: 'pointer', backgroundColor: page === currentPage ? '#333' : 'white', color: page === currentPage ? 'white' : 'inherit' }}
>
{page}
</button>
)
})}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
style={{ padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ccc', cursor: currentPage >= totalPages ? 'not-allowed' : 'pointer', opacity: currentPage >= totalPages ? 0.5 : 1 }}
>
Next
</button>
</div>
)}
</>
) : (
<div className="no-products">
<p>No products found.</p>