Skip to main content

ClusterOptions

Renders a set of option groups for a product cluster, each displayed as a dropdown selector. When a user picks a product from any option group, the component shows a preview card with the product thumbnail, name, and price. Required options are marked with a badge and validated on demand.


Usage

Basic usage on a cluster detail page

import ClusterOptions from '@/components/propeller/ClusterOptions';

<ClusterOptions
clusterId={42}
options={cluster.options}
onOptionSelect={(product) => console.log('Selected:', product.productId)}
/>

With validation errors

Pass showErrors={true} to highlight required options that have no selection. Typically toggled before an add-to-cart action:

const [showErrors, setShowErrors] = useState(false);

const validateBeforeAddToCart = (): boolean => {
const hasUnfilled = cluster.options.some(
opt => opt.hidden !== 'Y' && opt.isRequired === 'Y' && !(opt.id in selectedOptionProducts)
);
if (hasUnfilled) {
setShowErrors(true);
return false;
}
return true;
};

<ClusterOptions
clusterId={clusterId}
options={cluster.options}
onOptionSelect={handleOptionSelect}
showErrors={showErrors}
/>

<AddToCart beforeAddToCart={validateBeforeAddToCart} childItems={Object.values(selectedOptionProducts).map(p => p.productId)} />

Tracking selected option products for add-to-cart

The parent page should maintain a map of selected option products and pass them as childItems to AddToCart:

const [selectedOptionProducts, setSelectedOptionProducts] = useState<Record<number, Product>>({});

const handleOptionSelect = (product: Product) => {
const option = cluster?.options?.find(opt =>
opt.products?.some(p => p.productId === product.productId)
);
if (option) {
setSelectedOptionProducts(prev => ({ ...prev, [option.id]: product }));
}
};

<ClusterOptions
clusterId={clusterId}
options={cluster.options}
onOptionSelect={handleOptionSelect}
showErrors={showErrors}
/>

Custom labels

<ClusterOptions
clusterId={clusterId}
options={cluster.options}
labels={{
required: 'Verplicht',
selectRequired: '— Maak een keuze —',
selectOptional: '— Geen (optioneel) —',
requiredError: 'Dit veld is verplicht',
}}
/>

With custom styling

<ClusterOptions
clusterId={clusterId}
options={cluster.options}
className="my-8 border-t pt-6"
/>

Configuration

Required

PropTypeDescription
clusterIdnumberThe cluster ID this options selector belongs to.
optionsClusterOption[]Array of option groups from cluster.options. Hidden options (option.hidden === 'Y') are filtered out automatically.

Callbacks

PropTypeDescription
onOptionSelect(optionProduct: Product) => voidFired when the user selects a product in any option group. Receives the full Product object. Use this to update a price display or track selected products for add-to-cart.

Display & Validation

PropTypeDefaultDescription
showErrorsbooleanfalseWhen true, required options without a selection display a red border and error message.
labelsRecord<string, string>Built-in English defaultsOverride UI strings. Keys: required, selectRequired, selectOptional, requiredError.
classNamestring''Extra CSS class applied to the root <div>.

Labels

KeyDefault valueWhere it appears
required"Required"Badge next to required option names
selectRequired"-- Select an option --"Placeholder in required dropdowns
selectOptional"-- None (Optional) --"Placeholder in optional dropdowns
requiredError"This option is required"Error text below unfilled required dropdowns

Behavior

Option selection flow

  1. Each option group renders as a <select> dropdown. The default value is empty (placeholder text).
  2. When the user picks a product, the component stores the selection internally (selectedProductIds state keyed by option ID).
  3. If an onOptionSelect callback is provided, it fires with the full Product object of the selected item.
  4. A preview card appears below the dropdown showing the selected product's thumbnail, name, and price.

Visual presentation

  • Option header: Option name in semibold text. Required options show a red "Required" badge.
  • Dropdown: Full-width <select> with rounded borders. Required options have a slightly heavier border (border-gray-300); optional ones use border-gray-200. Focus ring uses the secondary theme color.
  • Validation errors: When showErrors is true and a required option has no selection, the dropdown border turns red (border-red-400) and an error message appears below it.
  • Preview card: A horizontal card with a 48x48px thumbnail (or an SVG placeholder if no image is available), the product name (truncated), and the price in the secondary theme color.
  • Price formatting: All prices render as EUR with two decimal places (e.g., €10.00). The price.gross value (excl. VAT) is used in the dropdown labels and preview.

Hidden options

Options where option.hidden === 'Y' are filtered out before rendering. They never appear in the UI.

State management

The component manages its own selection state internally. There is no controlled mode -- the parent tracks selections through the onOptionSelect callback rather than passing selected values back in.


GraphQL Query

The component does not execute queries directly. The parent page fetches the cluster via ClusterService.getCluster(), which returns cluster.options. A typical query that includes the fields ClusterOptions needs:

query GetCluster($clusterId: Int!, $language: String) {
cluster(id: $clusterId, language: $language) {
clusterId
options {
id
hidden
isRequired
names {
value
language
}
products {
productId
names {
value
language
}
price {
gross
net
}
media {
images(searchInput: { limit: 1 }) {
items {
imageVariants {
url
}
}
}
}
}
}
}
}

SDK Services

ClusterOptions does not fetch data itself. It receives ClusterOption[] from the parent, which typically comes from a ClusterService.getCluster() call.

ClusterOption fields read

FieldTypeUsage
option.idnumberUnique key for each option group
option.names{ value: string }[]Display name (first entry used). Falls back to "Option {id}".
option.hiddenEnums.YesNoOptions with hidden === 'Y' are excluded from rendering
option.isRequiredEnums.YesNo'Y' marks the option as required (badge + validation)
option.productsProduct[]The selectable products within this option group

Product fields read (from each option product)

FieldTypeUsage
product.productIdnumberUnique identifier, used as <option value> and for lookup
product.names{ value: string }[]Display name (first entry). Falls back to "Product {productId}".
product.price.grossnumberPrice displayed in the dropdown label and preview card (formatted as EUR)
product.media.images.items[].imageVariants[].urlstringThumbnail URL for the selected-product preview card