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) return float(self.model.base_price)
else: else:
return 0.0 # Default fallback 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 import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List, Optional from typing import List, Optional
from pathlib import Path from pathlib import Path
@ -40,7 +41,7 @@ def list_products(
limit: int = 20, limit: int = 20,
db: Session = Depends(get_db), 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: if category_id:
query = query.filter(Product.category_id == 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 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) @router.get("/{product_id}", response_model=ProductResponse)
def get_product(product_id: int, db: Session = Depends(get_db)): 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: if not product:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")

View File

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

View File

@ -13,7 +13,7 @@ def get_products(
skip: int = 0, skip: int = 0,
limit: int = 10, limit: int = 10,
) -> List[Product]: ) -> List[Product]:
query = db.query(Product).options(joinedload(Product.model)) query = db.query(Product).options(joinedload(Product.model), joinedload(Product.category))
if category_id: if category_id:
query = query.filter(Product.category_id == 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]: def search_products(db: Session, query: str, skip: int = 0, limit: int = 10) -> List[Product]:
return ( return (
db.query(Product) db.query(Product)
.options(joinedload(Product.model)) .options(joinedload(Product.model), joinedload(Product.category))
.filter( .filter(
Product.name.ilike(f"%{query}%") | Product.brand.ilike(f"%{query}%") 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 [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 [itemsPerPage, setItemsPerPage] = useState(25) // 25, 50, 100, or 'all'
const [currentPage, setCurrentPage] = useState(1)
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: '' })
@ -97,7 +98,8 @@ export default function Admin() {
const fetchProducts = async () => { const fetchProducts = async () => {
try { 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) setAllProducts(response.data)
setProducts(response.data) setProducts(response.data)
} catch (error) { } catch (error) {
@ -192,6 +194,7 @@ export default function Admin() {
} }
setProducts(filtered) setProducts(filtered)
setCurrentPage(1) // Reset to first page whenever filters change
}, [searchQuery, filterBrand, filterCategory, filterModel, allProducts]) }, [searchQuery, filterBrand, filterCategory, filterModel, allProducts])
const handleChange = (e) => { const handleChange = (e) => {
@ -1185,18 +1188,35 @@ export default function Admin() {
)} )}
<div> <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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ fontSize: '0.9rem', color: '#666' }}>Items per page:</label> <label style={{ fontSize: '0.9rem', color: '#666' }}>Items per page:</label>
<select <select
value={itemsPerPage} 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' }} style={{ padding: '0.5rem', borderRadius: '4px', border: '1px solid #ddd' }}
> >
<option value="25">25</option> <option value={10}>10</option>
<option value="50">50</option> <option value={25}>25</option>
<option value="100">100</option> <option value={50}>50</option>
<option value={100}>100</option>
<option value="all">All</option> <option value="all">All</option>
</select> </select>
</div> </div>
@ -1204,6 +1224,7 @@ export default function Admin() {
{loading ? ( {loading ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}> <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
<thead> <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' }}>ID</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Name</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' }}>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' }}>Slug</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Price</th> <th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Price</th>
<th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Stock</th> <th style={{ padding: '0.75rem', border: '1px solid #ddd' }}>Stock</th>
@ -1220,11 +1242,14 @@ export default function Admin() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{products.slice(0, itemsPerPage).map(product => ( {pagedProducts.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>
<td style={{ padding: '0.75rem', border: '1px solid #ddd' }}>{product.brand}</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' }}> <td style={{ padding: '0.75rem', border: '1px solid #ddd', fontSize: '0.85em', color: '#666' }}>
{product.slug || '-'} {product.slug || '-'}
</td> </td>
@ -1251,7 +1276,47 @@ export default function Admin() {
</tbody> </tbody>
</table> </table>
</div> </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> </div>
</> </>
)} )}

View File

@ -208,7 +208,7 @@ export default function ProductDetail() {
<div className="product-details"> <div className="product-details">
<h3>Product Details</h3> <h3>Product Details</h3>
<ul> <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>Gender:</strong> {product.gender}</li>
<li><strong>Brand:</strong> {product.brand}</li> <li><strong>Brand:</strong> {product.brand}</li>
<li><strong>SKU:</strong> {product.id}</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 ProductFilters from '../components/ProductFilters'
import '../styles/global.css' import '../styles/global.css'
const PAGE_SIZE = 24
export default function Products() { export default function Products() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [products, setProducts] = useState([]) const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [sortBy, setSortBy] = useState('latest') const [sortBy, setSortBy] = useState('latest')
const [activeFilters, setActiveFilters] = useState({}) const [activeFilters, setActiveFilters] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [totalCount, setTotalCount] = useState(0)
const [showAll, setShowAll] = useState(false)
useEffect(() => { useEffect(() => {
fetchProducts() 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 () => { const fetchProducts = async () => {
try { try {
setLoading(true) setLoading(true)
const categorySlug = searchParams.get('category') const params = buildFilterParams()
const params = { limit: 50 }
const categorySlug = searchParams.get('category')
if (categorySlug) { if (categorySlug) {
// Get category by slug
const catRes = await api.get('/categories') const catRes = await api.get('/categories')
const category = catRes.data.find((c) => c.slug === categorySlug) const category = catRes.data.find((c) => c.slug === categorySlug)
if (category) params.category_id = category.id if (category) params.category_id = category.id
} }
// Only add non-empty filter values to params // Fetch total count for pagination
if (activeFilters.gender && activeFilters.gender !== '') { const countRes = await api.get('/products/count', { params })
params.gender = activeFilters.gender const total = countRes.data.count
} setTotalCount(total)
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
}
// Map sortBy to backend parameter // Fetch the page
if (sortBy === 'price-low') { if (showAll) {
params.sort_by = 'price_asc' params.skip = 0
} else if (sortBy === 'price-high') { params.limit = total || 1000
params.sort_by = 'price_desc' } else {
} else if (sortBy === 'name') { params.skip = (currentPage - 1) * PAGE_SIZE
params.sort_by = 'name' params.limit = PAGE_SIZE
} else if (sortBy === 'brand') {
params.sort_by = 'brand'
} else if (sortBy === 'popular') {
params.sort_by = 'popular'
} }
const response = await api.get('/products', { params }) const response = await api.get('/products', { params })
@ -73,8 +80,11 @@ export default function Products() {
const handleFilter = (filters) => { const handleFilter = (filters) => {
setActiveFilters(filters) setActiveFilters(filters)
setCurrentPage(1)
} }
const totalPages = showAll ? 1 : Math.ceil(totalCount / PAGE_SIZE)
return ( return (
<div className="products-page"> <div className="products-page">
<h1>Our Products</h1> <h1>Our Products</h1>
@ -85,7 +95,7 @@ export default function Products() {
<div className="products-content"> <div className="products-content">
<div className="sort-bar"> <div className="sort-bar">
<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); setCurrentPage(1) }}>
<option value="latest">Latest</option> <option value="latest">Latest</option>
<option value="popular">Most Popular</option> <option value="popular">Most Popular</option>
<option value="brand">Brand</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-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option> <option value="price-high">Price: High to Low</option>
</select> </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> </div>
{loading ? ( {loading ? (
<div className="loading">Loading products...</div> <div className="loading">Loading products...</div>
) : products.length > 0 ? ( ) : products.length > 0 ? (
<>
<div className="grid"> <div className="grid">
{products.map((product) => ( {products.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
</div> </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"> <div className="no-products">
<p>No products found.</p> <p>No products found.</p>