Skip to main content

ClusterCard

A card component that renders a Propeller Commerce cluster: default product image with optional badge overlays, cluster details (name, SKU, manufacturer, short description, price, stock), a favourite toggle, and a "View cluster" navigation button. Supports both grid and row layouts.


Usage

Basic grid card

import ClusterCard from '@/components/propeller/ClusterCard';
import config from '@/data/config';

<ClusterCard
cluster={cluster}
configuration={config}
language="NL"
/>

SPA navigation with Next.js router

import { useRouter } from 'next/navigation';

const router = useRouter();

<ClusterCard
cluster={cluster}
configuration={config}
onClusterClick={(c) => router.push(config.urls.getClusterUrl(c, 'NL'))}
/>

With image badges and text attribute labels

<ClusterCard
cluster={cluster}
configuration={config}
imageLabels={['new', 'sale']}
textLabels={['brand', 'color']}
/>

Each string in imageLabels / textLabels is matched against defaultProduct.attributes.items[].attributeDescription.name. Attributes with no matching value are silently omitted.

With favourite toggle

<ClusterCard
cluster={cluster}
configuration={config}
enableAddFavorite={true}
onToggleFavorite={(cluster, isFavorite) => {
isFavorite
? wishlistService.add(cluster.clusterId)
: wishlistService.remove(cluster.clusterId);
}}
/>

All display options enabled

<ClusterCard
cluster={cluster}
configuration={config}
showName={true}
showImage={true}
showSku={true}
showManufacturer={true}
showShortDescription={true}
showStock={true}
showAvailability={true}
language="NL"
/>

Row layout (single-column list)

When columns is set to 1, the card renders as a compact horizontal row instead of a vertical card.

<ClusterCard
cluster={cluster}
configuration={config}
columns={1}
/>

Localised labels (Dutch)

<ClusterCard
cluster={cluster}
configuration={config}
labels={{
addToFavorites: 'Toevoegen aan favorieten',
removeFromFavorites: 'Verwijderen uit favorieten',
viewCluster: 'Bekijk cluster',
inStock: 'Op voorraad',
lowStock: 'Weinig voorraad',
outOfStock: 'Niet op voorraad',
}}
stockLabels={{
inStock: 'Op voorraad',
outOfStock: 'Niet op voorraad',
lowStock: 'Weinig voorraad',
pieces: 'stuks',
}}
/>

Price excluding VAT

<ClusterCard
cluster={cluster}
configuration={config}
includeTax={false}
/>

Configuration

Core

PropTypeRequiredDescription
clusterClusterYesThe Propeller cluster object to display
configurationanyYesApp configuration object (from @/data/config). Must expose urls.getClusterUrl()
languagestringNoLanguage code for resolving localised names and slugs. Defaults to 'NL'

Display toggles

PropTypeDefaultDescription
showNamebooleantrueRenders the cluster name as a clickable link
showImagebooleantrueRenders the default product image area
showSkubooleantrueRenders SKU; uses cluster.sku, falls back to defaultProduct.sku
showShortDescriptionbooleanfalseRenders the first localised short description
showManufacturerbooleanfalseRenders defaultProduct.manufacturer
showStockbooleantrueRenders a stock badge via the embedded ItemStock component
showAvailabilitybooleantrueShows the availability indicator inside ItemStock. Only relevant when showStock is true

Layout

PropTypeDefaultDescription
columnsnumberundefinedWhen set to 1, renders the card as a compact horizontal row instead of a vertical card
classNamestringundefinedExtra CSS class applied to the root element

Attribute labels

PropTypeDescription
imageLabelsstring[]Attribute names whose values are shown as badge overlays on the image
textLabelsstring[]Attribute names whose values are shown as extra text rows below the name

Attribute lookup is performed against defaultProduct.attributes.items[n].attributeDescription.name. The resolved value.value string is rendered. Entries with no match are dropped.

Favourites

PropTypeDefaultDescription
enableAddFavoritebooleanfalseRenders a heart-icon toggle button on the image
onToggleFavorite(cluster: Cluster, isFavorite: boolean) => void--Called on every favourite state change. isFavorite = true means just added
PropTypeDescription
onClusterClick(cluster: Cluster) => voidCalled when the name, image, or button is clicked. When provided, default <a> navigation is suppressed so you can use framework-specific routing

Pricing

PropTypeDefaultDescription
includeTaxbooleantrueWhen true, shows price.net (incl. VAT). When false, shows price.gross (excl. VAT). If omitted, follows the global price toggle from localStorage

Labels

Pass UI string overrides via the labels prop:

KeyDefault valueUsed for
addToFavorites'Add to favourites'aria-label on the heart button (not yet favourited)
removeFromFavorites'Remove from favourites'aria-label on the heart button (already favourited)
viewCluster'View cluster'Text of the navigation button
inStock'In stock'Stock badge when totalQuantity > 5
lowStock'Low stock'Stock badge when 1 <= totalQuantity <= 5
outOfStock'Out of stock'Stock badge when totalQuantity === 0

Pass stock-specific label overrides via the stockLabels prop (forwarded to ItemStock):

KeyDefault value
inStock'In stock'
outOfStock'Out of stock'
lowStock'Low stock'
available'Available'
notAvailable'Not available'
pieces'pieces'

Behavior

Price toggle

The component supports the global price toggle pattern used across the application:

  • When includeTax is explicitly passed as a prop, it takes precedence.
  • When includeTax is omitted, the component reads from localStorage key price_include_tax and listens for the priceToggleChanged custom event to stay in sync with the PriceToggle component.
  • price.net = price including VAT, price.gross = price excluding VAT. The price is formatted as EUR X.XX.

Image handling

  • When the default product has media, the first image variant URL is rendered inside an object-contain container.
  • When no image URL is available, a grey SVG placeholder icon is rendered instead.
  • In grid view, the image area uses aspect-[4/3] on small screens and aspect-square on larger screens.
  • In row view, the image is a fixed 80x80px thumbnail.

Cluster vs Product differences

A cluster groups multiple product variants (e.g., sizes, colors) under a single entity. The ClusterCard always shows data from the cluster's defaultProduct for fields like price, stock, images, and manufacturer. The cluster itself provides the name, SKU, short description, and URL slug, falling back to the default product's values when the cluster-level data is empty.

Unlike ProductCard, which links to a single product detail page, ClusterCard links to a cluster page where users can select among variants.

Grid vs Row layout

  • Grid layout (default): Vertical card with stacked image, details, and action button. Best for multi-column grids.
  • Row layout (columns={1}): Compact horizontal layout with a small thumbnail, inline details, and right-aligned price and action button. Suitable for list views.

Stock badge

The stock badge reads defaultProduct.inventory.totalQuantity and renders via the embedded ItemStock component:

ConditionColorLabel key
totalQuantity > 5GreeninStock
1 <= totalQuantity <= 5AmberlowStock
totalQuantity === 0RedoutOfStock
Inventory not presentHidden--

Favourite toggle

The component maintains an internal isFavorite boolean starting at false. Clicking the heart button toggles it and fires onToggleFavorite(cluster, newState). There is no prop to set the initial favourite state -- manage pre-seeded state externally.

The cluster name, image, and "View cluster" button all render as <a> tags with the URL generated by configuration.urls.getClusterUrl(). If onClusterClick is provided, e.preventDefault() is called and routing is delegated to the callback, enabling SPA navigation without full page reloads.

GraphQL Query Examples

Fetching clusters for a category listing

query Clusters($categoryId: Int!, $language: String) {
clusters(
input: {
categoryId: $categoryId
language: $language
offset: 0
limit: 20
}
) {
items {
clusterId
sku
names { language value }
slugs { language value }
shortDescriptions { language value }
defaultProduct {
productId
sku
names { language value }
slugs { language value }
shortDescriptions { language value }
manufacturer
price { net gross }
inventory { totalQuantity }
media {
images {
items {
imageVariants {
url
}
}
}
}
attributes {
items {
attributeDescription { name }
value { value }
}
}
}
}
itemsFound
}
}

Fetching a single cluster by ID

query Cluster($clusterId: Int!, $language: String) {
cluster(id: $clusterId, language: $language) {
clusterId
sku
names { language value }
slugs { language value }
defaultProduct {
productId
price { net gross }
inventory { totalQuantity }
media {
images {
items {
imageVariants { url }
}
}
}
}
}
}

SDK Services and Types

The component uses the following types from propeller-sdk-v2:

TypeUsage
ClusterThe main cluster data object passed via cluster prop
AttributeResultUsed to resolve imageLabels and textLabels from defaultProduct.attributes.items[]

Key data paths on the Cluster type

DataPathNotes
Namecluster.names[]Array of { language, value }. Falls back to defaultProduct.names[]
SKUcluster.skuFalls back to defaultProduct.sku
ImagedefaultProduct.media.images.items[0].imageVariants[0].urlFirst image variant of the default product
PricedefaultProduct.price.net / defaultProduct.price.grossnet = incl. VAT, gross = excl. VAT
URL slugcluster.slugs[]Falls back to defaultProduct.slugs[]. Resolved via configuration.urls.getClusterUrl()
Short descriptioncluster.shortDescriptions[]Falls back to defaultProduct.shortDescriptions[]
ManufacturerdefaultProduct.manufacturerPlain string
StockdefaultProduct.inventory.totalQuantityInteger; undefined means inventory data not loaded
AttributesdefaultProduct.attributes.items[]Each item has attributeDescription.name and value.value