diff --git a/admin-spa/src/routes/Appearance/Shop.tsx b/admin-spa/src/routes/Appearance/Shop.tsx index 6044c3b..771ba55 100644 --- a/admin-spa/src/routes/Appearance/Shop.tsx +++ b/admin-spa/src/routes/Appearance/Shop.tsx @@ -19,6 +19,7 @@ export default function AppearanceShop() { const [gridStyle, setGridStyle] = useState('standard'); const [cardStyle, setCardStyle] = useState('card'); const [aspectRatio, setAspectRatio] = useState('square'); + const [filterLayout, setFilterLayout] = useState('basic'); const [elements, setElements] = useState({ category_filter: true, @@ -50,6 +51,7 @@ export default function AppearanceShop() { setCardStyle(shop.layout?.card_style || 'card'); setAspectRatio(shop.layout?.aspect_ratio || 'square'); setCardTextAlign(shop.layout?.card_text_align || 'left'); + setFilterLayout(shop.layout?.filter_layout || 'basic'); if (shop.elements) { setElements(shop.elements); @@ -83,7 +85,8 @@ export default function AppearanceShop() { grid_style: gridStyle, card_style: cardStyle, aspect_ratio: aspectRatio, - card_text_align: cardTextAlign + card_text_align: cardTextAlign, + filter_layout: filterLayout }, elements: { category_filter: elements.category_filter, @@ -181,6 +184,18 @@ export default function AppearanceShop() { + + + + setSearchQuery(e.target.value)} + className="w-[200px] lg:w-[250px] !pl-9" + /> +
diff --git a/customer-spa/src/components/ProductCard.tsx b/customer-spa/src/components/ProductCard.tsx index 6dffbc8..20ccbdb 100644 --- a/customer-spa/src/components/ProductCard.tsx +++ b/customer-spa/src/components/ProductCard.tsx @@ -70,9 +70,9 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { if (isLoading) { return (
-
-
-
+
+
+
); } @@ -91,17 +91,17 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { if (cardStyle === 'minimal') { return gridCols >= 4 ? 'overflow-hidden hover:opacity-90 transition-opacity' - : 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4'; + : 'overflow-hidden hover:opacity-80 transition-opacity border-b border-border pb-4'; } if (cardStyle === 'overlay') { return gridCols >= 4 ? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md' - : 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-white'; + : 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-card'; } // Default 'card' style return gridCols >= 4 - ? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white' - : 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white'; + ? 'border border-border rounded-md overflow-hidden hover:shadow-md transition-shadow bg-card' + : 'border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-card'; }; const cardClasses = getCardClasses(); @@ -118,7 +118,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Image */} -
+
{product.image ? ( ) : ( -
+
No Image
)} @@ -146,12 +146,14 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
)} @@ -174,7 +176,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) { {/* Content */}
-

+

{product.name}

@@ -182,15 +184,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{product.on_sale && product.regular_price ? ( <> - + {formatPrice(product.sale_price || product.price)} - + {formatPrice(product.regular_price)} ) : ( - + {formatPrice(product.price)} )} diff --git a/customer-spa/src/hooks/useAppearanceSettings.ts b/customer-spa/src/hooks/useAppearanceSettings.ts index 32d56f3..9f29eb5 100644 --- a/customer-spa/src/hooks/useAppearanceSettings.ts +++ b/customer-spa/src/hooks/useAppearanceSettings.ts @@ -14,6 +14,7 @@ interface AppearanceSettings { grid_columns: string; card_style: string; aspect_ratio: string; + filter_layout?: 'basic' | 'rich_sidebar'; }; elements: { category_filter: boolean; @@ -86,6 +87,7 @@ export function useShopSettings() { card_style: 'card' as string, aspect_ratio: 'square' as string, card_text_align: 'left' as string, + filter_layout: 'basic' as 'basic' | 'rich_sidebar', }, elements: { category_filter: true, diff --git a/customer-spa/src/hooks/useDebounce.ts b/customer-spa/src/hooks/useDebounce.ts new file mode 100644 index 0000000..0a45ef2 --- /dev/null +++ b/customer-spa/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/customer-spa/src/layouts/BaseLayout.tsx b/customer-spa/src/layouts/BaseLayout.tsx index 61a5fcd..027dabb 100644 --- a/customer-spa/src/layouts/BaseLayout.tsx +++ b/customer-spa/src/layouts/BaseLayout.tsx @@ -166,13 +166,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{itemCount > 0 && ( - + {itemCount} )}
- Cart ({itemCount}) + Cart )} @@ -261,7 +261,7 @@ function ClassicLayout({ children }: BaseLayoutProps) { )} @@ -657,7 +665,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) { )} {headerSettings.elements.cart && ( )} diff --git a/customer-spa/src/pages/Shop/index.tsx b/customer-spa/src/pages/Shop/index.tsx index 616ac04..15acfd2 100644 --- a/customer-spa/src/pages/Shop/index.tsx +++ b/customer-spa/src/pages/Shop/index.tsx @@ -8,16 +8,37 @@ import { Button } from '@/components/ui/button'; import Container from '@/components/Layout/Container'; import { ProductCard } from '@/components/ProductCard'; import { toast } from 'sonner'; +import { Input } from '@/components/ui/input'; import { useShopSettings } from '@/hooks/useAppearanceSettings'; import SEOHead from '@/components/SEOHead'; +import { useDebounce } from '@/hooks/useDebounce'; import type { ProductsResponse, ProductCategory } from '@/types/product'; +function useBreakpoint() { + const [breakpoint, setBreakpoint] = React.useState<'mobile' | 'tablet' | 'desktop'>('desktop'); + React.useEffect(() => { + const check = () => { + if (window.innerWidth < 768) setBreakpoint('mobile'); + else if (window.innerWidth < 1024) setBreakpoint('tablet'); + else setBreakpoint('desktop'); + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + return breakpoint; +} + export default function Shop() { const navigate = useNavigate(); const { layout: shopLayout, elements } = useShopSettings(); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [category, setCategory] = useState(''); + const [minPriceInput, setMinPriceInput] = useState(''); + const [maxPriceInput, setMaxPriceInput] = useState(''); + const minPrice = useDebounce(minPriceInput, 500); + const maxPrice = useDebounce(maxPriceInput, 500); const [sortBy, setSortBy] = useState(''); const { addItem } = useCartStore(); @@ -73,15 +94,25 @@ export default function Shop() { const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`; const isMasonry = shopLayout.grid_style === 'masonry'; + const isRichSidebar = shopLayout.filter_layout === 'rich_sidebar'; + + // Automatically reset page when filters change + React.useEffect(() => { + setPage(1); + }, [search, category, minPrice, maxPrice, sortBy]); + + const breakpoint = useBreakpoint(); // Fetch products const { data: productsData, isLoading: productsLoading } = useQuery({ - queryKey: ['products', page, search, category], + queryKey: ['products', page, search, category, minPrice, maxPrice], queryFn: () => apiClient.get(apiClient.endpoints.shop.products, { page, per_page: 12, search, category, + min_price: minPrice, + max_price: maxPrice, }), }); @@ -135,57 +166,177 @@ export default function Shop() {

Browse our collection of products

- {/* Filters */} - {(elements.search_bar || elements.category_filter) && ( -
- {/* Search */} - {elements.search_bar && ( -
- - setSearch(e.target.value)} - className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" - /> - {search && ( - + {/* Main Content Area */} +
+ + {/* Rich Sidebar */} + {isRichSidebar && (elements.search_bar || elements.category_filter) && ( +
+

Filters

+ + {/* Search */} + {elements.search_bar && ( +
+ + setSearch(e.target.value)} + className="!pl-10 pr-10" + /> + {search && ( + + )} +
+ )} + + {/* Categories */} + {elements.category_filter && categories && categories.length > 0 && ( +
+

Categories

+
+ + {categories.map((cat: any) => ( + + ))} +
+
+ )} + + {/* Price Filter */} + {isRichSidebar && ( +
+

Price Range

+
+ setMinPriceInput(e.target.value)} + className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none" + /> + - + setMaxPriceInput(e.target.value)} + className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none" + /> +
+
+ )} + + {/* Clear Filters */} + {(search || category || minPrice || maxPrice) && ( + + )} +
+ )} + + {/* Product Grid Area */} +
+ {/* Top Bar Filters (Basic Layout) */} + {!isRichSidebar && (elements.search_bar || elements.category_filter || elements.sort_dropdown) && ( +
+ {/* Search */} + {elements.search_bar && ( +
+ + setSearch(e.target.value)} + className="!pl-10 pr-10" + /> + {search && ( + + )} +
+ )} + + {/* Category Filter */} + {elements.category_filter && categories && categories.length > 0 && ( +
+ + +
+ )} + + {/* Sort Dropdown */} + {elements.sort_dropdown && ( +
+ +
)}
)} - {/* Category Filter */} - {elements.category_filter && categories && categories.length > 0 && ( -
- - -
- )} - - {/* Sort Dropdown */} - {elements.sort_dropdown && ( -
+ {/* Sort Dropdown (Rich Layout) */} + {isRichSidebar && elements.sort_dropdown && ( +
)} -
- )} - {/* Products Grid */} - {productsLoading ? ( -
- {[...Array(8)].map((_, i) => ( -
-
-
-
+ {/* Products Grid */} + {productsLoading ? ( +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+ ))}
- ))} -
- ) : productsData?.products && productsData.products.length > 0 ? ( - <> -
- {productsData.products.map((product: any) => ( -
- -
- ))} -
+ ) : productsData?.products && productsData.products.length > 0 ? ( + <> + {isMasonry ? ( +
+ {(() => { + const currentCols = parseInt(gridCols[breakpoint] || (breakpoint === 'mobile' ? '2' : breakpoint === 'tablet' ? '3' : '4')); + // Use a safe column count fallback (e.g. at least 1) + const cols = Math.max(1, currentCols); + const masonryColumns: any[][] = Array.from({ length: cols }, () => []); + productsData.products.forEach((p: any, i: number) => { + masonryColumns[i % cols].push(p); + }); + return masonryColumns.map((col, colIndex) => ( +
+ {col.map((product) => ( + + ))} +
+ )); + })()} +
+ ) : ( +
+ {productsData.products.map((product: any) => ( +
+ +
+ ))} +
+ )} - {/* Pagination */} - {productsData.total_pages > 1 && ( -
- - - Page {page} of {productsData.total_pages} - - + {/* Pagination */} + {productsData.total_pages > 1 && ( +
+ + + Page {page} of {productsData.total_pages} + + +
+ )} + + ) : ( +
+

No products found

+ {!isRichSidebar && (search || category || minPrice || maxPrice) && ( + + )}
)} - - ) : ( -
-

No products found

- {(search || category) && ( - - )}
- )} +
); } diff --git a/includes/Admin/AppearanceController.php b/includes/Admin/AppearanceController.php index c774bf5..4c692e1 100644 --- a/includes/Admin/AppearanceController.php +++ b/includes/Admin/AppearanceController.php @@ -356,6 +356,7 @@ class AppearanceController 'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'), 'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'), 'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'), + 'filter_layout' => sanitize_text_field($data['layout']['filter_layout'] ?? 'basic'), ], 'elements' => [ 'category_filter' => (bool) ($data['elements']['category_filter'] ?? true), @@ -588,6 +589,7 @@ class AppearanceController 'grid_columns' => '3', 'card_style' => 'card', 'aspect_ratio' => 'square', + 'filter_layout' => 'basic', ], 'elements' => [ 'category_filter' => true, diff --git a/includes/Frontend/ShopController.php b/includes/Frontend/ShopController.php index dec4283..16923d5 100644 --- a/includes/Frontend/ShopController.php +++ b/includes/Frontend/ShopController.php @@ -54,6 +54,14 @@ class ShopController 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ], + 'min_price' => [ + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'max_price' => [ + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ], ], ]); @@ -106,6 +114,8 @@ class ShopController $slug = $request->get_param('slug'); $include = $request->get_param('include'); $exclude = $request->get_param('exclude'); + $min_price = $request->get_param('min_price'); + $max_price = $request->get_param('max_price'); $args = [ 'post_type' => 'product', @@ -152,6 +162,30 @@ class ShopController $args['s'] = $search; } + // Add price filter + if ($min_price !== '' || $max_price !== '') { + $price_query = [ + 'key' => '_price', + 'type' => 'NUMERIC', + ]; + + if ($min_price !== '' && $max_price !== '') { + $price_query['compare'] = 'BETWEEN'; + $price_query['value'] = [(float)$min_price, (float)$max_price]; + } elseif ($min_price !== '') { + $price_query['compare'] = '>='; + $price_query['value'] = (float)$min_price; + } else { + $price_query['compare'] = '<='; + $price_query['value'] = (float)$max_price; + } + + if (!isset($args['meta_query'])) { + $args['meta_query'] = []; + } + $args['meta_query'][] = $price_query; + } + $query = new \WP_Query($args); // Check if this is a single product request (by slug)