Misc fixes: Storefront shop layout, product filter UI improvements, and cart badge styling

This commit is contained in:
Dwindi Ramadhana
2026-06-02 19:37:50 +07:00
parent dcdd6d8cac
commit fd8eb38512
10 changed files with 402 additions and 131 deletions

View File

@@ -70,9 +70,9 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
if (isLoading) {
return (
<div 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" />
<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>
);
}
@@ -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) {
<Link to={`/product/${product.slug}`} className="group h-full">
<div className={`${cardClasses} h-full flex flex-col`}>
{/* Image */}
<div className={`relative w-full overflow-hidden bg-gray-100 ${aspectRatioClass}`}>
<div className={`relative w-full overflow-hidden bg-muted ${aspectRatioClass}`}>
{product.image ? (
<img
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"
/>
) : (
<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
</div>
)}
@@ -146,12 +146,14 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
<div className="absolute top-2 left-2 z-10">
<button
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'}
>
<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>
</div>
)}
@@ -174,7 +176,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
{/* Content */}
<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}
</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' : ''}`}>
{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)}
</span>
<span className="text-xs text-gray-500 line-through">
<span className="text-xs text-muted-foreground line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-base font-bold text-gray-900">
<span className="text-base font-bold text-foreground">
{formatPrice(product.price)}
</span>
)}

View File

@@ -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,

View 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;
}

View File

@@ -166,13 +166,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<div className="relative">
<ShoppingCart className="h-5 w-5" />
{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}
</span>
)}
</div>
<span className="hidden lg:block">
Cart ({itemCount})
Cart
</span>
</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">
<ShoppingCart className="h-5 w-5" />
{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}
</span>
)}
@@ -497,7 +497,15 @@ function ModernLayout({ children }: BaseLayoutProps) {
)}
{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">
<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>
)}
</nav>
@@ -657,7 +665,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
)}
{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">
<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>
)}
</nav>

View File

@@ -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>
);
}