Misc fixes: Storefront shop layout, product filter UI improvements, and cart badge styling
This commit is contained in:
@@ -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<ProductsResponse>({
|
||||
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() {
|
||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{(elements.search_bar || elements.category_filter) && (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
{elements.search_bar && (
|
||||
<div className="flex-1 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="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{/* Main Content Area */}
|
||||
<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">
|
||||
{/* Search */}
|
||||
{elements.search_bar && (
|
||||
<div className="flex-1 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>
|
||||
)}
|
||||
|
||||
{/* Category Filter */}
|
||||
{elements.category_filter && categories && categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name} ({cat.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
{elements.sort_dropdown && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Filter */}
|
||||
{elements.category_filter && categories && categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name} ({cat.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
{elements.sort_dropdown && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 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"
|
||||
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>
|
||||
@@ -196,73 +347,94 @@ export default function Shop() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className={`grid ${gridColsClass} gap-6`}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className={`grid ${gridColsClass} gap-6`}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-muted rounded mb-2" />
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : productsData?.products && productsData.products.length > 0 ? (
|
||||
<>
|
||||
<div className={isMasonry ? `${masonryColsClass} gap-6` : `grid ${gridColsClass} gap-6`}>
|
||||
{productsData.products.map((product: any) => (
|
||||
<div key={product.id} className={isMasonry ? 'mb-6 break-inside-avoid' : ''}>
|
||||
<ProductCard
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : productsData?.products && productsData.products.length > 0 ? (
|
||||
<>
|
||||
{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) => (
|
||||
<div key={product.id}>
|
||||
<ProductCard product={product} onAddToCart={handleAddToCart} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{productsData.total_pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4">
|
||||
Page {page} of {productsData.total_pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(productsData.total_pages, p + 1))}
|
||||
disabled={page === productsData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
{/* Pagination */}
|
||||
{productsData.total_pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4">
|
||||
Page {page} of {productsData.total_pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(productsData.total_pages, p + 1))}
|
||||
disabled={page === productsData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No products found</p>
|
||||
{!isRichSidebar && (search || category || minPrice || maxPrice) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
setMinPriceInput('');
|
||||
setMaxPriceInput('');
|
||||
setSortBy('');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No products found</p>
|
||||
{(search || category) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user