diff --git a/app/globals.css b/app/globals.css index 62a506f..ad08907 100644 --- a/app/globals.css +++ b/app/globals.css @@ -94,6 +94,17 @@ h2, display: none; } +/* Diagonal strike marking an unavailable product option (size pill, swatch) */ +.option-unavailable { + background-image: linear-gradient( + to top right, + transparent calc(50% - 0.5px), + currentColor calc(50% - 0.5px), + currentColor calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + /* Product description (HTML returned by the Storefront API) */ .product-description p { margin-bottom: 1rem; diff --git a/components/shopify/cart-drawer.tsx b/components/shopify/cart-drawer.tsx index e68b327..e175d04 100644 --- a/components/shopify/cart-drawer.tsx +++ b/components/shopify/cart-drawer.tsx @@ -126,10 +126,11 @@ const CartDrawer: React.FC = () => { @@ -138,7 +139,7 @@ const CartDrawer: React.FC = () => { {loading && items.length === 0 ? (
- +
) : items.length === 0 ? ( diff --git a/components/shopify/collection-detail.tsx b/components/shopify/collection-detail.tsx index 254e942..5c588dd 100644 --- a/components/shopify/collection-detail.tsx +++ b/components/shopify/collection-detail.tsx @@ -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 }) => ( -

- {title} -

-); +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([]); + const [filters, setFilters] = useState([]); + const [title, setTitle] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(null); + const [hasNextPage, setHasNextPage] = useState(false); - // Format title from handle + const [sortIndex, setSortIndex] = useState(0); + const [filtersOpen, setFiltersOpen] = useState(false); + const [activeFilters, setActiveFilters] = useState([]); + + 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 ( -
-
- + useEffect(() => { + if (!handle) return; + let cancelled = false; - {/* Loading Skeleton */} -
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
-
- ))} -
-
-
- ); - } + const run = async () => { + try { + setLoading(true); + setError(null); - if (error) { - return ( -
-
- -

{error}

- -
-
- ); - } + 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 ( -
+
- +

+ {title || formattedTitle} +

- {products.length === 0 ? ( -

- This collection doesn't have any products yet. -

- ) : ( -
- {products.map((product) => ( - - ))} -
- )} +
+ 0 ? products.length : null} + onOpenFilters={() => setFiltersOpen(true)} + sortOptions={SORT_OPTIONS} + sortIndex={sortIndex} + onSortChange={setSortIndex} + activeFilterCount={activeFilters.length} + /> +
+ +
+ {loading ? ( +
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +

{error}

+ ) : products.length === 0 ? ( +

+ {activeFilters.length > 0 + ? 'No products matched your filters.' + : "This collection doesn't have any products yet."} +

+ ) : ( + <> +
+ {products.map((product) => ( + + ))} +
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
-
+ + + ); }; diff --git a/components/shopify/header.tsx b/components/shopify/header.tsx index 5e75d6f..ebcf3d5 100644 --- a/components/shopify/header.tsx +++ b/components/shopify/header.tsx @@ -20,9 +20,9 @@ const CartIcon: React.FC = () => { variant="ghost" size="icon" aria-label={`Open bag (${itemCount})`} - className="relative" + className="relative rounded-full" > - + {itemCount > 0 && ( {itemCount > 99 ? '99+' : itemCount} @@ -86,7 +86,7 @@ const Header: React.FC = ({ {/* Actions */} -
+
@@ -98,7 +98,11 @@ const Header: React.FC = ({ className="md:hidden" aria-label="Toggle menu" > - {menuOpen ? : } + {menuOpen ? ( + + ) : ( + + )}
diff --git a/components/shopify/product-detail/index.tsx b/components/shopify/product-detail/index.tsx index 2474704..43b53a1 100644 --- a/components/shopify/product-detail/index.tsx +++ b/components/shopify/product-detail/index.tsx @@ -78,6 +78,24 @@ const ProductDetail: React.FC = ({ } }, [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 = ({ handleAddToCart={handleAddToCart} handleBuyNow={handleBuyNow} onOptionChange={handleOptionChange} + isOptionValueAvailable={isOptionValueAvailable} loading={addingToCart} buyingNow={buyingNow} addToCartLabel={addToCartLabel} diff --git a/components/shopify/product-detail/product-detail-gallery.tsx b/components/shopify/product-detail/product-detail-gallery.tsx index c64ff8d..905a501 100644 --- a/components/shopify/product-detail/product-detail-gallery.tsx +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -44,6 +44,37 @@ const ProductDetailGallery: React.FC = ({ 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) => { + 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) => { + 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 = ({
{images.map((image, index) => ( ))} diff --git a/components/shopify/product-detail/product-detail-info.tsx b/components/shopify/product-detail/product-detail-info.tsx index 7b8a98b..c701d95 100644 --- a/components/shopify/product-detail/product-detail-info.tsx +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -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 = ({ handleAddToCart, handleBuyNow, onOptionChange, + isOptionValueAvailable, loading = false, buyingNow = false, addToCartLabel = 'Add to Cart', @@ -143,6 +146,8 @@ const ProductDetailInfo: React.FC = ({
{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 = ({ 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 = ({ 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} diff --git a/components/shopify/search-filters.tsx b/components/shopify/product-filters.tsx similarity index 92% rename from components/shopify/search-filters.tsx rename to components/shopify/product-filters.tsx index 4d2ab81..415767c 100644 --- a/components/shopify/search-filters.tsx +++ b/components/shopify/product-filters.tsx @@ -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 = ({ +const ProductFilters: React.FC = ({ open, onOpenChange, filters, @@ -45,7 +60,7 @@ const SearchFilters: React.FC = ({ 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 = ({ ); }; -export default SearchFilters; +export default ProductFilters; diff --git a/components/shopify/product-toolbar.tsx b/components/shopify/product-toolbar.tsx new file mode 100644 index 0000000..be8e784 --- /dev/null +++ b/components/shopify/product-toolbar.tsx @@ -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 = ({ + totalCount, + onOpenFilters, + sortOptions, + sortIndex, + onSortChange, + activeFilterCount = 0, +}) => { + const [sortOpen, setSortOpen] = useState(false); + + return ( +
+ + +
+ {typeof totalCount === 'number' && ( + + {totalCount} {totalCount === 1 ? 'Item' : 'Items'} + + )} + +
+ + + {sortOpen && ( + <> + {/* Click-away layer sits under the menu, above the page. */} +
setSortOpen(false)} + /> +
+ {sortOptions.map((option, index) => ( + + ))} +
+ + )} +
+
+
+ ); +}; + +export default ProductToolbar; diff --git a/components/shopify/search-dialog.tsx b/components/shopify/search-dialog.tsx index 2675b41..3f867a4 100644 --- a/components/shopify/search-dialog.tsx +++ b/components/shopify/search-dialog.tsx @@ -86,8 +86,9 @@ const SearchDialog: React.FC = () => { variant="ghost" size="icon" aria-label="Search" + className="rounded-full" > - + !next && close()}> diff --git a/components/shopify/search-results.tsx b/components/shopify/search-results.tsx index a26d3c3..2de4556 100644 --- a/components/shopify/search-results.tsx +++ b/components/shopify/search-results.tsx @@ -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([]); @@ -126,58 +125,15 @@ const SearchResults: React.FC = () => { Search - {/* Toolbar */} -
- - -
- - {totalCount} {totalCount === 1 ? 'Item' : 'Items'} - - -
- - - {sortOpen && ( - <> -
setSortOpen(false)} - /> -
- {SORT_OPTIONS.map((option, index) => ( - - ))} -
- - )} -
-
+
+ setFiltersOpen(true)} + sortOptions={SORT_OPTIONS} + sortIndex={sortIndex} + onSortChange={setSortIndex} + activeFilterCount={activeFilters.length} + />
{/* Results */} @@ -227,7 +183,7 @@ const SearchResults: React.FC = () => {
- ; } // Fetch all collections @@ -45,19 +75,65 @@ export async function getCollections(first = 50): Promise { // Fetch products in a collection by handle export async function getCollectionProducts( handle: string, - { first = 50, sortKey = 'BEST_SELLING', reverse = false }: UseCollectionProductsOptions = {} + options: UseCollectionProductsOptions = {} ): Promise { + const page = await getCollectionProductsPage(handle, options); + if (!page.collection) return null; + + return { ...page.collection, products: page.products }; +} + +// Same fetch, but keeps the cursor and facet list for filtering and paging. +export async function getCollectionProductsPage( + handle: string, + { + first = 50, + after = null, + sortKey = 'COLLECTION_DEFAULT', + reverse = false, + filterInputs = [], + }: UseCollectionProductsOptions = {} +): Promise { + const filters = filterInputs.flatMap((input) => { + try { + return [JSON.parse(input)]; + } catch { + console.warn('Ignoring malformed product filter input:', input); + return []; + } + }); + const response = await shopifyFetch({ query: GET_COLLECTION_PRODUCTS_QUERY, - variables: { handle, first, sortKey, reverse }, + variables: { + handle, + first, + after, + sortKey, + reverse, + filters: filters.length ? filters : null, + }, }); const collection = response.data.collection; - if (!collection) return null; + if (!collection) { + return { + collection: null, + products: [], + filters: [], + hasNextPage: false, + endCursor: null, + }; + } + + const { edges, pageInfo, filters: facets } = collection.products; return { - ...collection, - products: collection.products.edges.map((edge: { node: Product }) => edge.node), + collection, + products: edges.map((edge: { node: Product }) => edge.node), + filters: facets ?? [], + hasNextPage: Boolean(pageInfo?.hasNextPage), + endCursor: pageInfo?.endCursor ?? null, }; }