feat: filter by product meta field

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2024-05-06 21:59:17 +07:00
parent 0eba468825
commit 305fe3d458
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
14 changed files with 241 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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';

View File

@ -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[]> {

View File

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

View File

@ -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;
}[];
};

View File

@ -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
View File

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

View File

@ -56,6 +56,7 @@ module.exports = {
values: theme('transitionDelay')
}
);
})
}),
require('@tailwindcss/forms')
]
};