Product Listing Page - using Graph
Optimizely Graph makes it possible to query your data in an advanced way, by using GraphQL. Querying data, using facets and search phrases, is very valuable when building "Product Listing Pages" for Commerce sites. I will describe how you can create your product listing page for Customizable Commerce, using NextJs.
We are going to use the Foundation example site in this tutorial. For more information how to setup Foundation, see https://github.com/episerver/Foundation
We will create a NextJs app where you can filter products by brand, size, color, and price. We will also be able to search using "semantic search" and normal relevance search.
Note: The templating and styling of the "product listing page" in this example has been made by a person (me) that has very poor knowledge of styling. Its just an example of how you can create a template.
Setup and start the Foundation site
Start with setting up the Foundation site, and make sure the site is running as it should. Also make sure you can login to edit/admin mode. For more information about the Foundation site, see https://github.com/episerver/Foundation
Enable Graph for the Foundation site
You need to have a Graph account to be able to send content from the Foundation site to Graph. But don't worry if you don't have any Graph account. We have prepared a Graph account with Foundation content, so you can test to create an application using our account.
Note: You can skip this part ("Enable Graph for the Foundation Site") if you don't have a Graph account. You can instead jump to the next section "Create GraphQL queries" and use the following singleKey: 5m4F2pBpXPWehc3QGqFfvohgNtgYxHQOxfmKsnqhRYDpZTBU
Install package to push content to Graph
Install the following nuget package (3.9.0 or later): Optimizely.ContentGraph.Cms
Optimizely.ContentGraph.Cms
Note: The current pre-package "Optimizely.ContentGraph.Commerce" is currently not maintained. A proper Commerce integration will come later. Don't use the pre-package Optimizely.ContentGraph.Commerce. Add classes similar to how I'm doing it in this blog post instead.
Configure your Graph account
Add the following section to appSettings.json:
"Optimizely": {
"ContentGraph": {
"GatewayAddress": "https://cg.optimizely.com",
"AppKey": "{your-app-key}",
"Secret": "{your-secret}",
"SingleKey": "{your-single-key}",
"AllowSendingLog": "true"
}
}
And add the following to the "ConfigureServices" method in Startup.cs:
services.AddContentGraph(x =>
{
x.IncludeInheritanceInContentType = true;
x.PreventFieldCollision = true;
});
Add a reference to "GraphCommerceIntegration" project
1. Clone the repository GraphCommerceIntegration
https://github.com/jonasbergqvist/GraphCommerceIntegration.git
2. Add the project "GraphCommerceIntegration.csproj", which you find under the "GraphCommerceIntegration" folder, to your Foundation solution.
3. Add Project reference in the Foundation site to "GraphCommerceIntegration"
4. Compile and start the Foundation site
Note: The repository contains
- A class that makes scheduled job include catalog content (event synchronisation of catalog content works without referencing the project)
- A class that includes all languages that has been configured on catalog content.
- A class that triggers event synchronisation when prices have been changed.
- Price Base class, which makes it easy to include price information on products and variations when they are pushed to Graph
- Asset Base class, which makes it easy to add assets information on products and variations when they are pushed to Graph
- Aggregation Base class, which makes it easy to aggregate data from variations to its related products.
Note: For more information, see the README at https://github.com/jonasbergqvist/GraphCommerceIntegration/tree/main
Add default price to Products and Variations
Add the following class to your Foundation site, to include default price on default market for products and variations:
[ServiceConfiguration(typeof(IContentApiModelProperty), Lifecycle = ServiceInstanceScope.Singleton)]
public class DefaultPriceContentApiModel : ContentApiModelPriceBase
{
private readonly ICurrentMarket _currentMarketService;
public DefaultPriceContentApiModel(
ContentTypeModelRepository contentTypeModelRepository,
IContentLoader contentLoader,
IPriceService priceService,
ICurrentMarket currentMarketService)
: base(contentTypeModelRepository, contentLoader, priceService)
{
_currentMarketService = currentMarketService;
}
public override string Name => "DefaultMarketPrice";
protected override IMarket GetMarket()
{
return _currentMarketService.GetCurrentMarket();
}
}
Note: Products will get the lowest price from its related variations.
Aggregate data from Variation content to Product content
Add the following two classes, to include colors and sizes, when content is getting pushed to Graph:
Colors
[ServiceConfiguration(typeof(IContentApiModelProperty), Lifecycle = ServiceInstanceScope.Singleton)]
public class ColorContentApiModel : ProductAggregationContentApiModelBase<string, GenericProduct, GenericVariant>
{
public ColorContentApiModel(ContentTypeModelRepository contentTypeModelRepository, IContentLoader contentLoader)
: base(contentTypeModelRepository, contentLoader)
{
}
public override string Name => "Colors";
protected override Expression<Func<GenericVariant, string>> VariationProperty => (x) => x.Color;
}
Sizes
[ServiceConfiguration(typeof(IContentApiModelProperty), Lifecycle = ServiceInstanceScope.Singleton)]
public class SizeContentApiModel : ProductAggregationContentApiModelBase<string, GenericProduct, GenericVariant>
{
public SizeContentApiModel(ContentTypeModelRepository contentTypeModelRepository, IContentLoader contentLoader)
: base(contentTypeModelRepository, contentLoader)
{
}
public override string Name => "Sizes";
protected override Expression<Func<GenericVariant, string>> VariationProperty => (x) => x.Size;
protected override string ModifyValue(string value) => value.ToUpper();
}
Push default product asset to Graph
Add the following class, to include the url of default product asset to products, when they are pushed to Graph
[ServiceConfiguration(typeof(IContentApiModelProperty), Lifecycle = ServiceInstanceScope.Singleton)]
public class DefaultImageUrlContentApiModel : CommerceAssetApiModelBase<string>
{
public DefaultImageUrlContentApiModel(ContentTypeModelRepository contentTypeModelRepository, IContentLoader contentLoader, IUrlResolver urlResolver)
: base(contentTypeModelRepository, contentLoader, urlResolver)
{
}
public override string Name => "DefaultImageUrl";
public override string NoValue => string.Empty;
protected override string GetAssets(IEnumerable<CommerceMedia> commerceMediaItems)
{
foreach(CommerceMedia media in commerceMediaItems.OrderBy(x => x.SortOrder))
{
if (ContentLoader.TryGet<IContentImage>(media.AssetLink, out var contentMedia))
{
return GetUrl(media);
}
}
return NoValue;
}
}
Create GraphQL queries
We will create a couple of GraphQL queries using the data from the Foundation site. We can use our online IDE using url https://cg.optimizely.com/app/graphiql?auth={your-single-key}. You can use our example account if you don't have any Graph account of your own: https://cg.optimizely.com/app/graphiql?auth=5m4F2pBpXPWehc3QGqFfvohgNtgYxHQOxfmKsnqhRYDpZTBU
Create ProductListing page
Create the following query (including a fragment) to handle the product listing page:
fragment GenericProductTeaser on GenericProduct {
Name
Code
DefaultMarketPrice
Brand
DefaultImageUrl
}
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
}) {
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 5
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(
ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
Lets go through the query peice by peice
GenericProductTeaser
A fragment is a reusable partual query. You can think about them as blocks. We are creating a fragment that we call "GenericProductTeaser" (it can be called what ever we like) that is handling the "GenericProduct" type. "GenericProduct" is a content-type in the Foundation site, which has been pushed to Graph. We can now choose which fields (properties) to return from "GenericProduct" type. You will get intellisese by holding ctrl and clicking space (ctrl + space).
fragment GenericProductTeaser on GenericProduct {
Name
Code
DefaultMarketPrice
Brand
DefaultImageUrl
}
Query name and Variables
We have named the query "ProductListing", but it could have been named anything. We have also added several variables to the query, which makes it possible to execute the query with many different options. You can think about a GraphQL query as a method in your favorite programming language. You name a query and add the variables to it that you like. You can then use the variables inside the query.
- $languages: The languages that you want to get content for. This can be multiple languages. The values in "Locales" is the languages that you have configured in your commerce site.
- $searchText: A search phrase to perform normal or semantic search
- $brands: Selected brands
- $sizes: Selected sizes
- $minPrice: Lowest default price to get products for
- $hightPrice: Highest default price to get products for
- $skip: How many items from the top of the result to skip
- $limit: The number of result items to get
- $order: Which order to receive content.
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
})
GenericProduct
We will query for all "GenericProduct" items using the content-type "GenericProduct". This will query all content that is of type "GenericProduct" or inherits from "GenericProduct".
GenericProduct(
Locale
The languages to query, were we use the variable $languages. Default value for $languages has been set to "en" in the query.
locale: $languages
Where
Filtering of content. Filtering will only happen for variables that has a value.
We are using an "_or" statement to match the variable "$searchText" against all searchable properties (_fullText) and "Name". We will boost result that matches "Name" by 5.
We will also filter the "default market price", based on the $minPrice and $maxPrice variables.
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 5
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
Skip & Limit
Skip $skip items from the top and include $limit number of items in the result
skip: $skip,
limit: $limit
OrderBy
Order the result based on the incoming $order variable. It will by default order the result based on "semantic search" ranking (default variable value in the query)
orderBy: $order
Total
The total number of result for the query
total
Items
Items are being used to get selected fields (properties). We are referencing the fragment "GenericProductTeaser" to get the fields (properties) specified in the fragment
items {
...GenericProductTeaser
}
Facets
Facets are aggregating data for a specific field (property) and gives you the unique values together with "count" for the unique value.
We are creating facets for "Brand", "Sizes", "Colors", and "DefaultMarketPrice". The first three facets are simple facets, where we use "filters" parameter for each facet to specify which values the user has selected. The last facet is a range facet, to give the the count for different price intervals.
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(
ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
Create Product Details page
We will also create a product detail page, which will get a product using "code"
query ProductDetail(
$locale: Locales = en
$code: String!
) {
GenericProduct(
locale: [$locale]
where:{
Code: { eq: $code }
}
limit:1
) {
items {
Name
Code
DefaultImageUrl
DefaultMarketPrice
Brand
LongDescription
}
}
}
Create NextJs application with Foundation data
We will create a NextJs app from scratch. If you want to see how the final result can be, than check out the site here: https://github.com/jonasbergqvist/GraphCommerceIntegration/tree/main/graph-commerce-example-app
Create new NextJs app
npx create-next-app@latest
- TypeSript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- src/ directory: Yes
- App Router: No
- Customize the defaultimport alias: No
Add dependencies
Open package.json and add the following
In devDependencies
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.2.6",
"@parcel/watcher": "^2.4.1",
In dependencies
"@apollo/client": "^3.10.4",
"graphql": "^16.8.1",
"html-react-parser": "^5.1.10",
"next-range-slider": "^1.0.5",
In scripts
"codegen": "graphql-codegen --watch",
Install all dependencies
Run the following command to install all the dependencies
npm install
Configure GraphQL Codegen
GraphQL codegen is a greate tool to give strongly typed queries and query result. We need to configure Codegen to use our account in Graph for the application
Create codegen.ts
Create a new file under the root folder of the application with name "codegen.ts" and add the following
import { CodegenConfig } from '@graphql-codegen/cli'
const config : CodegenConfig = {
schema: "https://cg.optimizely.com/content/v2?auth={your-single-key}",
documents: ["src/**/*.{ts,tsx}"],
ignoreNoDocuments: true,
generates: {
'./src/graphql/': {
preset: 'client',
plugins: [],
}
}
}
export default config
Change {your-single-key} to your single-key. If you don't have any Graph account, then use
Start codegen watcher
yarn codegen
Use Apollo Client with one-page edit support
Apollo Client is one of many clients that has build-in GraphQL support. We will use Apollo Client in this example, but you can use any client of choose in your own projects.
Add apolloClient.tsx
Add a new file with name "apolloClient.tsx" under "src" folder
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
let client: ApolloClient<any> | undefined = undefined;
if (typeof window !== "undefined" && window.location !== undefined) {
const queryString = window?.location?.search;
const urlParams = new URLSearchParams(queryString);
const preview_token = urlParams.get('preview_token') ?? undefined;
if (preview_token) {
const httpLink = createHttpLink({
uri: 'https://cg.optimizely.com/content/v2',
});
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: `Bearer ${preview_token}`
}
};
});
client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
const communicationScript = document.createElement('script');
communicationScript.src = `{url-to-your-foundation-site}/Util/javascript/communicationinjector.js`;
communicationScript.setAttribute('data-nscript', 'afterInteractive')
document.body.appendChild(communicationScript);
}
}
if (client === undefined) {
const httpLink = createHttpLink({
uri: 'https://cg.optimizely.com/content/v2?auth={your-single-key}',
});
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers
}
};
});
client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
}
export default client;
Your need to change two things in this file:
- Change {url-to-your-foundation-site} to the url where you run your Foundation site, for example https://localhost:44397
- {your-single-key} to your single-key of your Graph account. Use 5m4F2pBpXPWehc3QGqFfvohgNtgYxHQOxfmKsnqhRYDpZTBU if you don't have any Graph account
Update _app.tsx to use your apollo cloent
Update_app.tsx under src/pages to be the following
import "@/styles/globals.css";
import { ApolloProvider } from "@apollo/client";
import type { AppProps } from "next/app";
import client from '../apolloClient';
export default function App({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={client!}>
<Component {...pageProps} />
</ApolloProvider>
);
}
Create GenericProductTeaserComponent.tsx
Add a folder with name "components" under "src" and add a file with name "GenericProductTeaserComponent.tsx" in the "components" folder.
import { FragmentType, graphql, useFragment } from "@/graphql"
import { Dispatch, FC, SetStateAction, useState } from "react";
export const GenericProductTeaserFragment = graphql(/* GraphQL */ `
fragment GenericProductTeaser on GenericProduct {
Name
Code
DefaultMarketPrice
Brand
DefaultImageUrl
}
`)
interface GenericProductTeaserProps {
GenericProductTeaser: FragmentType<typeof GenericProductTeaserFragment>
setSelectedCode: Dispatch<SetStateAction<string>>;
setShowModal: Dispatch<SetStateAction<boolean>>;
}
const GenericProductTeaserComponent: FC<GenericProductTeaserProps> = ({ GenericProductTeaser, setSelectedCode, setShowModal}) => {
const setSelected = (event: any) => {
if(event?.target?.id) {
setSelectedCode(event?.target?.id)
setShowModal(true)
}
}
const item = useFragment(GenericProductTeaserFragment, GenericProductTeaser)
const imageUrl = 'https://localhost:44397' + item.DefaultImageUrl
return (
<div className="group relative">
<div className="aspect-h-1 aspect-w-1 w-full overflow-hidden rounded-md bg-gray-200 lg:aspect-none group-hover:opacity-75 lg:h-80">
<img src={imageUrl} alt={item.Name ?? ''} id={item.Code ?? ''} onClick={setSelected} className="h-full w-full object-cover object-center lg:h-full lg:w-full"/>
</div>
<div className="mt-4 flex justify-between">
<div>
<h3 className="text-sm text-gray-700">
<button data-modal-target="defaultModal" data-modal-toggle="defaultModal" id={item.Code ?? ''} onClick={setSelected} className="inline-flex items-center text-blue-400">
{item.Name}
</button>
</h3>
<p className="mt-1 text-sm text-gray-500">{item.Brand}</p>
</div>
<p className="text-sm font-medium text-gray-900">${item.DefaultMarketPrice}</p>
</div>
</div>
)
}
export default GenericProductTeaserComponent
The GraphQL fragment GenericProductTeaser is added in the beginning of the file. This query is then used in the "GenericProductTeaserComponent". You can try to add fields (properties) that exist in GenericProduct in the fragment. You will have the fields/properties available in the GenericProductTeaserComponent a couple of seconds after you have added them and saved the file.
Test to add _score and save the file
fragment GenericProductTeaser on GenericProduct {
Name
Code
DefaultMarketPrice
Brand
DefaultImageUrl
_score
}
_score will be available in "item" inside GenericProductTeaserComponent a couple of seconds after you have saved the file.
Try to build the site
Check that everything is working by running the following command
npm run build
Create ProductListingComponent..tsx
Create ProductListingComponent.tsx under the "components" with the product listing page GraphQL query
import React, { FC, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
}
})
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<div className='mt-2 ml-4'>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
</main>
)
}
export default ProductListingComponent
Update index.tsx
Update index.tsx in src/pages folder to the following
import ProductListingComponent from "@/components/ProductListingComponent";
export default function Home() {
return (
<ProductListingComponent />
);
}
Start the Foundation website to make images work
Start the Foundation website if its not running already. It needs to run to make images in the NextJs app work. The reason for this is that Graph only stores the link to the actual images, which lives inside the Commerce system. Everything except the images (you will get broken images) will work in case you don't start the Foundation site.
Start the NextJs app
npm run dev
You should now see some products when browsing to
http://localhost:3000/
Create ProductDetailComponent.tsx
Create a file with name ProductDetailComponent.tsx under "components"
import React, { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import parse from 'html-react-parser';
export const ProductDetail = graphql(/* GraphQL */ `
query ProductDetail(
$locale: Locales = en
$code: String!
) {
GenericProduct(
locale: [$locale]
where:{
Code: { eq: $code }
}
limit:1
) {
items {
Name
Code
DefaultImageUrl
DefaultMarketPrice
Brand
LongDescription
}
}
}
`)
interface ProductDetailProps {
code: string
setOpen: Dispatch<SetStateAction<boolean>>;
}
const ProductDetailComponent: FC<ProductDetailProps> = ({code, setOpen}) => {
const { data } = useQuery(ProductDetail, {
variables: {
code
}
})
const item = data?.GenericProduct?.items![0]
const imageUrl = '{url-to-your-foundation-site}' + item?.DefaultImageUrl
return (
<div className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 z-50 outline-none focus:outline-none">
<div className="relative w-auto my-6 mx-auto max-w-3xl">
{/*content*/}
<div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none">
{/*header*/}
<div className="flex items-start justify-between p-5 border-b border-solid border-slate-200 rounded-t">
<h3 className="text-3xl font-semibold">
{ item?.Name }
</h3>
<button
className="text-red-500 background-transparent font-bold uppercase px-6 py-2 text-sm outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150"
type="button"
onClick={() => setOpen(false)}
>
Close
</button>
</div>
{/*body*/}
<div className="relative p-6 flex-auto">
<div className="aspect-h-1 aspect-w-1 w-full overflow-hidden rounded-md bg-gray-200 lg:aspect-none group-hover:opacity-75 lg:h-80">
<img src={imageUrl} alt={item?.Name ?? ''} className="h-full w-full object-cover object-center lg:h-full lg:w-full"/>
</div>
<p className="my-4 text-slate-500 text-lg leading-relaxed">
{ parse(item?.LongDescription ?? '')}
</p>
<p className="my-4 text-slate-800 text-lg leading-relaxed">
From: {item?.Brand}
</p>
</div>
{/*footer*/}
</div>
</div>
</div>
)
}
export default ProductDetailComponent
Change {url-to-your-foundation-site} to the url where you run your Foundation site, for example https://localhost:44397
Update ProductListingComponent to load product details when needed
Update ProductListingComponent.tsx to open product details component in a modal when clicking on the image or name
Add the following before </main>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
and import the ProductDetailComponent
import ProductDetailComponent from './ProductDetailComponent'
The ProductListingComponent should now look like this
import React, { FC, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
import ProductDetailComponent from './ProductDetailComponent'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
}
})
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<div className='mt-2 ml-4'>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
</main>
)
}
export default ProductListingComponent
Test the app
Try to click on an image to load details about the product
Create TermFacetComponent.tsx
Create TermFacetComponent.tsx in "components" folder with following code
import { StringFacet } from "@/graphql/graphql"
import { Dispatch, FC, SetStateAction } from "react"
interface TermFacetProps {
headingText: string
values: string[]
facet: StringFacet[] | null
setValues: Dispatch<SetStateAction<string[]>>;
}
const TermFacetComponent: FC<TermFacetProps> = ({ headingText, values, facet, setValues }) => {
const handleSelection = (event: React.ChangeEvent<HTMLInputElement>) => {
let localValues = Array.from(values)
if(event.target.checked) {
localValues.push(event.target.id);
}
else {
localValues = localValues.filter(x => x !== event.target.id);
}
setValues(localValues);
};
return (
<div className="border-b border-gray-200 py-6">
<h3 className="-my-3 flow-root">{ headingText }</h3>
<div className="pt-6" id="filter-section-0">
<div className="space-y-4">
{ facet?.map((item, idx) => {
return (
<div className="flex items-center" key={idx}>
<input id={item?.name ?? ''} name="color[]" value={item?.name ?? ''} checked={values.indexOf(item?.name ?? '') > -1 } onChange={handleSelection} type="checkbox" className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"/>
<label htmlFor="filter-color-0" className="ml-3 text-sm text-gray-600">{ item?.name } ({ item?.count })</label>
</div>
)
}) }
</div>
</div>
</div>
)
}
export default TermFacetComponent
Update ProductListingComponent.tsx to use terms facets
Update ProductListingComponent.tsx to look like this
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
import ProductDetailComponent from './ProductDetailComponent'
import TermFacetComponent from './TermFacetComponent'
import { StringFacet } from '@/graphql/graphql'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [brands, setBrands] = useState(() => new Array<string>());
const [brandFacet, setBrandFacet] = useState(() => new Array<StringFacet>())
const [colors, setColors] = useState(() => new Array<string>());
const [colorFacet, setColorFacet] = useState(() => new Array<StringFacet>())
const [sizes, setSizes] = useState(() => new Array<string>());
const [sizeFacet, setSizeFacet] = useState(() => new Array<StringFacet>())
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
brands,
colors,
sizes,
}
})
function facetOptionChanged(fasetQueryResult: StringFacet[], faset: StringFacet[]): boolean {
if(fasetQueryResult.length != faset.length) {
return true
}
for (let i = 0; i < fasetQueryResult.length; i++) {
if(fasetQueryResult[i]?.name !== faset[i]?.name) {
return true
}
if(fasetQueryResult[i]?.count !== faset[i]?.count) {
return true
}
}
return false
}
useEffect(() => {
if(data?.GenericProduct?.facets?.Brand != undefined && data?.GenericProduct?.facets?.Brand) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Brand as StringFacet[], brandFacet)) {
setBrandFacet(data.GenericProduct.facets?.Brand as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Colors != undefined && data?.GenericProduct?.facets?.Colors) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Colors as StringFacet[], colorFacet)) {
setColorFacet(data.GenericProduct.facets?.Colors as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Sizes != undefined && data?.GenericProduct?.facets?.Sizes) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Sizes as StringFacet[], sizeFacet)) {
setSizeFacet(data.GenericProduct.facets?.Sizes as StringFacet[])
}
}
}, [brandFacet, colorFacet, sizeFacet, data?.GenericProduct?.facets]);
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Brands'
facet={brandFacet}
values={brands}
setValues={setBrands}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Colors'
facet={colorFacet}
values={colors}
setValues={setColors}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Sizes'
facet={sizeFacet}
values={sizes}
setValues={setSizes}
/>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<div className='mt-2 ml-4'>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
</main>
)
}
export default ProductListingComponent
Test facets in the app
Test to click on different facet values, and validate that correct products are shown based on your selections
Create RangeFacetComponent.tsx
Create RangeFacetComponent.tsx under "Components" with following code
import { NumberFacet } from "@/graphql/graphql"
import React, { Dispatch, FC, SetStateAction, useEffect, useState } from "react";
import { RangeSlider } from 'next-range-slider';
import 'next-range-slider/dist/main.css';
interface RangeFacetProps {
headingText: string
minValue: number
maxValue: number
currentLowValue: number
currentHighValue: number
facet: NumberFacet[] | null
setLowValue: Dispatch<SetStateAction<number>>;
setHighValue: Dispatch<SetStateAction<number>>;
}
const RangeFacetComponent: FC<RangeFacetProps> = ({ headingText, minValue, maxValue, currentLowValue, currentHighValue, facet, setLowValue, setHighValue }) => {
const [localLowValue, setLocalLowValue] = useState(() => minValue);
const [localHighValue, setHighLocalValue] = useState(() => maxValue)
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
setLowValue(localLowValue)
setHighValue(localHighValue)
}, 500)
return () => clearTimeout(delayDebounceFn)
}, [localLowValue, localHighValue, setLowValue, setHighValue])
const facetValues = Array.from(facet?.values() ?? []).map((x) => x.count!)
const highestFacetCount = Math.max(...facetValues)
return (
<div className="border-b border-gray-200 py-6">
<div className="flex flex-col items-center w-full max-w-screen-md p-6 pb-6 rounded-lg shadow-xl sm:p-1">
<div className="flex items-end flex-grow w-full mt-2 space-x-2 sm:space-x-3 h-16">
{
facet?.map((x, index) => {
let hValue = Math.round((x.count! / highestFacetCount) * 12)
if(Math.abs(hValue % 2) == 1) {
hValue = hValue - 1
}
const className = "bg-indigo-200 relative flex justify-center w-full h-" + hValue
return (
<div className="relative flex flex-col items-center flex-grow pb-5 group" key={index}>
<span className="absolute top-0 hidden -mt-6 text-xs font-bold group-hover:block">{x?.count ?? '0'}</span>
<div className={className}></div>
</div>
)
})
}
</div>
</div>
<div>
<RangeSlider
min={minValue}
max={maxValue}
step={10}
options={{
leftInputProps: {
value: currentLowValue,
onChange: (e) => setLocalLowValue(Number(e.target.value)),
},
rightInputProps: {
value: currentHighValue,
onChange: (e) => setHighLocalValue(Number(e.target.value)),
},
}
}
/>
</div>
<div className="text-center">{ headingText }: {currentLowValue} - {currentHighValue}</div>
</div>
);
}
export default RangeFacetComponent
Update ProductListingComponent to add price facet
Add RangeFacetComponent and two "useStates". The variables send in the GraphQL query will also have to pass the minPrice and maxPrice
<RangeFacetComponent
headingText='Price range'
currentHighValue={highPrice}
setHighValue={setHighPrice}
setLowValue={setLowPrice}
currentLowValue={lowPrice}
minValue={0}
maxValue={600}
facet={data?.GenericProduct?.facets?.DefaultMarketPrice as NumberFacet[]}
/>
const [lowPrice, setLowPrice] = useState(() => 0);
const [highPrice, setHighPrice] = useState(() => 600)
const { data } = useQuery(ProductListing, {
variables: {
brands,
colors,
sizes,
minPrice: lowPrice,
maxPrice: highPrice,
}
})
The ProductListingComponent should have the following code after adding RangeFacetComponent
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
import ProductDetailComponent from './ProductDetailComponent'
import TermFacetComponent from './TermFacetComponent'
import { NumberFacet, StringFacet } from '@/graphql/graphql'
import RangeFacetComponent from './RangeFacetComponent'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [brands, setBrands] = useState(() => new Array<string>());
const [brandFacet, setBrandFacet] = useState(() => new Array<StringFacet>())
const [colors, setColors] = useState(() => new Array<string>());
const [colorFacet, setColorFacet] = useState(() => new Array<StringFacet>())
const [sizes, setSizes] = useState(() => new Array<string>());
const [sizeFacet, setSizeFacet] = useState(() => new Array<StringFacet>())
const [lowPrice, setLowPrice] = useState(() => 0);
const [highPrice, setHighPrice] = useState(() => 600)
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
brands,
colors,
sizes,
}
})
function facetOptionChanged(fasetQueryResult: StringFacet[], faset: StringFacet[]): boolean {
if(fasetQueryResult.length != faset.length) {
return true
}
for (let i = 0; i < fasetQueryResult.length; i++) {
if(fasetQueryResult[i]?.name !== faset[i]?.name) {
return true
}
if(fasetQueryResult[i]?.count !== faset[i]?.count) {
return true
}
}
return false
}
useEffect(() => {
if(data?.GenericProduct?.facets?.Brand != undefined && data?.GenericProduct?.facets?.Brand) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Brand as StringFacet[], brandFacet)) {
setBrandFacet(data.GenericProduct.facets?.Brand as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Colors != undefined && data?.GenericProduct?.facets?.Colors) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Colors as StringFacet[], colorFacet)) {
setColorFacet(data.GenericProduct.facets?.Colors as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Sizes != undefined && data?.GenericProduct?.facets?.Sizes) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Sizes as StringFacet[], sizeFacet)) {
setSizeFacet(data.GenericProduct.facets?.Sizes as StringFacet[])
}
}
}, [brandFacet, colorFacet, sizeFacet, data?.GenericProduct?.facets]);
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
<RangeFacetComponent
headingText='Price range'
currentHighValue={highPrice}
setHighValue={setHighPrice}
setLowValue={setLowPrice}
currentLowValue={lowPrice}
minValue={0}
maxValue={600}
facet={data?.GenericProduct?.facets?.DefaultMarketPrice as NumberFacet[]}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Brands'
facet={brandFacet}
values={brands}
setValues={setBrands}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Colors'
facet={colorFacet}
values={colors}
setValues={setColors}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Sizes'
facet={sizeFacet}
values={sizes}
setValues={setSizes}
/>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<div className='mt-2 ml-4'>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
</main>
)
}
export default ProductListingComponent
Add OrderByComponent.tsx
Add OrderByComponent.tsx under "components" with the following code
import { OrderBy } from "@/graphql/graphql";
import { Dispatch, FC, SetStateAction, useState } from "react"
interface OrderByProps {
orderBy: string
setorderBy: Dispatch<SetStateAction<string>>;
orderByDirection: OrderBy
setorderByDirection: Dispatch<SetStateAction<OrderBy>>;
}
const OrderByComponent: FC<OrderByProps> = ({ orderBy, setorderBy, orderByDirection, setorderByDirection }) => {
const [isOrderByInputOpen, setOrderByInputIsOpen] = useState(false);
const [isOrderByDirectionOpen, setOrderByDirectionIsOpen] = useState(false);
const toggleOrderByDirectionDropdown = () => {
setOrderByDirectionIsOpen(!isOrderByDirectionOpen);
};
const toggleOrderByInputDropdown = () => {
setOrderByInputIsOpen(!isOrderByInputOpen);
};
const orderBySemantic = () => {
setorderBy('Semantic')
setOrderByInputIsOpen(false);
};
const orderByName = () => {
setorderBy('Name')
setOrderByInputIsOpen(false);
setorderByDirection(OrderBy.Desc)
};
const orderByPrice = () => {
setorderBy('DefaultMarketPrice')
setOrderByInputIsOpen(false);
};
const orderByBrand = () => {
setorderBy('Brand')
setOrderByInputIsOpen(false);
};
const orderAsc = () => {
setorderByDirection(OrderBy.Asc)
setOrderByDirectionIsOpen(false);
};
const orderDesc = () => {
setorderByDirection(OrderBy.Desc)
setOrderByDirectionIsOpen(false);
};
return (
<div className='w-full py-6 pb-8'>
<div className="ml-2 relative inline-block">
<button
type="button"
className="px-4 py-2 text-black bg-slate-400 hover:bg-slate-300 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm inline-flex items-center"
onClick={toggleOrderByInputDropdown}
>
Order By: {orderBy}<svg className="w-2.5 h-2.5 ml-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" d="m1 1 4 4 4-4" />
</svg>
</button>
{isOrderByInputOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-44 rounded-lg shadow-lg bg-white ring-1 ring-black ring-opacity-5">
<ul role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderBySemantic}
>
Semantic
</a>
</li>
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderByName}
>
Name
</a>
</li>
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderByPrice}
>
DefaultMarketPrice
</a>
</li>
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderByBrand}
>
Brand
</a>
</li>
</ul>
</div>
)}
</div>
<div className="ml-2 relative inline-block">
<button
type="button"
className="px-4 py-2 text-black bg-slate-400 hover:bg-slate-300 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm inline-flex items-center"
onClick={toggleOrderByDirectionDropdown}
>
Direction: {orderByDirection} <svg className="w-2.5 h-2.5 ml-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" d="m1 1 4 4 4-4" />
</svg>
</button>
{isOrderByDirectionOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-44 rounded-lg shadow-lg bg-white ring-1 ring-black ring-opacity-5">
<ul role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderAsc}
>
Asc
</a>
</li>
<li>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={orderDesc}
>
Desc
</a>
</li>
</ul>
</div>
)}
</div>
</div>
)
}
export default OrderByComponent
Update ProductListingComponent to include ordering
ProductListingComponent should now look like this
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
import ProductDetailComponent from './ProductDetailComponent'
import TermFacetComponent from './TermFacetComponent'
import { GenericProductOrderByInput, NumberFacet, OrderBy, Ranking, StringFacet } from '@/graphql/graphql'
import RangeFacetComponent from './RangeFacetComponent'
import OrderByComponent from './OrderByCompontent'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [orderByInput, setOrderByInput] = useState(() => 'DefaultMarketPrice');
const [orderByDirection, setOrderByDirection] = useState(() => OrderBy.Asc);
const [brands, setBrands] = useState(() => new Array<string>());
const [brandFacet, setBrandFacet] = useState(() => new Array<StringFacet>())
const [colors, setColors] = useState(() => new Array<string>());
const [colorFacet, setColorFacet] = useState(() => new Array<StringFacet>())
const [sizes, setSizes] = useState(() => new Array<string>());
const [sizeFacet, setSizeFacet] = useState(() => new Array<StringFacet>())
const [lowPrice, setLowPrice] = useState(() => 0);
const [highPrice, setHighPrice] = useState(() => 600)
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
brands,
colors,
sizes,
minPrice: lowPrice,
maxPrice: highPrice,
order: getOrder()
}
})
function getOrder(): GenericProductOrderByInput {
if(orderByInput === "Name") {
return { Name: orderByDirection }
} else if (orderByInput === "Brand") {
return { Brand: orderByDirection }
} else if(orderByInput === "DefaultMarketPrice") {
return { DefaultMarketPrice: orderByDirection }
} else {
return { _ranking: Ranking.Semantic }
}
}
function facetOptionChanged(fasetQueryResult: StringFacet[], faset: StringFacet[]): boolean {
if(fasetQueryResult.length != faset.length) {
return true
}
for (let i = 0; i < fasetQueryResult.length; i++) {
if(fasetQueryResult[i]?.name !== faset[i]?.name) {
return true
}
if(fasetQueryResult[i]?.count !== faset[i]?.count) {
return true
}
}
return false
}
useEffect(() => {
if(data?.GenericProduct?.facets?.Brand != undefined && data?.GenericProduct?.facets?.Brand) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Brand as StringFacet[], brandFacet)) {
setBrandFacet(data.GenericProduct.facets?.Brand as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Colors != undefined && data?.GenericProduct?.facets?.Colors) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Colors as StringFacet[], colorFacet)) {
setColorFacet(data.GenericProduct.facets?.Colors as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Sizes != undefined && data?.GenericProduct?.facets?.Sizes) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Sizes as StringFacet[], sizeFacet)) {
setSizeFacet(data.GenericProduct.facets?.Sizes as StringFacet[])
}
}
}, [brandFacet, colorFacet, sizeFacet, data?.GenericProduct?.facets]);
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
<RangeFacetComponent
headingText='Price range'
currentHighValue={highPrice}
setHighValue={setHighPrice}
setLowValue={setLowPrice}
currentLowValue={lowPrice}
minValue={0}
maxValue={600}
facet={data?.GenericProduct?.facets?.DefaultMarketPrice as NumberFacet[]}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Brands'
facet={brandFacet}
values={brands}
setValues={setBrands}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Colors'
facet={colorFacet}
values={colors}
setValues={setColors}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Sizes'
facet={sizeFacet}
values={sizes}
setValues={setSizes}
/>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<div className='mt-2 ml-4'>
<OrderByComponent
orderBy={orderByInput}
setorderBy={setOrderByInput}
orderByDirection={orderByDirection}
setorderByDirection={setOrderByDirection}
/>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
</main>
)
}
export default ProductListingComponent
Create SearchTextComponent.tsx
Create SearchTextComponent.tsx under "components" with the following code
import { Dispatch, FC, SetStateAction, useState } from "react"
interface SearchTextProps {
searchText: string
setSearchText: Dispatch<SetStateAction<string>>;
}
const SearchTextComponent: FC<SearchTextProps> = ({ searchText, setSearchText }) => {
const [internalSearchText, setInternalSearchText] = useState(() => searchText);
const handleSearchClick = (event: any) => {
setSearchText(internalSearchText)
};
const handleSearchInput = (event: React.ChangeEvent<HTMLInputElement>) => {
setInternalSearchText(event.target.value);
};
const handleSearchboxKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setSearchText(internalSearchText)
}
};
return (
<div className="relative flex items-center w-full h-full lg:w-96 group">
<input type="text" value={internalSearchText} onChange={handleSearchInput} onKeyDown={handleSearchboxKeyDown} className="block w-full py-1.5 pl-10 pr-4 leading-normal rounded-2xl focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 ring-opacity-90 bg-gray-100 dark:bg-gray-800 text-gray-400 aa-input" placeholder="Search"/>
<button type="submit" onClick={handleSearchClick} className="absolute top-0 right-0 p-2.5 text-sm font-medium h-full text-blue-500 focus:outline-none">
<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>
<span className="sr-only">Search</span>
</button>
</div>
)
}
export default SearchTextComponent
Update ProductListingComponent to include searchbox
Update ProductListingComponent.tsx to look like this
import React, { FC, useEffect, useState } from 'react'
import { useQuery } from '@apollo/client'
import { graphql } from '@/graphql'
import { GenericProductOrderByInput, NumberFacet, OrderBy, Ranking, StringFacet } from '@/graphql/graphql'
import TermFacetComponent from './TermFacetComponent'
import SearchTextComponent from './SearchTextComponent'
import GenericProductTeaserComponent from './GenericProductTeaserComponent'
import OrderByComponent from './OrderByCompontent'
import RangeFacetComponent from './RangeFacetComponent'
import ProductDetailComponent from './ProductDetailComponent'
export const ProductListing = graphql(/* GraphQL */ `
query ProductListing(
$languages: [Locales] = en
$searchText: String,
$brands: [String!],
$sizes: [String!],
$colors: [String!],
$minPrice: Float,
$maxPrice: Float,
$skip: Int = 0,
$limit: Int = 10,
$order: GenericProductOrderByInput = {
_ranking: SEMANTIC,
DefaultMarketPrice: ASC
}
)
{
GenericProduct(
locale: $languages
where:{
_or:[
{
_fulltext: {
match: $searchText
}
},
{
Name: {
match: $searchText
boost: 20
}
}
]
DefaultMarketPrice: {
gte: $minPrice
lte: $maxPrice
}
}
skip: $skip,
limit: $limit
orderBy: $order
) {
total
items {
...GenericProductTeaser
}
facets {
Brand(filters: $brands) {
name
count
}
Sizes(filters:$sizes) {
name
count
}
Colors(filters:$colors) {
name
count
}
DefaultMarketPrice(ranges: [
{ to: 50 },
{ from: 51, to: 100 },
{ from: 101, to: 150 },
{ from: 151, to: 200 },
{ from: 201, to: 250 },
{ from: 251, to: 300 },
{ from: 301, to: 350 },
{ from: 351, to: 400 },
{ from: 401, to: 450 },
{ from: 451, to: 500 },
{ from: 501 },
]) {
name
count
}
}
}
}
`)
const ProductListingComponent: FC = () => {
const [searchText, setSearchText] = useState(() => '');
const [orderByInput, setOrderByInput] = useState(() => 'DefaultMarketPrice');
const [orderByDirection, setOrderByDirection] = useState(() => OrderBy.Asc);
const [brands, setBrands] = useState(() => new Array<string>());
const [brandFacet, setBrandFacet] = useState(() => new Array<StringFacet>())
const [colors, setColors] = useState(() => new Array<string>());
const [colorFacet, setColorFacet] = useState(() => new Array<StringFacet>())
const [sizes, setSizes] = useState(() => new Array<string>());
const [sizeFacet, setSizeFacet] = useState(() => new Array<StringFacet>())
const [lowPrice, setLowPrice] = useState(() => 0);
const [highPrice, setHighPrice] = useState(() => 600)
const [showModal, setShowModal] = useState(false);
const [selectedCode, setSelectedCode] = useState(() => '');
const { data } = useQuery(ProductListing, {
variables: {
searchText,
brands,
colors,
sizes,
minPrice: lowPrice,
maxPrice: highPrice,
order: getOrder()
}
})
function getOrder(): GenericProductOrderByInput {
if(orderByInput === "Name") {
return { Name: orderByDirection }
} else if (orderByInput === "Brand") {
return { Brand: orderByDirection }
} else if(orderByInput === "DefaultMarketPrice") {
return { DefaultMarketPrice: orderByDirection }
} else {
return { _ranking: Ranking.Semantic }
}
}
function facetOptionChanged(fasetQueryResult: StringFacet[], faset: StringFacet[]): boolean {
if(fasetQueryResult.length != faset.length) {
return true
}
for (let i = 0; i < fasetQueryResult.length; i++) {
if(fasetQueryResult[i]?.name !== faset[i]?.name) {
return true
}
if(fasetQueryResult[i]?.count !== faset[i]?.count) {
return true
}
}
return false
}
useEffect(() => {
if(data?.GenericProduct?.facets?.Brand != undefined && data?.GenericProduct?.facets?.Brand) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Brand as StringFacet[], brandFacet)) {
setBrandFacet(data.GenericProduct.facets?.Brand as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Colors != undefined && data?.GenericProduct?.facets?.Colors) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Colors as StringFacet[], colorFacet)) {
setColorFacet(data.GenericProduct.facets?.Colors as StringFacet[])
}
}
if(data?.GenericProduct?.facets?.Sizes != undefined && data?.GenericProduct?.facets?.Sizes) {
if(facetOptionChanged(data?.GenericProduct?.facets?.Sizes as StringFacet[], sizeFacet)) {
setSizeFacet(data.GenericProduct.facets?.Sizes as StringFacet[])
}
}
}, [brandFacet, colorFacet, sizeFacet, data?.GenericProduct?.facets]);
return (
<main>
<div className="flex">
<div className="relative hidden lg:block w-80">
<div className="h-full rounded-2xl ml-4 bg-slate-50">
<nav className="mt-2 ml-4 mr-4">
<div>
<RangeFacetComponent
headingText='Price range'
currentHighValue={highPrice}
setHighValue={setHighPrice}
setLowValue={setLowPrice}
currentLowValue={lowPrice}
minValue={0}
maxValue={600}
facet={data?.GenericProduct?.facets?.DefaultMarketPrice as NumberFacet[]}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Brands'
facet={brandFacet}
values={brands}
setValues={setBrands}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Colors'
facet={colorFacet}
values={colors}
setValues={setColors}
/>
</div>
</nav>
<nav className="mt-2 ml-4">
<div>
<TermFacetComponent
headingText='Sizes'
facet={sizeFacet}
values={sizes}
setValues={setSizes}
/>
</div>
</nav>
</div>
</div>
<div className="w-full pl-0 md:p-2">
<header className="z-40 items-center w-full h-16 shadow-lg rounded-2xl bg-slate-50">
<div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center" style={{ background: "radial-gradient(141.61% 141.61% at 29.14% -11.49%, rgba(203, 213, 225, 0.15) 0%, rgba(203, 213, 225, 0) 57.72%)"}}>
<div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0 ">
<SearchTextComponent
searchText={searchText}
setSearchText={setSearchText}
/>
<div className='mt-2 ml-4'>
<OrderByComponent
orderBy={orderByInput}
setorderBy={setOrderByInput}
orderByDirection={orderByDirection}
setorderByDirection={setOrderByDirection}
/>
</div>
</div>
</div>
</header>
<main role="main" className="w-full h-full flex-grow p-3 overflow-auto">
<div className="tracking-widest text-lg title-font font-medium mb-1">Hits: { data?.GenericProduct?.total }</div>
<div className="custom-screen ">
<div className="mt-12">
<ul className="grid grid-cols-3 gap-10">
{ data?.GenericProduct?.items?.map((item, index) => {
return <GenericProductTeaserComponent
key={index}
GenericProductTeaser={item!}
setSelectedCode={setSelectedCode}
setShowModal={setShowModal}
/>
})}
</ul>
</div>
</div>
</main>
</div>
</div>
{
showModal ? (
<ProductDetailComponent
setOpen={setShowModal}
code={selectedCode}
/>
) : null
}
</main>
)
}
export default ProductListingComponent
Test search
Search for "shoes" and select "Semantic" in the "order by" dropdown
Great post! The product listing parts and especially facets is exactly the thing I have been digging into myself to see if we can replace Search & Navigation with Content Graph on our sites and your example is exactly what I was looking for.
The commerce integration is so simple that I cannot understand why there is no official Commerce nuget package yet, the package has been in beta forever and it doesn't seem like there is new development aka news versions even if official roadmaps that there will be a package for this.
Great post, Jonas. It would be great if you put a list of prerequisites in the post. One thing I noticed that existing graph nuget package (Optimizely.ContentGraph.Cms) must be upgraded to 3.9.0 to be able to index commerce content
@Thomas, I dont think optimizely graphql commerce beta package is being used anymore. Install the latest Optimizely.ContentGraph.Cms (3.9.0) and re-run the idnex job
@Vincent I am testing on 3.9.0 and there is no Commerce IIndextTarget, at least I can only find a CmsTarget, and no reference to any commerce packages in the Content Graph packages. Don't see how it can be done without at leasts using ReferenceConverter to get catalog root unless the catalog root is hardcoded somewhere, but again can't find it nor do I get any Commerce content indexed unless i create a Commerce IIndexTarget as Jonas has done in his blog post.
@Thomas, thanks for pointing this out. I haven't been able to get an instance of opti graph to test and verify this yet :( I only saw the release note said you need to update to 3.9.0 :(
I also notice that some updates in this post, thanks Jonas.
FYI for anyone coming across this blog -- a nuget package for Graph + Commerce has been released:
https://docs.developers.optimizely.com/customized-commerce/docs/optimizely-graph-for-commerce-connect
https://support.optimizely.com/hc/en-us/articles/23973422587405-2024-Commerce-Connect-release-notes#h_01J91J0XM4B32PKWP86QKR5R9G