Template
Add collection filters and sort, out-of-stock options, gallery drag
- Collections: filters and sort via the Storefront API — the products connection now takes `filters`/`after` and returns its facets, with cursor-based load more on the collection page - Extract ProductFilters (renamed from SearchFilters) and a shared ProductToolbar so search and collections use the same chrome - PDP: mark option values with no in-stock variant using a diagonal strike, computed against the other selected options - PDP: pointer drag-scrolling for the mobile image carousel, since a scroll container doesn't drag with a mouse - Header/cart: circular icon buttons, tighter spacing, and icon sizes set by class (Button's base CSS clamps un-classed svgs to size-4) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc901b9ce8
commit
2ef5639a2e
@@ -126,10 +126,11 @@ const CartDrawer: React.FC = () => {
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
aria-label="Close cart"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
<RiCloseLine className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
@@ -138,7 +139,7 @@ const CartDrawer: React.FC = () => {
|
||||
<SheetBody className="px-5">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader size={32} />
|
||||
<Loader size={20} />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty>
|
||||
|
||||
@@ -1,89 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
||||
import ProductCard from './product-card';
|
||||
import ProductFilters, { type ProductFilterFacet } from './product-filters';
|
||||
import ProductToolbar from './product-toolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import {
|
||||
getCollectionProductsPage,
|
||||
type CollectionSortKey,
|
||||
} from '@/hooks/use-shopify-collections';
|
||||
import type { Product } from '@/hooks/use-shopify-products';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
const GRID_CLASSES =
|
||||
'grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16';
|
||||
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
|
||||
|
||||
const CollectionTitle: React.FC<{ title: string }> = ({ title }) => (
|
||||
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground mb-16">
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
interface SortOption {
|
||||
label: string;
|
||||
sortKey: CollectionSortKey;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
|
||||
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
|
||||
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
|
||||
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
|
||||
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
|
||||
];
|
||||
|
||||
const CollectionDetail: React.FC = () => {
|
||||
const params = useParams();
|
||||
const handle = params?.handle as string;
|
||||
|
||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
// Format title from handle
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const sort = SORT_OPTIONS[sortIndex];
|
||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||
|
||||
// Fall back to the handle until the collection's real title arrives.
|
||||
const formattedTitle = handle
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
: 'Collection';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<CollectionTitle title={formattedTitle} />
|
||||
useEffect(() => {
|
||||
if (!handle) return;
|
||||
let cancelled = false;
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div className={GRID_CLASSES}>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="h-4 bg-zinc-200 w-4/5"></div>
|
||||
<div className="h-4 bg-zinc-200 w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||
<CollectionTitle title={formattedTitle} />
|
||||
<p className="text-sm text-muted-foreground mb-6">{error}</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const page = await getCollectionProductsPage(handle, {
|
||||
first: PAGE_SIZE,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
const products = collection?.products || [];
|
||||
const title = collection?.title || formattedTitle;
|
||||
if (cancelled) return;
|
||||
|
||||
if (!page.collection) {
|
||||
setError('Collection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setTitle(page.collection.title);
|
||||
setProducts(page.products);
|
||||
setCursor(page.endCursor);
|
||||
setHasNextPage(page.hasNextPage);
|
||||
// Keep the facet list stable while a selection is active, so options
|
||||
// don't disappear out from under the panel.
|
||||
if (activeFilters.length === 0) setFilters(page.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Error fetching collection products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load collection');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle, sort.sortKey, sort.reverse, activeKey]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (loadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const page = await getCollectionProductsPage(handle, {
|
||||
first: PAGE_SIZE,
|
||||
after: cursor,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
setProducts((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
|
||||
});
|
||||
setCursor(page.endCursor);
|
||||
setHasNextPage(page.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more products:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-16 bg-background">
|
||||
<section className="bg-background py-10">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<CollectionTitle title={title} />
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{title || formattedTitle}
|
||||
</h1>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
This collection doesn't have any products yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className={GRID_CLASSES}>
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<ProductToolbar
|
||||
totalCount={products.length > 0 ? products.length : null}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
sortIndex={sortIndex}
|
||||
onSortChange={setSortIndex}
|
||||
activeFilterCount={activeFilters.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className={GRID_CLASSES}>
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="h-4 w-4/5 bg-zinc-200"></div>
|
||||
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeFilters.length > 0
|
||||
? 'No products matched your filters.'
|
||||
: "This collection doesn't have any products yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={GRID_CLASSES}>
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMore && <Loader size={16} />}
|
||||
{loadingMore ? 'Loading' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
onActiveFiltersChange={setActiveFilters}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ const CartIcon: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Open bag (${itemCount})`}
|
||||
className="relative"
|
||||
className="relative rounded-full"
|
||||
>
|
||||
<RiShoppingBagLine size={20} />
|
||||
<RiShoppingBagLine className="size-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{itemCount > 99 ? '99+' : itemCount}
|
||||
@@ -86,7 +86,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center">
|
||||
<SearchDialog />
|
||||
<CartIcon />
|
||||
|
||||
@@ -98,7 +98,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
className="md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
|
||||
{menuOpen ? (
|
||||
<RiCloseLine className="size-5" />
|
||||
) : (
|
||||
<RiMenu3Line className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,24 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// A value is available if some in-stock variant carries it alongside the
|
||||
// other currently-selected options. Options the shopper hasn't chosen yet
|
||||
// act as wildcards, so nothing is struck through before a full selection.
|
||||
const isOptionValueAvailable = (optionName: string, value: string) => {
|
||||
const variants = product?.variants.edges ?? [];
|
||||
if (variants.length === 0) return true;
|
||||
|
||||
return variants.some(({ node }) => {
|
||||
if (!node.availableForSale) return false;
|
||||
|
||||
return node.selectedOptions.every((option) => {
|
||||
if (option.name === optionName) return option.value === value;
|
||||
const selected = selectedOptions[option.name];
|
||||
return selected === undefined || selected === option.value;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptionChange = (optionName: string, value: string) => {
|
||||
const newOptions = { ...selectedOptions, [optionName]: value };
|
||||
setSelectedOptions(newOptions);
|
||||
@@ -197,6 +215,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
handleAddToCart={handleAddToCart}
|
||||
handleBuyNow={handleBuyNow}
|
||||
onOptionChange={handleOptionChange}
|
||||
isOptionValueAvailable={isOptionValueAvailable}
|
||||
loading={addingToCart}
|
||||
buyingNow={buyingNow}
|
||||
addToCartLabel={addToCartLabel}
|
||||
|
||||
@@ -44,6 +44,37 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
setActiveIndex(nearest);
|
||||
}, []);
|
||||
|
||||
// Touch already scrolls natively; this adds click-and-drag for pointers that
|
||||
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
|
||||
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
|
||||
scroller.style.scrollSnapType = 'none';
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!drag.current || !scroller) return;
|
||||
|
||||
event.preventDefault();
|
||||
scroller.scrollLeft =
|
||||
drag.current.startScroll - (event.clientX - drag.current.startX);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!drag.current || !scroller) return;
|
||||
|
||||
drag.current = null;
|
||||
// Restoring snap lets the browser settle on the nearest slide.
|
||||
scroller.style.scrollSnapType = '';
|
||||
};
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
const scroller = scrollerRef.current;
|
||||
const slide = scroller?.children[index];
|
||||
@@ -89,7 +120,12 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar sm:grid sm:grid-cols-2 sm:overflow-visible"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerLeave={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto sm:overflow-visible"
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
@@ -103,7 +139,8 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
className="w-full h-full object-cover select-none"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -51,6 +51,8 @@ interface ProductDetailInfoProps {
|
||||
handleAddToCart: () => void;
|
||||
handleBuyNow?: () => void;
|
||||
onOptionChange: (optionName: string, value: string) => void;
|
||||
/** Whether an option value still has an in-stock variant behind it. */
|
||||
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
|
||||
loading?: boolean;
|
||||
buyingNow?: boolean;
|
||||
addToCartLabel?: string;
|
||||
@@ -81,6 +83,7 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
handleAddToCart,
|
||||
handleBuyNow,
|
||||
onOptionChange,
|
||||
isOptionValueAvailable,
|
||||
loading = false,
|
||||
buyingNow = false,
|
||||
addToCartLabel = 'Add to Cart',
|
||||
@@ -143,6 +146,8 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionValuesFor(option).map((value) => {
|
||||
const isSelected = selected === value.name;
|
||||
const isSoldOut =
|
||||
isOptionValueAvailable?.(option.name, value.name) === false;
|
||||
|
||||
if (isSwatch) {
|
||||
const { background, image } = swatchStyle(value);
|
||||
@@ -153,14 +158,17 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={value.name}
|
||||
aria-label={value.name}
|
||||
aria-pressed={isSelected}
|
||||
title={
|
||||
isSoldOut ? `${value.name} — out of stock` : value.name
|
||||
}
|
||||
data-available={!isSoldOut}
|
||||
className={`rounded-full bg-cover bg-center hover:bg-transparent ${
|
||||
isSelected
|
||||
? 'ring-2 ring-foreground ring-offset-2'
|
||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||
}`}
|
||||
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
|
||||
style={{
|
||||
backgroundColor: background,
|
||||
backgroundImage: image ? `url(${image})` : undefined,
|
||||
@@ -179,11 +187,12 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
variant="outline"
|
||||
aria-pressed={isSelected}
|
||||
title={isSoldOut ? `${value.name} — out of stock` : undefined}
|
||||
className={`min-w-14 px-5 font-normal shadow-none ${
|
||||
isSelected
|
||||
? 'border-foreground text-foreground'
|
||||
: 'text-muted-foreground hover:border-foreground hover:text-foreground'
|
||||
}`}
|
||||
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
|
||||
>
|
||||
{value.name}
|
||||
</Button>
|
||||
|
||||
@@ -12,21 +12,36 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
||||
import { swatchColorForName } from '@/config/swatches';
|
||||
import type { SearchFilter, SearchFilterValue } from '@/hooks/use-shopify-search';
|
||||
// Shape of a Storefront facet — identical for `search.productFilters` and
|
||||
// `collection.products.filters`, so both pages share this panel.
|
||||
export interface ProductFilterValue {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
/** JSON string accepted back as a `ProductFilter` input. */
|
||||
input: string;
|
||||
}
|
||||
|
||||
interface SearchFiltersProps {
|
||||
export interface ProductFilterFacet {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
|
||||
values: ProductFilterValue[];
|
||||
}
|
||||
|
||||
interface ProductFiltersProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: SearchFilter[];
|
||||
filters: ProductFilterFacet[];
|
||||
activeFilters: string[];
|
||||
onActiveFiltersChange: (filters: string[]) => void;
|
||||
}
|
||||
|
||||
// Colour facets render as swatches; everything else as a labelled list.
|
||||
const isColorFilter = (filter: SearchFilter) =>
|
||||
const isColorFilter = (filter: ProductFilterFacet) =>
|
||||
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
||||
|
||||
const SearchFilters: React.FC<SearchFiltersProps> = ({
|
||||
const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
@@ -45,7 +60,7 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
|
||||
input.includes('"price"')
|
||||
);
|
||||
|
||||
const toggleValue = (value: SearchFilterValue) => {
|
||||
const toggleValue = (value: ProductFilterValue) => {
|
||||
onActiveFiltersChange(
|
||||
activeSet.has(value.input)
|
||||
? activeFilters.filter((input) => input !== value.input)
|
||||
@@ -235,4 +250,4 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilters;
|
||||
export default ProductFilters;
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
RiEqualizerLine,
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
export interface ToolbarSortOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProductToolbarProps {
|
||||
totalCount?: number | null;
|
||||
onOpenFilters: () => void;
|
||||
sortOptions: ToolbarSortOption[];
|
||||
sortIndex: number;
|
||||
onSortChange: (index: number) => void;
|
||||
activeFilterCount?: number;
|
||||
}
|
||||
|
||||
// Filters trigger on the left, item count and sort menu on the right — shared
|
||||
// by the search results and collection pages.
|
||||
const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
||||
totalCount,
|
||||
onOpenFilters,
|
||||
sortOptions,
|
||||
sortIndex,
|
||||
onSortChange,
|
||||
activeFilterCount = 0,
|
||||
}) => {
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
onClick={onOpenFilters}
|
||||
variant="ghost"
|
||||
className="gap-x-2 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
<RiEqualizerLine size={18} />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
{typeof totalCount === 'number' && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setSortOpen((prev) => !prev)}
|
||||
variant="ghost"
|
||||
aria-expanded={sortOpen}
|
||||
className="gap-x-1 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
Sort
|
||||
<RiArrowDownSLine size={16} />
|
||||
</Button>
|
||||
|
||||
{sortOpen && (
|
||||
<>
|
||||
{/* Click-away layer sits under the menu, above the page. */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSortOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
|
||||
{sortOptions.map((option, index) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
onSortChange(index);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
{option.label}
|
||||
{index === sortIndex && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductToolbar;
|
||||
@@ -86,8 +86,9 @@ const SearchDialog: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Search"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiSearchLine size={20} />
|
||||
<RiSearchLine className="size-5" />
|
||||
</Button>
|
||||
|
||||
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ProductCard from './product-card';
|
||||
import SearchFilters from './search-filters';
|
||||
import ProductFilters from './product-filters';
|
||||
import ProductToolbar from './product-toolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import { RiEqualizerLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
|
||||
import {
|
||||
searchProducts,
|
||||
type SearchFilter,
|
||||
@@ -42,7 +42,6 @@ const SearchResults: React.FC = () => {
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
@@ -126,58 +125,15 @@ const SearchResults: React.FC = () => {
|
||||
Search
|
||||
</h1>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
variant="ghost"
|
||||
className="gap-x-2 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
<RiEqualizerLine size={18} />
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
|
||||
</span>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setSortOpen((prev) => !prev)}
|
||||
variant="ghost"
|
||||
aria-expanded={sortOpen}
|
||||
className="gap-x-1 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
Sort
|
||||
<RiArrowDownSLine size={16} />
|
||||
</Button>
|
||||
|
||||
{sortOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSortOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
|
||||
{SORT_OPTIONS.map((option, index) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
setSortIndex(index);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
{option.label}
|
||||
{index === sortIndex && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<ProductToolbar
|
||||
totalCount={totalCount}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
sortIndex={sortIndex}
|
||||
onSortChange={setSortIndex}
|
||||
activeFilterCount={activeFilters.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
@@ -227,7 +183,7 @@ const SearchResults: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchFilters
|
||||
<ProductFilters
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
|
||||
Reference in New Issue
Block a user