Misc fixes: Storefront shop layout, product filter UI improvements, and cart badge styling
This commit is contained in:
@@ -19,6 +19,7 @@ export default function AppearanceShop() {
|
|||||||
const [gridStyle, setGridStyle] = useState('standard');
|
const [gridStyle, setGridStyle] = useState('standard');
|
||||||
const [cardStyle, setCardStyle] = useState('card');
|
const [cardStyle, setCardStyle] = useState('card');
|
||||||
const [aspectRatio, setAspectRatio] = useState('square');
|
const [aspectRatio, setAspectRatio] = useState('square');
|
||||||
|
const [filterLayout, setFilterLayout] = useState('basic');
|
||||||
|
|
||||||
const [elements, setElements] = useState({
|
const [elements, setElements] = useState({
|
||||||
category_filter: true,
|
category_filter: true,
|
||||||
@@ -50,6 +51,7 @@ export default function AppearanceShop() {
|
|||||||
setCardStyle(shop.layout?.card_style || 'card');
|
setCardStyle(shop.layout?.card_style || 'card');
|
||||||
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
||||||
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
||||||
|
setFilterLayout(shop.layout?.filter_layout || 'basic');
|
||||||
|
|
||||||
if (shop.elements) {
|
if (shop.elements) {
|
||||||
setElements(shop.elements);
|
setElements(shop.elements);
|
||||||
@@ -83,7 +85,8 @@ export default function AppearanceShop() {
|
|||||||
grid_style: gridStyle,
|
grid_style: gridStyle,
|
||||||
card_style: cardStyle,
|
card_style: cardStyle,
|
||||||
aspect_ratio: aspectRatio,
|
aspect_ratio: aspectRatio,
|
||||||
card_text_align: cardTextAlign
|
card_text_align: cardTextAlign,
|
||||||
|
filter_layout: filterLayout
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: elements.category_filter,
|
category_filter: elements.category_filter,
|
||||||
@@ -181,6 +184,18 @@ export default function AppearanceShop() {
|
|||||||
</Select>
|
</Select>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection label="Filter Layout" htmlFor="filter-layout" description="Choose how catalog filters are presented">
|
||||||
|
<Select value={filterLayout} onValueChange={setFilterLayout}>
|
||||||
|
<SelectTrigger id="filter-layout">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="basic">Basic - Horizontal Top Bar</SelectItem>
|
||||||
|
<SelectItem value="rich_sidebar">Rich - Left Sidebar</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
||||||
<Select value={cardStyle} onValueChange={setCardStyle}>
|
<Select value={cardStyle} onValueChange={setCardStyle}>
|
||||||
<SelectTrigger id="card-style">
|
<SelectTrigger id="card-style">
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function SoftwareVersions() {
|
|||||||
placeholder={__('Search products...')}
|
placeholder={__('Search products...')}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="!pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit, Search } from 'lucide-react';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -253,6 +254,16 @@ export default function Products() {
|
|||||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search products...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-[200px] lg:w-[250px] !pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
<div className="h-4 bg-muted rounded mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,17 +91,17 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
if (cardStyle === 'minimal') {
|
if (cardStyle === 'minimal') {
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'overflow-hidden hover:opacity-90 transition-opacity'
|
? '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') {
|
if (cardStyle === 'overlay') {
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md'
|
? '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
|
// Default 'card' style
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white'
|
? 'border border-border rounded-md overflow-hidden hover:shadow-md transition-shadow bg-card'
|
||||||
: 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white';
|
: 'border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-card';
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardClasses = getCardClasses();
|
const cardClasses = getCardClasses();
|
||||||
@@ -118,7 +118,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<Link to={`/product/${product.slug}`} className="group h-full">
|
<Link to={`/product/${product.slug}`} className="group h-full">
|
||||||
<div className={`${cardClasses} h-full flex flex-col`}>
|
<div className={`${cardClasses} h-full flex flex-col`}>
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className={`relative w-full overflow-hidden bg-gray-100 ${aspectRatioClass}`}>
|
<div className={`relative w-full overflow-hidden bg-muted ${aspectRatioClass}`}>
|
||||||
{product.image ? (
|
{product.image ? (
|
||||||
<img
|
<img
|
||||||
src={product.image}
|
src={product.image}
|
||||||
@@ -126,7 +126,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
No Image
|
No Image
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,12 +146,14 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className="absolute top-2 left-2 z-10">
|
<div className="absolute top-2 left-2 z-10">
|
||||||
<button
|
<button
|
||||||
onClick={handleWishlistClick}
|
onClick={handleWishlistClick}
|
||||||
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
|
className={`font-[inherit] p-2 rounded-full shadow-md border flex items-center justify-center transition-all ${
|
||||||
|
inWishlist
|
||||||
|
? 'bg-red-50 border-red-100 dark:bg-red-950 dark:border-red-900'
|
||||||
|
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||||
}`}
|
}`}
|
||||||
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
|
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
|
||||||
>
|
>
|
||||||
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
|
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''}`} />
|
||||||
}`} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,7 +176,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -182,15 +184,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
||||||
{product.on_sale && product.regular_price ? (
|
{product.on_sale && product.regular_price ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-base font-bold" style={{ color: 'var(--color-primary)' }}>
|
<span className="text-base font-bold text-primary">
|
||||||
{formatPrice(product.sale_price || product.price)}
|
{formatPrice(product.sale_price || product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 line-through">
|
<span className="text-xs text-muted-foreground line-through">
|
||||||
{formatPrice(product.regular_price)}
|
{formatPrice(product.regular_price)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-base font-bold text-gray-900">
|
<span className="text-base font-bold text-foreground">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface AppearanceSettings {
|
|||||||
grid_columns: string;
|
grid_columns: string;
|
||||||
card_style: string;
|
card_style: string;
|
||||||
aspect_ratio: string;
|
aspect_ratio: string;
|
||||||
|
filter_layout?: 'basic' | 'rich_sidebar';
|
||||||
};
|
};
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: boolean;
|
category_filter: boolean;
|
||||||
@@ -86,6 +87,7 @@ export function useShopSettings() {
|
|||||||
card_style: 'card' as string,
|
card_style: 'card' as string,
|
||||||
aspect_ratio: 'square' as string,
|
aspect_ratio: 'square' as string,
|
||||||
card_text_align: 'left' as string,
|
card_text_align: 'left' as string,
|
||||||
|
filter_layout: 'basic' as 'basic' | 'rich_sidebar',
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: true,
|
category_filter: true,
|
||||||
|
|||||||
17
customer-spa/src/hooks/useDebounce.ts
Normal file
17
customer-spa/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -166,13 +166,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-medium">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden lg:block">
|
<span className="hidden lg:block">
|
||||||
Cart ({itemCount})
|
Cart
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -497,7 +497,15 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<div className="relative">
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>Cart</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -657,7 +665,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<div className="relative">
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>Cart</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -8,16 +8,37 @@ import { Button } from '@/components/ui/button';
|
|||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { ProductCard } from '@/components/ProductCard';
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import type { ProductsResponse, ProductCategory } from '@/types/product';
|
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() {
|
export default function Shop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { layout: shopLayout, elements } = useShopSettings();
|
const { layout: shopLayout, elements } = useShopSettings();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = 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 [sortBy, setSortBy] = useState('');
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
|
||||||
@@ -73,15 +94,25 @@ export default function Shop() {
|
|||||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||||
|
|
||||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
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
|
// Fetch products
|
||||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||||
queryKey: ['products', page, search, category],
|
queryKey: ['products', page, search, category, minPrice, maxPrice],
|
||||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
||||||
page,
|
page,
|
||||||
per_page: 12,
|
per_page: 12,
|
||||||
search,
|
search,
|
||||||
category,
|
category,
|
||||||
|
min_price: minPrice,
|
||||||
|
max_price: maxPrice,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,24 +166,124 @@ export default function Shop() {
|
|||||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Main Content Area */}
|
||||||
{(elements.search_bar || elements.category_filter) && (
|
<div className={isRichSidebar ? "flex flex-col lg:flex-row gap-8" : ""}>
|
||||||
|
|
||||||
|
{/* Rich Sidebar */}
|
||||||
|
{isRichSidebar && (elements.search_bar || elements.category_filter) && (
|
||||||
|
<div className="w-full lg:w-64 flex-shrink-0 space-y-6 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto no-scrollbar">
|
||||||
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Filters</h2>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{elements.search_bar && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="!pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{elements.category_filter && categories && categories.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Categories</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
className={`text-left text-sm hover:text-primary transition-colors ${!category ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||||
|
onClick={() => setCategory('')}
|
||||||
|
>
|
||||||
|
All Categories
|
||||||
|
</button>
|
||||||
|
{categories.map((cat: any) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={`text-left text-sm hover:text-primary transition-colors flex justify-between items-center ${category === cat.slug ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||||
|
onClick={() => setCategory(cat.slug)}
|
||||||
|
>
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground">{cat.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price Filter */}
|
||||||
|
{isRichSidebar && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Price Range</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
value={minPriceInput}
|
||||||
|
onChange={(e) => setMinPriceInput(e.target.value)}
|
||||||
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
value={maxPriceInput}
|
||||||
|
onChange={(e) => setMaxPriceInput(e.target.value)}
|
||||||
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{(search || category || minPrice || maxPrice) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setCategory('');
|
||||||
|
setMinPriceInput('');
|
||||||
|
setMaxPriceInput('');
|
||||||
|
setSortBy('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Grid Area */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Top Bar Filters (Basic Layout) */}
|
||||||
|
{!isRichSidebar && (elements.search_bar || elements.category_filter || elements.sort_dropdown) && (
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{elements.search_bar && (
|
{elements.search_bar && (
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => 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"
|
className="!pl-10 pr-10"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-muted-foreground" />
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -199,29 +330,65 @@ export default function Shop() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sort Dropdown (Rich Layout) */}
|
||||||
|
{isRichSidebar && elements.sort_dropdown && (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Default sorting</option>
|
||||||
|
<option value="popularity">Sort by popularity</option>
|
||||||
|
<option value="rating">Sort by average rating</option>
|
||||||
|
<option value="date">Sort by latest</option>
|
||||||
|
<option value="price">Sort by price: low to high</option>
|
||||||
|
<option value="price-desc">Sort by price: high to low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Products Grid */}
|
{/* Products Grid */}
|
||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className={`grid ${gridColsClass} gap-6`}>
|
<div className={`grid ${gridColsClass} gap-6`}>
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="animate-pulse">
|
<div key={i} className="animate-pulse">
|
||||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
<div className="h-4 bg-muted rounded mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : productsData?.products && productsData.products.length > 0 ? (
|
) : productsData?.products && productsData.products.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className={isMasonry ? `${masonryColsClass} gap-6` : `grid ${gridColsClass} gap-6`}>
|
{isMasonry ? (
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-${gridCols.tablet || '3'} lg:grid-cols-${gridCols.desktop || '4'} gap-6`}>
|
||||||
|
{(() => {
|
||||||
|
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) => (
|
||||||
|
<div key={colIndex} className="flex flex-col gap-6">
|
||||||
|
{col.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`grid ${productsData.products.length < parseInt(gridCols.desktop || '4') ? `grid-cols-1 sm:grid-cols-2 lg:grid-cols-${productsData.products.length}` : gridColsClass} gap-6`}>
|
||||||
{productsData.products.map((product: any) => (
|
{productsData.products.map((product: any) => (
|
||||||
<div key={product.id} className={isMasonry ? 'mb-6 break-inside-avoid' : ''}>
|
<div key={product.id}>
|
||||||
<ProductCard
|
<ProductCard product={product} onAddToCart={handleAddToCart} />
|
||||||
product={product}
|
|
||||||
onAddToCart={handleAddToCart}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{productsData.total_pages > 1 && (
|
{productsData.total_pages > 1 && (
|
||||||
@@ -249,12 +416,15 @@ export default function Shop() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-muted-foreground text-lg">No products found</p>
|
<p className="text-muted-foreground text-lg">No products found</p>
|
||||||
{(search || category) && (
|
{!isRichSidebar && (search || category || minPrice || maxPrice) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setCategory('');
|
setCategory('');
|
||||||
|
setMinPriceInput('');
|
||||||
|
setMaxPriceInput('');
|
||||||
|
setSortBy('');
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
@@ -263,6 +433,8 @@ export default function Shop() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ class AppearanceController
|
|||||||
'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'),
|
'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'),
|
||||||
'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'),
|
'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'),
|
||||||
'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'),
|
'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'),
|
||||||
|
'filter_layout' => sanitize_text_field($data['layout']['filter_layout'] ?? 'basic'),
|
||||||
],
|
],
|
||||||
'elements' => [
|
'elements' => [
|
||||||
'category_filter' => (bool) ($data['elements']['category_filter'] ?? true),
|
'category_filter' => (bool) ($data['elements']['category_filter'] ?? true),
|
||||||
@@ -588,6 +589,7 @@ class AppearanceController
|
|||||||
'grid_columns' => '3',
|
'grid_columns' => '3',
|
||||||
'card_style' => 'card',
|
'card_style' => 'card',
|
||||||
'aspect_ratio' => 'square',
|
'aspect_ratio' => 'square',
|
||||||
|
'filter_layout' => 'basic',
|
||||||
],
|
],
|
||||||
'elements' => [
|
'elements' => [
|
||||||
'category_filter' => true,
|
'category_filter' => true,
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ class ShopController
|
|||||||
'default' => '',
|
'default' => '',
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'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');
|
$slug = $request->get_param('slug');
|
||||||
$include = $request->get_param('include');
|
$include = $request->get_param('include');
|
||||||
$exclude = $request->get_param('exclude');
|
$exclude = $request->get_param('exclude');
|
||||||
|
$min_price = $request->get_param('min_price');
|
||||||
|
$max_price = $request->get_param('max_price');
|
||||||
|
|
||||||
$args = [
|
$args = [
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
@@ -152,6 +162,30 @@ class ShopController
|
|||||||
$args['s'] = $search;
|
$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);
|
$query = new \WP_Query($args);
|
||||||
|
|
||||||
// Check if this is a single product request (by slug)
|
// Check if this is a single product request (by slug)
|
||||||
|
|||||||
Reference in New Issue
Block a user