ソースを参照

docs: Add storefront guides for nav, product list

Michael Bromley 2 年 前
コミット
e1fb4c224f

+ 307 - 84
docs/docs/guides/storefront/connect-api/index.mdx

@@ -107,53 +107,171 @@ export async function request(query: string, variables: any) {
 }
 ```
 
+There are some concrete examples of this approach in the examples later on in this guide.
 
 
+## Specifying a channel
+
+If your project has multiple [channels](/guides/core-concepts/channels/), you can specify the active channel by setting
+the `vendure-token` header on each request to match the `channelToken` for the desired channel.
+
+Let's say you have a channel with the token `uk-channel` and you want to make a request to the Shop API to get the
+products in that channel. You would set the `vendure-token` header to `uk-channel`:
+
+```ts title="src/client.ts"
+export function query(document: string, variables: Record<string, any> = {}) {
+    return fetch('https://localhost:3000/shop-api', {
+        method: 'POST',
+        headers: {
+            'content-type': 'application/json',
+            // highlight-start
+            'vendure-token': 'uk-channel',
+            // highlight-end
+        },
+        credentials: 'include',
+        body: JSON.stringify({
+          query: document,
+          variables,
+        }),
+    })
+      .then((res) => res.json())
+      .catch((err) => console.log(err));
+}
+```
+
+:::note
+If no channel token is specified, then the **default channel** will be used.
+:::
+
+:::info
+The header name `vendure-token` is the default, but can be changed using the [`apiOptions.channelTokenKey`](/reference/typescript-api/configuration/api-options/#channeltokenkey) config option.
+:::
+
+## Setting language
+
+If you have translations of your products, collections, facets etc, you can specify the language for the request by setting the `languageCode` query string on the request. The value should be one of the ISO 639-1 codes defined by the [`LanguageCode` enum](/reference/typescript-api/common/language-code/).
+
+```
+POST http://localhost:3000/shop-api?languageCode=de
+```
+
 ## Examples
 
-Here are some examples of how to set up clients to connect to the Shop API:
+Here are some examples of how to set up clients to connect to the Shop API. All of these examples include functions for setting the language and channel token.
 
 ### Fetch
 
 First we'll look at a plain [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)-based implementation, to show you that there's no special magic to a GraphQL request - it's just a POST request with a JSON body.
 
+Note that we also include a React hook in this example, but that's just to make it more convenient to use the client
+in a React component - it is not required.
+
 
 <Tabs>
 <TabItem value="client.ts" label="client.ts" default>
 
 ```ts title="src/client.ts"
+import { useState, useEffect } from 'react';
+
+// If using bearer-token based session management, we'll store the token
+// in localStorage using this key.
+const AUTH_TOKEN_KEY = 'auth_token';
+
+const API_URL = 'https://readonlydemo.vendure.io/shop-api';
+
+let languageCode: string | undefined;
+let channelToken: string | undefined;
+
+export function setLanguageCode(value: string | undefined) {
+    languageCode = value;
+}
+
+export function setChannelToken(value: string | undefined) {
+    channelToken = value;
+}
+
 export function query(document: string, variables: Record<string, any> = {}) {
-    return fetch('https://localhost:3000/shop-api', {
+    const authToken = localStorage.getItem(AUTH_TOKEN_KEY);
+    const headers = new Headers({
+        'content-type': 'application/json',
+    });
+    if (authToken) {
+        headers.append('authorization', `Bearer ${authToken}`);
+    }
+    if (channelToken) {
+        headers.append('vendure-token', channelToken);
+    }
+    let endpoint = API_URL;
+    if (languageCode) {
+        endpoint += `?languageCode=${languageCode}`;
+    }
+    // highlight-start
+    return fetch(endpoint, {
         method: 'POST',
         headers: { 'content-type': 'application/json' },
         credentials: 'include',
         body: JSON.stringify({
-          query: document,
-          variables,
+            query: document,
+            variables,
         }),
-    })
-      .then((res) => res.json())
-      .catch((err) => console.log(err));
+    // highlight-end
+    }).then((res) => {
+        if (!res.ok) {
+            throw new Error(`An error ocurred, HTTP status: ${res.status}`);
+        }
+        const newAuthToken = res.headers.get('vendure-auth-token');
+        if (newAuthToken) {
+            localStorage.setItem(AUTH_TOKEN_KEY, newAuthToken);
+        }
+        return res.json();
+    });
+}
+
+/**
+ * Here we have wrapped the `query` function into a React hook for convenient use in
+ * React components.
+ */
+ export function useQuery(
+    document: string,
+    variables: Record<string, any> = {}
+) {
+    const [data, setData] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [error, setError] = useState(null);
+
+    useEffect(() => {
+        query(document, variables)
+            .then((result) => {
+                setData(result.data);
+                setError(null);
+            })
+            .catch((err) => {
+                setError(err.message);
+                setData(null);
+            })
+            .finally(() => {
+                setLoading(false);
+            });
+    }, []);
+
+    return { data, loading, error };
 }
 ```
 
 </TabItem>
-<TabItem value="index.ts" label="index.ts" default>
-
-This function can then be used to make a GraphQL query:
-
+<TabItem value="App.tsx" label="App.tsx" default>
 
-```ts title="src/index.ts"
-import { query } from './client';
+```ts title="src/App.tsx"
+import { useQuery } from './client';
+import './style.css';
 
-const document = `
+const GET_PRODUCTS = /*GraphQL*/ `
     query GetProducts($options: ProductListOptions) {
         products(options: $options) {
             items {
                 id
                 name
                 slug
-                description
                 featuredAsset {
                     preview
                 }
@@ -162,55 +280,51 @@ const document = `
     }
 `;
 
-query(document, {
-    options: {
-        take: 10,
-        skip: 0,
-    },
-}).then((res) => console.log(res));
+export default function App() {
+    const { data, loading, error } = useQuery(GET_PRODUCTS, {
+        options: { take: 3 },
+    });
+
+    if (loading) return <p>Loading...</p>;
+    if (error) return <p>Error : {error.message}</p>;
+
+    return data.products.items.map(({ id, name, slug, featuredAsset }) => (
+        <div key={id}>
+            <h3>{name}</h3>
+            <img src={`${featuredAsset.preview}?preset=small`} alt={name} />
+        </div>
+    ));
+}
+
 ```
 
 </TabItem>
-<TabItem value="result" label="result">
-
-```json title="console log"
-{
-  "data": {
-    "products": {
-      "items": [
-        {
-          "id": "1",
-          "name": "Laptop",
-          "slug": "laptop",
-          "featuredAsset": {
-            "preview": "https://demo.vendure.io/assets/preview/71/derick-david-409858-unsplash__preview.jpg"
-          }
-        },
-        {
-          "id": "2",
-          "name": "Tablet",
-          "slug": "tablet",
-          "featuredAsset": {
-            "preview": "https://demo.vendure.io/assets/preview/b8/kelly-sikkema-685291-unsplash__preview.jpg"
-          }
-        },
-        {
-          "id": "3",
-          "name": "Wireless Optical Mouse",
-          "slug": "cordless-mouse",
-          "featuredAsset": {
-            "preview": "https://demo.vendure.io/assets/preview/a1/oscar-ivan-esquivel-arteaga-687447-unsplash__preview.jpg"
-          }
-        }
-      ]
-    }
-  }
-}
+<TabItem value="index.ts" label="index.ts" default>
+
+```ts title="src/index.ts"
+ import * as React from 'react';
+ import { StrictMode } from 'react';
+ import { createRoot } from 'react-dom/client';
+
+ import App from './App';
+
+ const rootElement = document.getElementById('root');
+ const root = createRoot(rootElement);
+
+ root.render(
+     <StrictMode>
+         <App />
+     </StrictMode>
+ );
 ```
 
 </TabItem>
 </Tabs>
 
+Here's a live version of this example:
+
+<Stackblitz id='vendure-docs-fetch-react' />
+
 As you can see, the basic implementation with `fetch` is quite straightforward. However, it is also lacking some features that other,
 dedicated client libraries will provide.
 
@@ -231,15 +345,26 @@ import {
     ApolloLink,
     HttpLink,
     InMemoryCache,
-} from '@apollo/client'
-import { setContext } from '@apollo/client/link/context'
+} from '@apollo/client';
+import { setContext } from '@apollo/client/link/context';
+
+const API_URL = `https://demo.vendure.io/shop-api`;
 
 // If using bearer-token based session management, we'll store the token
 // in localStorage using this key.
 const AUTH_TOKEN_KEY = 'auth_token';
 
+let channelToken: string | undefined;
+let languageCode: string | undefined;
+
 const httpLink = new HttpLink({
-    uri: `${process.env.NEXT_PUBLIC_URL_SHOP_API}/shop-api`,
+    uri: () => {
+        if (languageCode) {
+            return `${API_URL}?languageCode=${languageCode}`;
+        } else {
+            return API_URL;
+        }
+    },
     // This is required if using cookie-based session management,
     // so that any cookies get sent with the request.
     credentials: 'include',
@@ -260,25 +385,41 @@ const afterwareLink = new ApolloLink((operation, forward) => {
     });
 });
 
+/**
+ * Used to specify a channel token for projects that use
+ * multiple Channels.
+ */
+export function setChannelToken(value: string | undefined) {
+    channelToken = value;
+}
+
+/**
+ * Used to specify a language for any localized results.
+ */
+export function setLanguageCode(value: string | undefined) {
+    languageCode = value;
+}
+
 export const client = new ApolloClient({
     link: ApolloLink.from([
         // If we have stored the authToken from a previous
         // response, we attach it to all subsequent requests.
-        setContext(() => {
-            const authToken = localStorage.getItem(AUTH_TOKEN_KEY)
+        setContext((request, operation) => {
+            const authToken = localStorage.getItem(AUTH_TOKEN_KEY);
+            let headers: Record<string, any> = {};
             if (authToken) {
-                return {
-                    headers: {
-                        authorization: `Bearer ${authToken}`,
-                    },
-                }
+                headers.authorization = `Bearer ${authToken}`;
             }
+            if (channelToken) {
+                headers['vendure-token'] = channelToken;
+            }
+            return { headers };
         }),
         afterwareLink,
         httpLink,
     ]),
     cache: new InMemoryCache(),
-})
+});
 ```
 
 </TabItem>
@@ -333,7 +474,7 @@ export default function App() {
     return data.products.items.map(({ id, name, slug, featuredAsset }) => (
         <div key={id}>
             <h3>{name}</h3>
-            <img width="400" height="250" alt="location-reference" src={`${featuredAsset.preview}?preset=medium`} />
+            <img src={`${featuredAsset.preview}?preset=small`} alt={name} />
         </div>
     ));
 }
@@ -349,16 +490,104 @@ Here's a live version of this example:
 
 ### TanStack Query
 
-Here's an example using [@tanstack/query](https://tanstack.com/query/latest) in combination with [graphql-request](https://github.com/jasonkuhrt/graphql-request) based on [this guide](https://tanstack.com/query/v4/docs/react/graphql):
+Here's an example using [@tanstack/query](https://tanstack.com/query/latest) in combination with [graphql-request](https://github.com/jasonkuhrt/graphql-request) based on [this guide](https://tanstack.com/query/v4/docs/react/graphql).
 
+Note that in this example we have also installed the [`@graphql-typed-document-node/core` package](https://github.com/dotansimha/graphql-typed-document-node), which allows the
+client to work with TypeScript code generation for type-safe queries.
 
 <Tabs>
-<TabItem value="App.tsx" label="App.tsx" default>
+
+<TabItem value="client.ts" label="client.ts" default>
+
+```tsx title="src/client.ts"
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import {
+    GraphQLClient,
+    RequestDocument,
+    RequestMiddleware,
+    ResponseMiddleware,
+    Variables,
+} from 'graphql-request';
+
+// If using bearer-token based session management, we'll store the token
+// in localStorage using this key.
+const AUTH_TOKEN_KEY = 'auth_token';
+
+const API_URL = 'http://localhost:3000/shop-api';
+
+// If we have a session token, add it to the outgoing request
+const requestMiddleware: RequestMiddleware = async (request) => {
+    const authToken = localStorage.getItem(AUTH_TOKEN_KEY);
+    return {
+        ...request,
+        headers: {
+            ...request.headers,
+            ...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
+        },
+    };
+};
+
+// Check all responses for a new session token
+const responseMiddleware: ResponseMiddleware = (response) => {
+    if (!(response instanceof Error) && response.errors) {
+        const authHeader = response.headers.get('vendure-auth-token');
+        if (authHeader) {
+            // If the session token has been returned by the Vendure
+            // server, we store it in localStorage
+            localStorage.setItem(AUTH_TOKEN_KEY, authHeader);
+        }
+    }
+};
+
+const client = new GraphQLClient(API_URL, {
+    // Required for cookie-based sessions
+    credentials: 'include',
+    requestMiddleware,
+    responseMiddleware,
+});
+
+/**
+ * Sets the languageCode to be used for all subsequent requests.
+ */
+export function setLanguageCode(languageCode: string | undefined) {
+    if (!languageCode) {
+        client.setEndpoint(API_URL);
+    } else {
+        client.setEndpoint(`${API_URL}?languageCode=${languageCode}`);
+    }
+}
+
+/**
+ * Sets the channel token to be used for all subsequent requests.
+ */
+export function setChannelToken(channelToken: string | undefined) {
+    if (!channelToken) {
+        client.setHeader('vendure-token', undefined);
+    } else {
+        client.setHeader('vendure-token', channelToken);
+    }
+}
+
+/**
+ * Makes a GraphQL request using the `graphql-request` client.
+ */
+export function request<T, V extends Variables = Variables>(
+    document: RequestDocument | TypedDocumentNode<T, V>,
+    variables: Record<string, any> = {}
+) {
+    return client.request(document, variables);
+}
+```
+
+</TabItem>
+
+<TabItem value="App.tsx" label="App.tsx">
 
 ```tsx title="src/App.tsx"
 import * as React from 'react';
-import { gql, request } from 'graphql-request';
+import { gql } from 'graphql-request';
 import { useQuery } from '@tanstack/react-query';
+import { request } from './client';
 
 const GET_PRODUCTS = gql`
     query GetProducts($options: ProductListOptions) {
@@ -376,33 +605,27 @@ const GET_PRODUCTS = gql`
 `;
 
 export default function App() {
-    const { data } = useQuery({
+    const { isLoading, data } = useQuery({
         queryKey: ['products'],
         queryFn: async () =>
-            request(
-                'https://demo.vendure.io/shop-api',
-                GET_PRODUCTS,
-                { options: { take: 3 } }
-            ),
+            request(GET_PRODUCTS, {
+                options: { take: 3 },
+            }),
     });
 
+    if (isLoading) return <p>Loading...</p>;
+
     return data ? (
         data.products.items.map(({ id, name, slug, featuredAsset }) => (
             <div key={id}>
                 <h3>{name}</h3>
-                <img
-                    width="400"
-                    height="250"
-                    alt="location-reference"
-                    src={`${featuredAsset.preview}?preset=medium`}
-                />
+                <img src={`${featuredAsset.preview}?preset=small`} alt={name} />
             </div>
         ))
     ) : (
         <>Loading...</>
     );
 }
-
 ```
 
 </TabItem>

+ 701 - 0
docs/docs/guides/storefront/listing-products/index.mdx

@@ -0,0 +1,701 @@
+---
+title: "Listing Products"
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import Stackblitz from '@site/src/components/Stackblitz';
+
+Products are listed when:
+
+- Displaying the contents of a collection
+- Displaying search results
+
+In Vendure, we usually use the `search` query for both of these. The reason is that the `search` query is optimized
+for high performance, because it is backed by a dedicated search index. Other queries such as `products` or `Collection.productVariants` _can_ also
+be used to fetch a list of products, but they need to perform much more complex database queries, and are therefore slower.
+
+## Listing products in a collection
+
+Following on from the [navigation example](/guides/storefront/navigation-menu/), let's assume that a customer has
+clicked on a collection item from the menu, and we want to display the products in that collection.
+
+Typically, we will know the `slug` of the selected collection, so we can use the `collection` query to fetch the
+details of this collection:
+
+<Tabs>
+<TabItem value="Query" label="Query" default>
+
+```graphql
+query GetCollection($slug: String!) {
+  collection(slug: $slug) {
+    id
+    name
+    slug
+    description
+    featuredAsset {
+      id
+      preview
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Variables" label="Variables">
+
+```json
+{
+  "slug": "electronics"
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+```json
+{
+  "data": {
+    "collection": {
+      "id": "2",
+      "name": "Electronics",
+      "slug": "electronics",
+      "description": "",
+      "featuredAsset": {
+        "id": "16",
+        "preview": "https://demo.vendure.io/assets/preview/5b/jakob-owens-274337-unsplash__preview.jpg"
+      }
+    }
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+The collection data can be used to render the page header.
+
+Next, we can use the `search` query to fetch the products in the collection:
+
+
+<Tabs>
+<TabItem value="Query" label="Query" default>
+
+```graphql
+query GetCollectionProducts($slug: String!, $skip: Int, $take: Int) {
+  search(
+    input: {
+      // highlight-next-line
+      collectionSlug: $slug,
+      groupByProduct: true,
+      skip: $skip,
+      take: $take }
+  ) {
+    totalItems
+    items {
+      productName
+      slug
+      productAsset {
+        id
+        preview
+      }
+      priceWithTax {
+        ... on SinglePrice {
+          value
+        }
+        ... on PriceRange {
+          min
+          max
+        }
+      }
+      currencyCode
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Variables" label="Variables">
+
+```json
+{
+  "slug": "electronics",
+  "skip": 0,
+  "take": 10
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+(the following data has been truncated for brevity)
+
+```json
+{
+  "data": {
+    "search": {
+      "totalItems": 20,
+      "items": [
+        {
+          "productName": "Laptop",
+          "slug": "laptop",
+          "productAsset": {
+            "id": "1",
+            "preview": "https://demo.vendure.io/assets/preview/71/derick-david-409858-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 155880,
+            "max": 275880
+          },
+          "currencyCode": "USD"
+        },
+        {
+          "productName": "Tablet",
+          "slug": "tablet",
+          "productAsset": {
+            "id": "2",
+            "preview": "https://demo.vendure.io/assets/preview/b8/kelly-sikkema-685291-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 39480,
+            "max": 53400
+          },
+          "currencyCode": "USD"
+        },
+      ],
+    },
+  },
+}
+```
+
+
+</TabItem>
+</Tabs>
+
+:::note
+The key thing to note here is that we are using the `collectionSlug` input to the `search` query. This ensures
+that the results all belong to the selected collection.
+:::
+
+Here's a live demo of the above code in action:
+
+<Stackblitz id='vendure-docs-listing-collection-products' />
+
+## Product search
+
+The `search` query can also be used to perform a full-text search of the products in the catalog by passing the
+`term` input:
+
+<Tabs>
+<TabItem value="Query" label="Query">
+
+```graphql
+query SearchProducts($term: String!, $skip: Int, $take: Int) {
+  search(
+    input: {
+      // highlight-next-line
+      term: $term,
+      groupByProduct: true,
+      skip: $skip,
+      take: $take }
+  ) {
+    totalItems
+    items {
+      productName
+      slug
+      productAsset {
+        id
+        preview
+      }
+      priceWithTax {
+        ... on SinglePrice {
+          value
+        }
+        ... on PriceRange {
+          min
+          max
+        }
+      }
+      currencyCode
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Variables" label="Variables">
+
+```json
+{
+  "term": "camera",
+  "skip": 0,
+  "take": 10
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+(the following data has been truncated for brevity)
+
+```json
+{
+  "data": {
+    "search": {
+      "totalItems": 8,
+      "items": [
+        {
+          "productName": "Instant Camera",
+          "slug": "instant-camera",
+          "productAsset": {
+            "id": "12",
+            "preview": "https://demo.vendure.io/assets/preview/b5/eniko-kis-663725-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 20999,
+            "max": 20999
+          },
+          "currencyCode": "USD"
+        },
+        {
+          "productName": "Camera Lens",
+          "slug": "camera-lens",
+          "productAsset": {
+            "id": "13",
+            "preview": "https://demo.vendure.io/assets/preview/9b/brandi-redd-104140-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 12480,
+            "max": 12480
+          },
+          "currencyCode": "USD"
+        }
+      ]
+    }
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+:::tip
+You can also limit the full-text search to a specific collection by passing the
+`collectionSlug` or `collectionId` input.
+:::
+
+## Faceted search
+
+The `search` query can also be used to perform faceted search. This is a powerful feature which allows customers
+to filter the results according to the facet values assigned to the products & variants.
+
+By using the `facetValues` field, the search query will return a list of all the facet values which are present
+in the result set. This can be used to render a list of checkboxes or other UI elements which allow the customer
+to filter the results.
+
+<Tabs>
+<TabItem value="Query" label="Query">
+
+```graphql
+query SearchProducts($term: String!, $skip: Int, $take: Int) {
+  search(
+    input: {
+      term: $term,
+      groupByProduct: true,
+      skip: $skip,
+      take: $take }
+  ) {
+    totalItems
+    // highlight-start
+    facetValues {
+      count
+      facetValue {
+        id
+        name
+        facet {
+          id
+          name
+        }
+      }
+    }
+    // highlight-end
+    items {
+      productName
+      slug
+      productAsset {
+        id
+        preview
+      }
+      priceWithTax {
+        ... on SinglePrice {
+          value
+        }
+        ... on PriceRange {
+          min
+          max
+        }
+      }
+      currencyCode
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Variables" label="Variables" default>
+
+```json
+{
+  "term": "camera",
+  "skip": 0,
+  "take": 10
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+(the following data has been truncated for brevity)
+
+```json
+{
+  "data": {
+    "search": {
+      "totalItems": 8,
+      // highlight-start
+      "facetValues": [
+        {
+          "facetValue": {
+            "id": "1",
+            "name": "Electronics",
+            "facet": {
+              "id": "1",
+              "name": "category"
+            }
+          },
+          "count": 8
+        },
+        {
+          "facetValue": {
+            "id": "9",
+            "name": "Photo",
+            "facet": {
+              "id": "1",
+              "name": "category"
+            }
+          },
+          "count": 8
+        },
+        {
+          "facetValue": {
+            "id": "10",
+            "name": "Polaroid",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 1
+        },
+        {
+          "facetValue": {
+            "id": "11",
+            "name": "Nikkon",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 2
+        },
+        {
+          "facetValue": {
+            "id": "12",
+            "name": "Agfa",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 1
+        },
+        {
+          "facetValue": {
+            "id": "14",
+            "name": "Kodak",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 1
+        },
+        {
+          "facetValue": {
+            "id": "15",
+            "name": "Sony",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 1
+        },
+        {
+          "facetValue": {
+            "id": "16",
+            "name": "Rolleiflex",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 1
+        }
+      ],
+      // highlight-end
+      "items": [
+        {
+          "productName": "Instant Camera",
+          "slug": "instant-camera",
+          "productAsset": {
+            "id": "12",
+            "preview": "https://demo.vendure.io/assets/preview/b5/eniko-kis-663725-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 20999,
+            "max": 20999
+          },
+          "currencyCode": "USD"
+        },
+        {
+          "productName": "Camera Lens",
+          "slug": "camera-lens",
+          "productAsset": {
+            "id": "13",
+            "preview": "https://demo.vendure.io/assets/preview/9b/brandi-redd-104140-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 12480,
+            "max": 12480
+          },
+          "currencyCode": "USD"
+        },
+        {
+          "productName": "Vintage Folding Camera",
+          "slug": "vintage-folding-camera",
+          "productAsset": {
+            "id": "14",
+            "preview": "https://demo.vendure.io/assets/preview/3c/jonathan-talbert-697262-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "min": 642000,
+            "max": 642000
+          },
+          "currencyCode": "USD"
+        }
+      ]
+    }
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+These facet values can then be used to filter the results by passing them to the `facetValueFilters` input
+
+For example, let's filter the results to only include products which have the "Nikkon" brand. Based on our last
+request we know that there should be 2 such products, and that the `facetValue.id` for the "Nikkon" brand is `11`.
+
+```json
+{
+  "facetValue": {
+    // highlight-next-line
+    "id": "11",
+    "name": "Nikkon",
+    "facet": {
+      "id": "2",
+      "name": "brand"
+    }
+  },
+  // highlight-next-line
+  "count": 2
+}
+```
+
+Here's how we can use this information to filter the results:
+
+:::note
+In the next example, rather than passing each individual variable (skip, take, term) as a separate argument,
+we are passing the entire `SearchInput` object as a variable. This allows us more flexibility in how
+we use the query, as we can easily add or remove properties from the input object without having to
+change the query itself.
+:::
+
+
+<Tabs>
+<TabItem value="Query" label="Query">
+
+```graphql
+query SearchProducts($input: SearchInput!) {
+  // highlight-next-line
+  search(input: $input) {
+    totalItems
+    facetValues {
+      count
+      facetValue {
+        id
+        name
+        facet {
+          id
+          name
+        }
+      }
+    }
+    items {
+      productName
+      slug
+      productAsset {
+        id
+        preview
+      }
+      priceWithTax {
+        ... on SinglePrice {
+          value
+        }
+        ... on PriceRange {
+          min
+          max
+        }
+      }
+      currencyCode
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Variables" label="Variables" default>
+
+```json
+{
+  "input": {
+    "term": "camera",
+    "skip": 0,
+    "take": 10,
+    "groupByProduct": true,
+    // highlight-start
+    "facetValueFilters": [
+      { "and": "11" }
+    ]
+    // highlight-end
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+```json
+{
+  "data": {
+    "search": {
+      "totalItems": 2,
+      "facetValues": [
+        {
+          "facetValue": {
+            "id": "1",
+            "name": "Electronics",
+            "facet": {
+              "id": "1",
+              "name": "category"
+            }
+          },
+          "count": 2
+        },
+        {
+          "facetValue": {
+            "id": "9",
+            "name": "Photo",
+            "facet": {
+              "id": "1",
+              "name": "category"
+            }
+          },
+          "count": 2
+        },
+        {
+          "facetValue": {
+            "id": "11",
+            "name": "Nikkon",
+            "facet": {
+              "id": "2",
+              "name": "brand"
+            }
+          },
+          "count": 2
+        }
+      ],
+      "items": [
+        {
+          "productName": "Camera Lens",
+          "slug": "camera-lens",
+          "productAsset": {
+            "id": "13",
+            "preview": "https://demo.vendure.io/assets/preview/9b/brandi-redd-104140-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "value": 12480
+          },
+          "currencyCode": "USD"
+        },
+        {
+          "productName": "Nikkormat SLR Camera",
+          "slug": "nikkormat-slr-camera",
+          "productAsset": {
+            "id": "18",
+            "preview": "https://demo.vendure.io/assets/preview/95/chuttersnap-324234-unsplash__preview.jpg"
+          },
+          "priceWithTax": {
+            "value": 73800
+          },
+          "currencyCode": "USD"
+        }
+      ]
+    }
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+:::info
+The `facetValueFilters` input can be used to specify multiple filters, combining each with either `and` or `or`.
+
+For example, to filter by both the "Camera" **and** "Nikkon" facet values, we would use:
+
+```json
+{
+  "facetValueFilters": [
+    { "and": "9" },
+    { "and": "11" }
+  ]
+}
+```
+
+To filter by "Nikkon" **or** "Sony", we would use:
+
+```json
+{
+  "facetValueFilters": [
+    { "or": ["11", "15"] }
+  ]
+}
+```
+:::
+
+Here's a live example of faceted search. Try searching for terms like "shoe", "plant" or "ball".
+
+<Stackblitz id="vendure-docs-faceted-search"></Stackblitz>

+ 155 - 0
docs/docs/guides/storefront/navigation-menu/index.mdx

@@ -0,0 +1,155 @@
+---
+title: "Navigation Menu"
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import Stackblitz from '@site/src/components/Stackblitz';
+
+A navigation menu allows your customers to navigate your store and find the products they are looking for.
+
+Typically, navigation is based on a hierarchy of [collections](/guides/core-concepts/collections/). We can get the top-level
+collections using the `collections` query with the `topLevelOnly` filter:
+
+
+<Tabs>
+<TabItem value="Query" label="Query" default>
+
+```graphql
+query GetTopLevelCollections {
+  collections(options: { topLevelOnly: true }) {
+    items {
+      id
+      slug
+      name
+      featuredAsset {
+        id
+        preview
+      }
+    }
+  }
+}
+```
+
+</TabItem>
+<TabItem value="Response" label="Response" >
+
+```json
+{
+  "data": {
+    "collections": {
+      "items": [
+        {
+          "id": "2",
+          "slug": "electronics",
+          "name": "Electronics",
+          "featuredAsset": {
+            "id": "16",
+            "preview": "https://demo.vendure.io/assets/preview/5b/jakob-owens-274337-unsplash__preview.jpg"
+          }
+        },
+        {
+          "id": "5",
+          "slug": "home-garden",
+          "name": "Home & Garden",
+          "featuredAsset": {
+            "id": "47",
+            "preview": "https://demo.vendure.io/assets/preview/3e/paul-weaver-1120584-unsplash__preview.jpg"
+          }
+        },
+        {
+          "id": "8",
+          "slug": "sports-outdoor",
+          "name": "Sports & Outdoor",
+          "featuredAsset": {
+            "id": "24",
+            "preview": "https://demo.vendure.io/assets/preview/96/michael-guite-571169-unsplash__preview.jpg"
+          }
+        }
+      ]
+    }
+  }
+}
+```
+
+</TabItem>
+</Tabs>
+
+## Building a navigation tree
+
+The `collections` query returns a flat list of collections, but we often want to display them in a tree-like structure.
+This way, we can build up a navigation menu which reflects the hierarchy of collections.
+
+First of all we need to ensure that we have the `parentId` property on each collection.
+
+```graphql title="Shop API"
+query GetAllCollections {
+  collections(options: { topLevelOnly: true }) {
+    items {
+      id
+      slug
+      name
+      // highlight-next-line
+      parentId
+      featuredAsset {
+        id
+        preview
+      }
+    }
+  }
+}
+```
+
+Then we can use this data to build up a tree structure. The following code snippet shows how this can be done in TypeScript:
+
+```ts title="src/utils/array-to-tree.ts"
+export type HasParent = { id: string; parentId: string | null };
+export type TreeNode<T extends HasParent> = T & {
+    children: Array<TreeNode<T>>;
+};
+export type RootNode<T extends HasParent> = {
+    id?: string;
+    children: Array<TreeNode<T>>;
+};
+
+/**
+ * Builds a tree from an array of nodes which have a parent.
+ * Based on https://stackoverflow.com/a/31247960/772859, modified to preserve ordering.
+ */
+export function arrayToTree<T extends HasParent>(nodes: T[]): RootNode<T> {
+    const topLevelNodes: Array<TreeNode<T>> = [];
+    const mappedArr: { [id: string]: TreeNode<T> } = {};
+
+    // First map the nodes of the array to an object -> create a hash table.
+    for (const node of nodes) {
+        mappedArr[node.id] = { ...(node as any), children: [] };
+    }
+
+    for (const id of nodes.map((n) => n.id)) {
+        if (mappedArr.hasOwnProperty(id)) {
+            const mappedElem = mappedArr[id];
+            const parentId = mappedElem.parentId;
+            if (!parent) {
+                continue;
+            }
+            // If the element is not at the root level, add it to its parent array of children.
+            const parentIsRoot = !mappedArr[parentId];
+            if (!parentIsRoot) {
+                if (mappedArr[parentId]) {
+                    mappedArr[parentId].children.push(mappedElem);
+                } else {
+                    mappedArr[parentId] = { children: [mappedElem] } as any;
+                }
+            } else {
+                topLevelNodes.push(mappedElem);
+            }
+        }
+    }
+    const rootId = topLevelNodes.length ? topLevelNodes[0].parentId : undefined;
+    return { id: rootId, children: topLevelNodes };
+}
+```
+
+Here's a live demo of the above code in action:
+
+<Stackblitz id='vendure-docs-collection-tree' />

+ 6 - 1
docs/sidebars.js

@@ -93,7 +93,12 @@ const sidebars = {
             customProps: {
                 icon: icon.shoppingBag,
             },
-            items: ['guides/storefront/storefront-starters/index', 'guides/storefront/connect-api/index'],
+            items: [
+                'guides/storefront/storefront-starters/index',
+                'guides/storefront/connect-api/index',
+                'guides/storefront/navigation-menu/index',
+                'guides/storefront/listing-products/index',
+            ],
         },
         {
             type: 'category',