diff --git a/backend/app/models/product.py b/backend/app/models/product.py index e72a278..64b08e2 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -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 diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 28dea1b..90712a1 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -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") diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 2ca4f7d..7644158 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -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: diff --git a/backend/app/services/product.py b/backend/app/services/product.py index 6581b5f..6868beb 100644 --- a/backend/app/services/product.py +++ b/backend/app/services/product.py @@ -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}%") ) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index dccbbe1..e5429ca 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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() { )}
-
-

Products ({products.length})

+ {(() => { + 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 ( + <> +
+

+ Products — {allProducts.length} total + {products.length !== allProducts.length && ` (${products.length} matching filters)`} +

@@ -1204,6 +1224,7 @@ export default function Admin() { {loading ? (

Loading...

) : ( + <>
@@ -1211,6 +1232,7 @@ export default function Admin() { + @@ -1220,11 +1242,14 @@ export default function Admin() { - {products.slice(0, itemsPerPage).map(product => ( + {pagedProducts.map(product => ( + @@ -1251,7 +1276,47 @@ export default function Admin() {
ID Name BrandCategory Slug Price Stock
{product.id} {product.name} {product.brand} + {product.category_name || categories.find(c => c.id === product.category_id)?.name || product.category_id} + {product.slug || '-'}
+ + {/* Pagination controls */} + {itemsPerPage !== 'all' && totalPages > 1 && ( +
+ + {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 ( + + ) + })} + + + Page {safePage} of {totalPages} — showing {startIdx + 1}–{Math.min(endIdx, products.length)} of {products.length} + +
+ )} + )} + + ) + })()}
)} diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index c76046e..bb0a5fb 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -208,7 +208,7 @@ export default function ProductDetail() {

Product Details

    -
  • Category: Shoes
  • +
  • Category: {product.category_name || 'N/A'}
  • Gender: {product.gender}
  • Brand: {product.brand}
  • SKU: {product.id}
  • diff --git a/frontend/src/pages/Products.jsx b/frontend/src/pages/Products.jsx index adc97f4..9b6271e 100644 --- a/frontend/src/pages/Products.jsx +++ b/frontend/src/pages/Products.jsx @@ -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 (

    Our Products

    @@ -85,7 +95,7 @@ export default function Products() {
    - { setSortBy(e.target.value); setCurrentPage(1) }}> @@ -93,17 +103,62 @@ export default function Products() { - {products.length} products + + {showAll + ? `${totalCount} products (all)` + : `${totalCount} products — page ${currentPage} of ${totalPages || 1}`} + +
    {loading ? (
    Loading products...
    ) : products.length > 0 ? ( + <>
    {products.map((product) => ( ))}
    + + {/* Pagination controls (hidden when Show All is active) */} + {!showAll && totalPages > 1 && ( +
    + + {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 ( + + ) + })} + +
    + )} + ) : (

    No products found.