Customize your PLP
The Product Listing Page (PLP) is designed with flexibility in mind, allowing you to tailor its appearance and functionality to meet specific business needs. Below are the key areas where customization is available either through our SCAYLE Panel or directly in the source code.
If you haven't already, please start by reading our General Overview of the PLP page.
Category Structure
The Category Structure is an essential part of any e-commerce shop. It provides a clear and structured way to display product categories and subcategories, helping users navigate complex structures with ease. Furthermore, a clear category structure encourages product exploration and improves the usability aspect of the overall shop.
Our category structure can be easily configured through the SCAYLE Panel. For detailed guidance on setting up new categories and updating the structure, refer to the Shop Categories Documentation.
Storefront Boilerplate automatically fetches the category tree from the Storefront API and displays the tree to the user to allow for easy navigation. There is a slight difference in the appearance of category structure between our two web stacks:
- Desktop View
- By default, the category tree is displayed as a side navigation panel, showing root categories and their immediate children. Selecting a child category will reveal its own children below.
- Mobile View
- On mobile devices, the category tree is displayed using breadcrumbs for upper-level categories and chips for immediate children of the currently active category.
Content Banners
To make your PLP visually stand out even more, it can be beneficial to show a related image at the top of the page. Besides the page being more visually appealing, by using the banner, you can define a focus point as it will be in the user's first viewport of your PLP page. This can help you boost specific marketing campaign, category page, product brand or something else.
This requires a working CMS Setup as described on our CMS Integrations page for either Storyblok or Contentful.
For this we will need two components that come out of the box with the SCAYLE Storefront CMS Module:
CMSCategoryData
which fetches the data and makes it available to the child components using aslot
CMSImage
which is the actual component that will render the image
When you open the file pages/c/[...categories]/[...slug]-[id].vue
you will see the entry point and main code from the PLP. Upon further inspection, we can see that we render the CategorySideNavigation
component and next to it, we have our main content of the page.
We now want to show an image on top of the PLP ahead of any other content.
The first step is to fetch the data using our CMSCategoryData
component, like this:
<div class="w-full grow">
<CMSCategoryData :selected-category="currentCategoryId" class="flex flex-col">
<template #default="{ content, hasTeaserImage }">
<!-- Child Rendering -->
</template>
</CMSCategoryData>
<!-- ... -->
</div>
When rendered, this component will fetch the data from the selected CMS Provider and make the content available in the template
slot.
We can then use this content
provided to us to render the actual UI which can be easily achieved through the CMSImage
component:
<CMSImage
v-if="hasTeaserImage" # We only want to render the component if we have a teaser image defined for this category
:blok="content" # The actual content where the image is defined we will show
is-teaser # `is-teaser` selects the correct image from the `content`
is-cover # `is-cover` makes sure we fill the whole space with the image, potentially cutting off parts of it
class="h-[12.5rem] w-full" # we give the element a specific height and the full width
/>
The full code looks like this then:
<div class="w-full grow">
<CMSCategoryData :selected-category="currentCategoryId" class="flex flex-col">
<template #default="{ content, hasTeaserImage }">
<CMSImage
v-if="hasTeaserImage" # We only want to render the component if we have a teaser image defined for this category
:blok="content" # The actual content where the image is defined we will show
is-teaser # `is-teaser` selects the correct image from the `content`
is-cover # `is-cover` makes sure we fill the whole space with the image, potentially cutting off parts of it
class="mb-4 h-[12.5rem] w-full" # we give the element a specific height and the full width
/>
</template>
</CMSCategoryData>
<!-- ... -->
</div>
The final output of the implementation is:
Filters
Filters are crucial tools that help your users narrow down product searches and find items that meet their specific criteria. Well-maintained and business-focused filters should be a pillar of every successful e-commerce business.
Our filters are configured through the SCAYLE Panel and are presented in a filter flyout, as shown in the picture above. They consist of default filters and category-specific filters based on category attributes.
Default Filters
Default filters are set at the shop level and apply to all categories. Common examples of default filters include price or sale. For detailed information on configuring default filters, please refer to the Default Filters Setup section of the User Guide.
Category-Specific Filters
Category-specific filters are based on attributes unique to each category. For more details on configuring these filters, see the Category-Specific Filters Configuration section of the User Guide.
Color Filter
The color filter is special because it shows actual color bubbles instead of just the color names.
To achieve this, we have a mapping of color attribute value
to hex codes as key-value pairs in our codebase.
Since the values for your color attribute can be different from ours, they can be easily adjusted in the constants/product.ts
file.
export const ProductColor: Record<string, string | string[]> = {
weiss_1: '#ffffff',
beige: '#e3dad1',
schwarz: '#000000',
grau: '#6b7280',
rot: '#ef4444',
blau: '#3b82f6',
gruen: '#22c55e',
gelb: '#eab308',
orange: '#f97316',
braun: '#bfa094',
pink: '#ec4899',
lila: '#a855f7',
mischfarben: ['#0000ff', '#ffa500', '#ff0000', '#008000'],
}
Sorting Options
In the Storefront Boilerplate, we provide the most common default sorting options out of the box:
- Recommended
- Newest
- Price descending
- Price ascending
- Reduction descending
The "Recommended" sorting option is selected by default. Additionally, the sorting option resets to default when changing categories.
In our Boilerplate, all of the sorting logic is handled within the composables/useProductListSort.ts
file.
To adjust the sorting that is selected by default, you can change the DEFAULT_SORTING_KEY
:
export const DEFAULT_SORTING_KEY = 'date_newest' satisfies keyof typeof sortingOptions
In the same file, you can also adjust the available sorting options, e.g. remove one of the options or reorder to your business requirements.
You can also change the sorting key that is used for the Recommended
sorting option to a Custom Sorting Key or another Smart Sorting Key provided by SCAYLE.
const sortingOptions = {
top_seller: {
sortingKey: 'custom_sorting_key',
direction: APISortOrder.Descending,
},
date_newest: {
by: APISortOption.DateAdded,
direction: APISortOrder.Descending,
},
price_desc: {
by: APISortOption.Price,
direction: APISortOrder.Descending,
},
price_asc: {
by: APISortOption.Price,
direction: APISortOrder.Ascending,
},
reduction_desc: {
by: APISortOption.Reduction,
direction: APISortOrder.Descending,
},
}
Overall, you have the flexibility to customize these sorting options to better suit your specific business needs. These options can be modified using Sorting Keys, which you can configure through the SCAYLE Panel. For detailed instructions on setting up Smart or Custom Sorting Keys, please refer to the relevant section of the Developer Guide.
Product Cards
Product Cards in e-commerce are visual representations of individual products, which serve as a quick way for customers to browse and evaluate products throughout your shop. Even though it consists of a standard setup of components in our Boilerplate, they can also be customized to offer a more engaging & user-friendly shopping experience specific to the needs of your business. For example, its functionality can be extended to display additional product information or custom UI elements.
Our ProductCard
composes multiple smaller components which then form the final ProductCard
component.
We have the following components:
ProductCardImageSlider
which is responsible to display the product imagesWishlistToggle
which renders the heart icon in the top right cornerProductCardBadgesHeader
which render the badges in the top left cornerProductCardBadgesFooter
which render the badges at the bottom of theProductCardImageSlider
ProductCardDetails
which renders the information below theProductCardImageSlider
In addition, in the product card details we display product price. For more details, visit the Price Overview Page.
Image Logic
Which image is displayed first for each product can have a big impact on the user experience and conversion rate. It's important to select the best image and show this to the user.
In the Storefront Boilerplate, all image-related logic on how images are sorted and which image is shown first can be found in utils/image.ts
.
By default, it includes a basic implementation using a primaryImage
attribute to determine which image is displayed first. This attribute needs to be assigned to an image for each product. However, this setup may not align with your specific needs or business requirements.
The image logic essentially consists of two parts: first, a function that prioritizes and sorts all product images. This function dictates the order in which images are displayed on both the Product Card
Component and the Product Detail Page.
export const sortProductImages = (images: ProductImage[]) => {
return images.toSorted((imageA, imageB) => {
if (isPrimaryImage(imageB)) {
return 1
}
if (isPrimaryImage(imageA)) {
return -1
}
return 0
})
}
Additionally, the second part is a simple function that retrieves the primary image of a product. This function is used in scenarios where only a single image is displayed, without the need for an image slider.
export const getPrimaryImage = (images: ProductImage[]) => {
return images.find(isPrimaryImage) ?? images[0]
}
Available Sizes
Customizing the available sizes on product cards involves displaying size options directly on the product card, allowing customers to quickly see their preferred size without having to navigate to the Product Detail Page. This customization can enhance the shopping experience and streamline the purchase process.
Here’s how it works in Storefront Boilerplate:
Let's start by opening our ProductCard
component which is located in /components/product/card/ProductCard.vue
.
Since we want to show this information below the product name, we need to go into ProductCardDetails
component which renders the UI below the image.
First of all, we need to figure out which of the sizes of the product are available.
To do this, we set up a new computed
value in our script
which uses the variants
of our product.
Variants are the most specific representation of a product. These items carry stock and price info and are being sold in the shops.
To learn more about the Product Structure in SCAYLE, head over to the Developer Guide.
We then need to filter out any variants that are not in stock any longer and we then from each variant take the size
attribute value and use the label.
In our example the size
attribute holds the differentiating attribute between the different variants, in a typical fashion e-commerce this would be S, M, or L. However, depending on your data structure in SCAYLE, you might use a different attribute for this.
// Import two helper functions from our NPM Package
import { getFirstAttributeValue, isInStock } from '@scayle/storefront-nuxt'
// We set up a "computed" value so that our UI automatically updates
// in case the product provided to the component changes.
const availableVariants = computed(() => {
// Loop over all product variants,
// only include variants which are in Stock
// and map each variant to the respective attribute group size label
return props.product.variants
?.filter((variant) => isInStock(variant))
.map((variant) => getFirstAttributeValue(variant.attributes, 'size')?.label)
.filter((label): label is string => !!label) ?? []
})
We now have a reactive variable that updates automatically if our inputs change which is a list of the labels of the available sizes.
We can now use this variable in our template
to render this information.
First, we need to locate the product name in our existing template which should look like this:
<p data-testid="product-card-product-name" class="truncate text-sm text-gray-600">
{{ name }}
</p>
So we now want to create a new HTML component that will be responsible for rendering the available sizes of the product:
We only render this component if we have some availableVariants
, this is handled by the v-if
directive which is like a normal if
statement, it just conditionally renders a component.
We also give our component a bit of styling to look also consistent with the application.
<p
v-if="availableVariants"
class="truncate text-sm text-gray-600"
>
{{ $t('available-sizes', { sizes: availableVariants.join(', ') }) }}
</p>
To also have a bit of space between the product name and the available sizes, we can add the class mb-0.5
to the product name paragraph which will give the component a small bottom margin.
The final outcome of the customization looks like this:
Badges
Badges are used to provide quick, additional categorization of products, highlighting features, novelty, or other attributes. The current implementation includes the following badge types:
- New In Badge:
- States that a product is newly added. This badge is based on the
isNew
property, which can be configured in the SCAYLE Panel under Settings using theisNew
flag. By default, products are displayed as "New In" for the first 30 days after they go live in a country shop. You can adjust this period in days through the configuration settings. For more detailed information, refer to the dedicated section of the User Guide.
- States that a product is newly added. This badge is based on the
- Custom Badges:
- Custom badges can be created to highlight specific product features or attributes (e.g., sustainability, unisex). To add or modify custom badges, navigate to SCAYLE Panel -> Settings -> Attribute. Look for an attribute group named
storefrontBadge
. If it doesn't exist, create it. Within this attribute group, define the custom value. For example, if you want the badge "unisex," set the value to "unisex."
- Custom badges can be created to highlight specific product features or attributes (e.g., sustainability, unisex). To add or modify custom badges, navigate to SCAYLE Panel -> Settings -> Attribute. Look for an attribute group named
- Already in basket:
- The badge is displayed on products that have already been added to the user's shopping basket. This badge helps users quickly identify items they've already selected, reducing the chance of accidental duplicate purchases.
Badge Display
- Combining Badges: If both default (e.g., "New In") and custom badges are applied to a product, they are displayed together in one component, separated by a visual delimiter. In the current implementation, the number of badges displayed in this component is limited to two.
This customization allows you to tailor the product tiles to better fit your store's branding and highlight key product attributes effectively.
Low stock Badge
The Low Stock Badge offers many benefits for your business. It signals to users that the item is running out, encouraging them to make a purchase quickly before it's gone. Furthermore, it triggers FOMO (Fear of Missing Out), which motivates the users to buy now rather than risk missing out on the product entirely. This can help your business effectively drive sales & improve inventory turnover.
For instance, rather than always displaying the New In
badge, you might want to prioritize showing a Low Stock
badge instead.
The New In
badge is located in the components/product/card/badges/ProductCardBadgesHeader.vue
file.
First of all, we need to build our logic which decides whether or not the product itself is considered as low stock based on the variants and the quantity.
In our example, we will just consider all variants including sold-out ones and consider the product as having low stock if the average quantity is lower than 3.
For this, we need to set up a new computed
variable that calculates the information based on the product that is in the properties.
The code would look like this:
const isLowStock = computed(() => {
if (!props.product.variants) {
return false
}
// Here we calculate the average stock for a product.
const totalVariants = props.product.variants.length
// We sum up all the stock of all variants
const totalStock = props.product.variants.reduce(
(stockSum, variant) => stockSum + variant.stock.quantity,
0,
)
// Divide the total stock through the amount of variants
const averageStock = Math.round(totalStock / totalVariants)
// In case the average stock is below 3, we show a Low Stock Badge
return averageStock <= 3
})
We can now adjust labels
variable which handles the logic of which badges are in the end shown to the user.
It returns a list of up to two badges and we can adjust the first element to now also consider our new isLowStock
variable.
const labels = computed(() => {
return [
// Here we now prepare the Label, in case we consider the item as low stock
// we use the `badge_labels.low_stock` translation, otherwise we still check if the product is new,
// and if that's the case, we use the `badge_labels.new` translation.
//
// We need to use `isLowStock.value` here since `isLow`
isLowStock.value
? $i18n.t('badge_labels.low_stock')
: props.product.isNew
? $i18n.t('badge_labels.new')
: null,
customAttributes.value?.label,
].filter((item): item is string => !!item)
})
We use isLowStock.value
since our variable is a reactive reference and we can access the actual result of our computation through the .value
property.
This way our labels
will automatically update in case our isLowStock
variable changes. To learn more about Vue's Reactivity System, head over to the Vue Docs.
The final outcome of the customization looks like this: