mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 04:37:51 +00:00
feat: filter by product meta field
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
0eba468825
commit
305fe3d458
@ -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<object> => {
|
||||
const results = [] as Array<object>;
|
||||
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({
|
||||
<div className="flex w-full justify-end">
|
||||
<SortingMenu />
|
||||
</div>
|
||||
<section className="mt-3 border-t pt-2">
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<Grid className="pt-5 lg:grid-cols-3 lg:gap-x-8 xl:grid-cols-4">
|
||||
<aside className="hidden lg:block">
|
||||
<Filters collection={params.collection} />
|
||||
<Filters collection={params.collection} filters={filters} />
|
||||
</aside>
|
||||
<div className="lg:col-span-2 xl:col-span-3">
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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'
|
||||
});
|
||||
|
||||
|
@ -64,10 +64,10 @@ const MainMenu = ({ menu }: { menu: Menu[] }) => {
|
||||
>
|
||||
<Popover.Panel
|
||||
static
|
||||
className="absolute inset-x-0 left-1/2 top-full z-10 mt-0.5 min-w-32 max-w-sm -translate-x-1/2 transform text-sm"
|
||||
className="absolute inset-x-0 left-1/2 top-full z-10 mt-1 min-w-32 max-w-sm -translate-x-1/2 transform text-sm"
|
||||
>
|
||||
<div className="overflow-hidden rounded-md shadow-lg ring-1 ring-black/5">
|
||||
<ul className="flex flex-col space-y-2 bg-white px-4 py-3">
|
||||
<ul className="flex flex-col space-y-3 bg-white px-4 py-3">
|
||||
{item.items.map((subItem: Menu) => (
|
||||
<li key={subItem.title}>
|
||||
<Link
|
||||
|
58
components/layout/search/filters/filters-list.tsx
Normal file
58
components/layout/search/filters/filters-list.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
import { Filter } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
const Filters = ({ filters }: { filters: Filter[] }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleChange = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form onChange={handleChange} className="space-y-5 divide-y divide-gray-200">
|
||||
{filters.map(({ label, id, values }) => (
|
||||
<div key={id} className="flex h-auto max-h-[550px] flex-col gap-y-3 overflow-hidden pt-5">
|
||||
<div className="block text-sm font-medium text-gray-900">{label}</div>
|
||||
<div className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
|
||||
{values.map(({ id: valueId, label, count, value }) => (
|
||||
<label
|
||||
key={valueId}
|
||||
htmlFor={valueId}
|
||||
className={clsx('flex items-center gap-2 text-sm text-gray-600', {
|
||||
'cursor-not-allowed opacity-50': count === 0
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id={valueId}
|
||||
name={id}
|
||||
defaultChecked={searchParams.getAll(id).includes(String(value))}
|
||||
type="checkbox"
|
||||
value={String(value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-secondary focus:ring-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={count === 0}
|
||||
/>
|
||||
<span>{`${label} (${count})`}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
@ -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 }) => {
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="sr-only">Filters</h3>
|
||||
<FiltersList filters={filters} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
<DynamicTag
|
||||
prefetch={!active ? false : undefined}
|
||||
|
@ -32,3 +32,6 @@ export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2024-04/graphql.json';
|
||||
|
||||
export const CORE_WAIVER = 'core-waiver';
|
||||
export const CORE_VARIANT_ID_KEY = 'coreVariantId';
|
||||
|
||||
export const AVAILABILITY_FILTER_ID = 'filter.v.availability';
|
||||
export const PRICE_FILTER_ID = 'filter.v.price';
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||
import {
|
||||
AVAILABILITY_FILTER_ID,
|
||||
HIDDEN_PRODUCT_TAG,
|
||||
PRICE_FILTER_ID,
|
||||
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
||||
TAGS
|
||||
} from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
@ -27,6 +33,7 @@ import {
|
||||
Cart,
|
||||
Collection,
|
||||
Connection,
|
||||
Filter,
|
||||
Image,
|
||||
Menu,
|
||||
Money,
|
||||
@ -41,6 +48,7 @@ import {
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyFilter,
|
||||
ShopifyMenuOperation,
|
||||
ShopifyPageOperation,
|
||||
ShopifyPagesOperation,
|
||||
@ -168,6 +176,31 @@ const reshapeCollections = (collections: ShopifyCollection[]) => {
|
||||
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<Image>, productTitle: string) => {
|
||||
const flattened = removeEdgesAndNodes(images);
|
||||
|
||||
@ -305,28 +338,34 @@ export async function getCollection(handle: string): Promise<Collection | undefi
|
||||
export async function getCollectionProducts({
|
||||
collection,
|
||||
reverse,
|
||||
sortKey
|
||||
sortKey,
|
||||
filters
|
||||
}: {
|
||||
collection: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
filters?: Array<object>;
|
||||
}): Promise<{ products: Product[]; filters: Filter[] }> {
|
||||
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
|
||||
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<Collection[]> {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,13 +223,16 @@ export type ShopifyCollectionOperation = {
|
||||
export type ShopifyCollectionProductsOperation = {
|
||||
data: {
|
||||
collection: {
|
||||
products: Connection<ShopifyProduct>;
|
||||
products: Connection<ShopifyProduct> & {
|
||||
filters: ShopifyFilter[];
|
||||
};
|
||||
};
|
||||
};
|
||||
variables: {
|
||||
handle: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
filters?: Array<object>;
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}[];
|
||||
};
|
||||
|
@ -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",
|
||||
|
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -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:
|
||||
|
@ -56,6 +56,7 @@ module.exports = {
|
||||
values: theme('transitionDelay')
|
||||
}
|
||||
);
|
||||
})
|
||||
}),
|
||||
require('@tailwindcss/forms')
|
||||
]
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user