Logo BrocksiNet

BrocksiNet

Try to clean the web

Multi-Page or Single-Page Variants Selection

It feels wrong if you know ecommerce systems and then use Shopware's variants for the first time. You click on a variant, and the entire storefront page is reloaded. This is very helpful (and good for SEO) if your variants differ in text and images. But it isn't delightful if the server is not very fast. Also, if you have many similar options in text and pictures. There is also the use case where you want to show all possible options on the product listing page (category). So you may ask yourself if you need to invent the wheel for a more configurable approach to the Variants Selection. Or how could you improve this in a simple, fast, composable, and headless frontend?

Content

Dictionary

  • Options / Properties → Like color, size, …
  • Option Value / Property Value → blue, S, M, L, red, ...
  • Variant → The Product that has one or multiple Options and Option Values
  • Parent → The Product that holds all the Variants (as Childs)
  • Simple Product → A product without any Variant
  • Child → We will call it Variant, not Child; sadly, in API output, it is called children
  • Listing → The Category Page with defined Products (Simple, Parent, …)
  • Multi-Page Variants Selection → Reloads the complete Page
  • Single-Page Variants Selection → Without any Reload
  • Customizable Products → Can work with or without Variants; see Documentation (paid Feature included from Rise plan)

store API Requests for Variant Selection

For Multi-Page Variants Selection

In the Multi-Page Variant Selection, you work with the data you already have from the Product, such as options and groups. So there are different ways to find the variant because you do not load the children like in the Single-Page Variants selection below.

In store-API docs, you will find a request called “Search for a matching variant by product options.” Please do not use this call. You will need an additional call to get the product data if you do. We want to find the matching variant and get the product data in one call, and we will use filters for that.

async function findVariantForSelectedOptions(options?: {
    [code: string]: string;
  }): Promise<Schemas["Product"] | undefined> {
    const filter = [
      {
        type: "equals",
        field: "parentId",
        value: parentProductId.value as string,
      },
      ...Object.values(options || selected.value).map((id) => ({
        type: "equals",
        field: "optionIds",
        value: id as string,
      })),
    ];
    try {
      const response = await apiClient.invoke("readProduct post /product", {
        filter,
        limit: 1,
        includes: {Product: ["id", "translated", "productNumber", "seoUrls"],
          seo_url: ["seoPathInfo"],
        },
        associations: {
          seoUrls: {},
        },
      });
      return response?.elements?.[0]; // return first matching product
    } catch (e) {
      console.error("SwProductDetails:findVariantForSelectedOptions", e);
    }
  }

You can find this code here, and it is used here.

For Single-Page Variants Selection

We want to make one Request and get all the Data (including stock data) we need to create a Variant Selection. In the example call below, I show you how to call the data for one parent product that includes all variants (children).

// store-api/product - POST Request
{
    "page": 1,
    "limit": 1,
    "associations": {
        "options": {
            "associations": {
                "group": {}
            }
        },
        "media": {},
        "seoUrls": {},
        "children": {
            "associations": {
                "options": {
                    "associations": {
                        "group": {}
                    }
                },
                "media": {},
                "seoUrls": {}
            }
        }
    },
    "filter": [
        {
            "type": "equals",
            "field": "id",
            "value": "cd30de26a6c44c2aac261717f199c365"
        }
    ]
}

Multi-Page Variants Selection (Default Shopware)

So first, let’s look at the Demo for the Multi-Page Variants Selection so you can try it out and have the same understanding of how it works.

When you change the Variant, the URL and the whole Page, including Images, Descriptions, and so on, are updated. As I said before, this is great when the products are different; it benefits the customer by allowing them to explore the details and is suitable for SEO reasons. But this comes at some costs; the update triggers new requests, and until the Page is ready, the customer has to wait a bit.

This approach is excellent if your products differ significantly or you want to sell different accessories or spare parts for a specific variant. Then, you can use complete reloading to show different products cross-sell or up-sell.

Single-Page Variants Selection

We also got a Demo for the Single-Page Variants Selection, but remember that this demo is based on React and not on Vue 3/Nuxt 3.

The big difference is that everything happens on one Page without reloading. That is nice for the customer but sometimes more challenging for the developers because you need to update Images, Prices, or, let’s say, everything different from one Variant to another yourself.

In the demo, only a little is updated, but when you click around, you see that some options are out of stock and cannot be added. So, we have already considered the stock; on the Multi-Page Variants Selection, you see that only after changing the Page. Users love not to waste time with options they can not buy, and especially in ecommerce, this can be frustrating.

Let’s look into this and connect the output of the store API Call with the Frontend you see.

Data-Transforming

In our React Demo, we use some functions to transform the data we get from the store API response to a defined object structure we use on our frontend. See transformOptions and transformVariants; the returned objects will have these structures:

export type ProductOption = {
  id: string;
  name: string;
  values: string[];
};

export type ProductVariant = {
  id: string;
  title: string;
  availableForSale: boolean;
  selectedOptions: {
    name: string;
    value: string;
  }[];
  price: Money;
};

Frontend

Let’s look at where the magic is happening and how the Frontend decides which combinations are possible depending on the options and variants. Check the code of the variant-selector.tsx file in our nextjs-shopware-starter Repository.

The VariantSelector component takes two props: options and variants. options is an array of ProductOption objects, each representing a different characteristic of a product (like color or size). variants is an array of ProductVariant objects, each representing a specific combination of product options (like an XXL red T-shirt).

The component first checks if there are no options or just one option for the Product. If so, the component returns null, effectively rendering nothing.

Next, the component creates an array of combinations. Each combination is an object that represents a variant of the Product, including its ID, availability, and a key-value pair for each option (like { color: 'Red', size: 'Large' }).

The component then returns a list of dl elements, one for each product option. Each dl contains a dt that displays the option's name and a dd with a button for each possible option value.

When clicked, each button updates the URL's search parameters to reflect the selected option. The button is disabled if the combination of the current option and the other selected options is unavailable for sale. The button's appearance changes based on whether the option is active (i.e., currently selected) and whether the option is available for sale.

Conclusion

If your products mostly share the same attributes and descriptions, you should consider the Single-Page Variant Selection. This approach is better for the user experience when you have a lot of variants (it reduces waiting time). However, it would be best to consider what to update when a variant changes, such as Images and Prices. This part needs to be added (in the future) in our React demo for a better experience.

Listing with all Options for Products

So we “talked” a lot about the Product detail pages, but many customers already want to show the Options (sizes, colors, ...) on the product listing pages. In our demo store, we already have filters for Options, but in this case, we want to show the related Options in the product box. Let’s check the store API call we need for that.

Solve it with Aggregations

There are many different ways, and first, I want to describe how to use Aggregations to display all the options for one parent product. So below, you find a product listing call with aggregations that will collect all options related to one parent.

// {{baseUrl}}/product-listing/54a3e428361147c0a3372d44070fb84d - POST Request
{
    "includes": {
        "product": [
            "id",
            "parentId",
            "productNumber",
            "options",
            "variation",
            "children"
        ],
        "property_group_option": [
            "name",
            "id",
            "group"
        ],
        "property_group": [
            "id",
            "name"
        ]
    },
    "associations": {
        "options": {
            "associations": {
                "group": {}
            }
        },
        "media": {},
        "seoUrls": {}
    },
    "aggregations": [
        {
            "name": "parent_options",
            "type": "terms",
            "field": "parentId",
            "aggregation": {
                "name": "parent_option_ids",
                "type": "entity",
                "definition": "property_group_option",
                "field": "options.id"
            }
        }
    ]
}

The output of the Store API endpoint should now look like this:

// ... we just look at the aggregations output
"aggregations": {
        "parent_options": {
            "name": "parent_options",
            "buckets": [
                {
                    "key": "",
                    "count": 2,
                    "parent_option_ids": {
                        "extensions": [],
                        "name": "parent_option_ids",
                        "entities": []
                    },
                    "apiAlias": "aggregation_bucket"
                },
                {
                    "key": "b507705739194f0db41b66b3f0de5671",
                    "count": 3,
                    "parent_option_ids": {
                        "extensions": [],
                        "name": "parent_option_ids",
                        "entities": [
                            {
                                "extensions": {
                                    "foreignKeys": {
                                        "extensions": [],
                                        "apiAlias": "property_group_option_foreign_keys_extension"
                                    }
                                },
                                "_uniqueIdentifier": "c3979c6ac5844104afa113c4fbde87f8",
                                "versionId": null,
                                "translated": {
                                    "name": "200 ml",
                                    "position": 1,
                                    "customFields": []
                                },
                                "createdAt": "2020-08-06T06:26:29.996+00:00",
                                "updatedAt": null,
                                "groupId": "b113a709d73045d2947d8a38f76f64d6",
                                "name": "200 ml",
                                "position": 1,
																// ...
                                "id": "c3979c6ac5844104afa113c4fbde87f8"
                            },
               sizes             {
                                "extensions": {
                                    "foreignKeys": {
                                        "extensions": [],
                                        "apiAlias": "property_group_option_foreign_keys_extension"
                                    }
                                },
                                "_uniqueIdentifier": "c5193dd75be7402aaa8a822a10eefa0f",
                                "versionId": null,
                                "translated": {
                                    "name": "700 ml",
                                    "position": 4,
                                    "customFields": []
                                },
                                "createdAt": "2020-08-06T06:26:29.998+00:00",
                                "updatedAt": null,
                                "groupId": "b113a709d73045d2947d8a38f76f64d6",
                                "name": "700 ml",
                                "position": 4,
																// ...
                                "id": "c5193dd75be7402aaa8a822a10eefa0f"
                            },
                            {
                                "extensions": {
                                    "foreignKeys": {
                                        "extensions": [],
                                        "apiAlias": "property_group_option_foreign_keys_extension"
                                    }
                                },
                                "_uniqueIdentifier": "0ea96a99423148e4adbc0e0a93d08dd2",
                                "versionId": null,
                                "translated": {
                                    "name": "350 ml",
                                    "position": 3,
                                    "customFields": []
                                },
                                "createdAt": "2020-08-06T06:26:29.981+00:00",
                                "updatedAt": null,
                                "groupId": "b113a709d73045d2947d8a38f76f64d6",
                                "name": "350 ml",
                                "position": 3,
																// ...
                                "id": "0ea96a99423148e4adbc0e0a93d08dd2"
                            }
                        ]
                    },
                    "apiAlias": "aggregation_bucket"
                },
                // ...
            ],
            "apiAlias": "parent_options_aggregation"
        },
        // ...
    },
// ...

You now know that the Product with the parentId b507705739194f0db41b66b3f0de5671 has three options, and you also have the name, position, groupId, and so on from that option. You can now display those options in a product box on your custom frontend. For that, you need to filter with the parentId in that Aggregation output, and when the parentId and the key in the parent_options bucket match, you can display the option in a lovely template. Remember that you get simple products and variants in your elements output; the parent product is not always present.

The good thing about that solution is that you do not need additional requests. When you work with Nuxt, you should also consider reducing the output of the aggregation values with useAsyncData and the pick param. The bad thing about that solution is that it can get slow over time, depending on how many products you have (Performance Hint: MySQL).

Why can I not only use Associations in that case?

When you use the product-listing call from above, you can not be sure that the parent product is present. So you only get the first variant of a parent (performance reasons), and that does not contain any children, so the association will not help you with that.

Solve it Client-Only

There is also another way how you can solve this. Let’s say we rely on the parentId, which means we only do any additional logic when the parentId is not null. So we do not change the original product-listing call; we just make an additional call when the parentId is present. So, create a new component, wrap that component with the ClientOnly component, and only fire the request when the product is in the user's viewport. You can use the same request from above as we did for the Single-Page Variant Selection.

The downside is that the user is unaware that some data is reloading, but this can be solved with a skeleton template showing that something is loading. The data gets only fetched on the client side, which means it does not impact SEO (can be good or bad).

The even more advanced way

You could use server routes and build your own optimized response for SEO and minified JSON. This would allow you to make all the requests inside the server routes and just return what you need. Combining the Aggregations-Solution or separated calls would also be possible. This is the most advanced solution because you should also think about Caching and Invalidation, and it requires adding Composables that are using the server routes.

TL;DR

The data you need to build Single-Page or Multi-Page Variant Selections is there. It depends on your products; for one merchant, the Single-Page, and for another, the Multi-Page performs better (A/B Testing).

Showing all possible Product options on a listing page can be a challenge. But you can solve that with Aggregations. We did the same for the Product Count on the Search page.

Released - 18.04.2024

Comments


About the author

Bild Bjoern
Björn MeyerSoftware Engineer
Björn is interested in the details of web technologies. He started with Joomla, Drupal, Typo3, vBulletin, Fireworks and Photoshop. Today his focus is on PHP for example with Magento 2, Shopware 6 and Symfony. He also likes JavaScript with Angular, React, VueJs and TypeScript. In his free time, he seeks constant balance with swimming, running and yoga.