diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index 181186fe4..04c162eb7 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -8,7 +8,7 @@ import Grid from 'components/grid'; import ProductGridItems from 'components/layout/product-grid-items'; import Filters from 'components/layout/search/filters'; import SortingMenu from 'components/layout/search/sorting-menu'; -import { defaultSort, sorting } from 'lib/constants'; +import { AVAILABILITY_FILTER_ID, PRICE_FILTER_ID, defaultSort, sorting } from 'lib/constants'; import { Suspense } from 'react'; export const runtime = 'edge'; @@ -29,6 +29,38 @@ export async function generateMetadata({ }; } +const constructFilterInput = (filters: { + [key: string]: string | string[] | undefined; +}): Array => { + const results = [] as Array; + Object.entries(filters) + .filter(([key]) => ![AVAILABILITY_FILTER_ID, PRICE_FILTER_ID].includes(key)) + .forEach(([key, value]) => { + const [namespace, metafieldKey] = key.split('.').slice(-2); + if (Array.isArray(value)) { + results.push( + ...value.map((v) => ({ + productMetafield: { + namespace, + key: metafieldKey, + value: v + } + })) + ); + } else { + results.push({ + productMetafield: { + namespace, + key: metafieldKey, + value + } + }); + } + }); + + return results; +}; + export default async function CategoryPage({ params, searchParams @@ -36,12 +68,21 @@ export default async function CategoryPage({ params: { collection: string }; searchParams?: { [key: string]: string | string[] | undefined }; }) { - const { sort } = searchParams as { [key: string]: string }; + const { sort, q, collection: _collection, ...rest } = searchParams as { [key: string]: string }; const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; - const productsData = getCollectionProducts({ collection: params.collection, sortKey, reverse }); + + const filtersInput = constructFilterInput(rest); + + const productsData = getCollectionProducts({ + collection: params.collection, + sortKey, + reverse, + ...(filtersInput.length ? { filters: filtersInput } : {}) + }); + const collectionData = getCollection(params.collection); - const [products, collection] = await Promise.all([productsData, collectionData]); + const [{ products, filters }, collection] = await Promise.all([productsData, collectionData]); return ( <> @@ -59,13 +100,13 @@ export default async function CategoryPage({
-
+
{products.length === 0 ? (

{`No products found in this collection`}

) : (
diff --git a/components/carousel.tsx b/components/carousel.tsx index 286d4dfea..f5b6e079d 100644 --- a/components/carousel.tsx +++ b/components/carousel.tsx @@ -4,7 +4,7 @@ import { GridTileImage } from './grid/tile'; export async function Carousel() { // Collections that start with `hidden-*` are hidden from the search page. - const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); + const { products } = await getCollectionProducts({ collection: 'hidden-homepage-carousel' }); if (!products?.length) return null; diff --git a/components/grid/three-items.tsx b/components/grid/three-items.tsx index 23b3f8991..8ed44b46c 100644 --- a/components/grid/three-items.tsx +++ b/components/grid/three-items.tsx @@ -39,7 +39,7 @@ function ThreeItemGridItem({ export async function ThreeItemGrid() { // Collections that start with `hidden-*` are hidden from the search page. - const homepageItems = await getCollectionProducts({ + const { products: homepageItems } = await getCollectionProducts({ collection: 'hidden-homepage-featured-items' }); diff --git a/components/layout/navbar/main-menu.tsx b/components/layout/navbar/main-menu.tsx index dff74a849..e296bc60a 100644 --- a/components/layout/navbar/main-menu.tsx +++ b/components/layout/navbar/main-menu.tsx @@ -64,10 +64,10 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => { >
-
    +
      {item.items.map((subItem: Menu) => (
    • { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleChange = (e: React.FormEvent) => { + const formData = new FormData(e.currentTarget); + const newSearchParams = new URLSearchParams(searchParams); + + Array.from(formData.keys()).forEach((key) => { + const values = formData.getAll(key); + newSearchParams.delete(key); + values.forEach((value) => newSearchParams.append(key, String(value))); + }); + + router.replace(createUrl(pathname, newSearchParams), { scroll: false }); + }; + + return ( +
      + {filters.map(({ label, id, values }) => ( +
      +
      {label}
      +
      + {values.map(({ id: valueId, label, count, value }) => ( + + ))} +
      +
      + ))} +
      + ); +}; + +export default Filters; diff --git a/components/layout/search/filters/index.tsx b/components/layout/search/filters/index.tsx index f6de03b4a..033c7f450 100644 --- a/components/layout/search/filters/index.tsx +++ b/components/layout/search/filters/index.tsx @@ -1,7 +1,9 @@ import { getMenu } from 'lib/shopify'; +import { Filter } from 'lib/shopify/types'; import Link from 'next/link'; +import FiltersList from './filters-list'; -const Filters = async ({ collection }: { collection: string }) => { +const Filters = async ({ collection, filters }: { collection: string; filters: Filter[] }) => { const menu = await getMenu('main-menu'); const subMenu = menu.find((item) => item.path === `/search/${collection}`)?.items || []; return ( @@ -23,6 +25,8 @@ const Filters = async ({ collection }: { collection: string }) => {
    ) : null} +

    Filters

    +
); }; diff --git a/components/layout/search/sorting-menu/item.tsx b/components/layout/search/sorting-menu/item.tsx index 206b32ee9..e27a9c328 100644 --- a/components/layout/search/sorting-menu/item.tsx +++ b/components/layout/search/sorting-menu/item.tsx @@ -8,15 +8,17 @@ const SortingItem = ({ item, hover }: { item: SortFilterItem; hover: boolean }) const pathname = usePathname(); const searchParams = useSearchParams(); const active = searchParams.get('sort') === item.slug; - const q = searchParams.get('q'); - const href = createUrl( - pathname, - new URLSearchParams({ - ...(q && { q }), - ...(item.slug && item.slug.length && { sort: item.slug }) - }) - ); + + const newSearchParams = new URLSearchParams(searchParams); + if (item.slug && item.slug.length) { + newSearchParams.set('sort', item.slug); + } else { + newSearchParams.delete('sort'); + } + + const href = createUrl(pathname, newSearchParams); const DynamicTag = active ? 'p' : Link; + return ( { return reshapedCollections; }; +const reshapeFilters = (filters: ShopifyFilter[]): Filter[] => { + const reshapedFilters = []; + + for (const filter of filters) { + const values = filter.values + .map((valueItem) => { + try { + return { + ...valueItem, + ...(![AVAILABILITY_FILTER_ID, PRICE_FILTER_ID].includes(filter.id) + ? { value: JSON.parse(valueItem.input).productMetafield.value } + : { value: JSON.parse(valueItem.input) }) + }; + } catch (error) { + return null; + } + }) + .filter(Boolean) as Filter['values']; + + reshapedFilters.push({ ...filter, values }); + } + + return reshapedFilters; +}; + const reshapeImages = (images: Connection, productTitle: string) => { const flattened = removeEdgesAndNodes(images); @@ -305,28 +338,34 @@ export async function getCollection(handle: string): Promise { + filters?: Array; +}): Promise<{ products: Product[]; filters: Filter[] }> { const res = await shopifyFetch({ query: getCollectionProductsQuery, tags: [TAGS.collections, TAGS.products], variables: { handle: collection, reverse, - sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey + sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey, + filters } }); if (!res.body.data.collection) { console.log(`No collection found for \`${collection}\``); - return []; + return { products: [], filters: [] }; } - return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products)); + return { + products: reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products)), + filters: reshapeFilters(res.body.data.collection.products.filters) + }; } export async function getCollections(): Promise { diff --git a/lib/shopify/queries/collection.ts b/lib/shopify/queries/collection.ts index 6396ff8eb..b291d88e8 100644 --- a/lib/shopify/queries/collection.ts +++ b/lib/shopify/queries/collection.ts @@ -41,14 +41,26 @@ export const getCollectionProductsQuery = /* GraphQL */ ` $handle: String! $sortKey: ProductCollectionSortKeys $reverse: Boolean + $filters: [ProductFilter!] ) { collection(handle: $handle) { - products(sortKey: $sortKey, reverse: $reverse, first: 100) { + products(sortKey: $sortKey, filters: $filters, reverse: $reverse, first: 100) { edges { node { ...product } } + filters { + id + label + type + values { + id + count + input + label + } + } } } } diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index ca8b4cc0b..3514a43bd 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -223,13 +223,16 @@ export type ShopifyCollectionOperation = { export type ShopifyCollectionProductsOperation = { data: { collection: { - products: Connection; + products: Connection & { + filters: ShopifyFilter[]; + }; }; }; variables: { handle: string; reverse?: boolean; sortKey?: string; + filters?: Array; }; }; @@ -296,3 +299,35 @@ export type CoreChargeOption = { value: string; price: Money; }; + +export type ShopifyFilter = { + id: string; + label: string; + type: FilterType; + values: { + id: string; + input: string; + count: number; + label: string; + }[]; +}; + +export enum FilterType { + // eslint-disable-next-line no-unused-vars + LIST = 'LIST', + // eslint-disable-next-line no-unused-vars + PRICE_RANGE = 'PRICE_RANGE' +} + +export type Filter = { + id: string; + label: string; + type: FilterType; + values: { + id: string; + input: string; + count: number; + label: string; + value: unknown; + }[]; +}; diff --git a/package.json b/package.json index f758ea0e5..d22edd629 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.11", "@types/node": "20.11.30", "@types/react": "18.2.72", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03dcf6dca..d5eb463f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ devDependencies: '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@3.4.1) + '@tailwindcss/forms': + specifier: ^0.5.7 + version: 0.5.7(tailwindcss@3.4.1) '@tailwindcss/typography': specifier: ^0.5.11 version: 0.5.11(tailwindcss@3.4.1) @@ -596,6 +599,15 @@ packages: tailwindcss: 3.4.1 dev: true + /@tailwindcss/forms@0.5.7(tailwindcss@3.4.1): + resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.1 + dev: true + /@tailwindcss/typography@0.5.11(tailwindcss@3.4.1): resolution: {integrity: sha512-ahOULqBQGCdSqL3vMNjH1R5cU2gxTh059fJIKF2enHXE8c/s3yKGDSKZ1+4poCr7BZRREJS8n5cCFmwsW4Ok3A==} peerDependencies: @@ -2559,6 +2571,11 @@ packages: engines: {node: '>=4'} dev: true + /mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: diff --git a/tailwind.config.js b/tailwind.config.js index 4a53eb14e..f6d2d5929 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -56,6 +56,7 @@ module.exports = { values: theme('transitionDelay') } ); - }) + }), + require('@tailwindcss/forms') ] };