Skip to main content

ProductCard

A complete product card component that renders an image with optional badge overlays, product details (name, SKU, manufacturer, short description, price), a favourite toggle, and an embedded AddToCart control. Supports both grid and row layouts.


Visual Layout

┌─────────────────────────────────┐
│ [badge] [♡ fav btn] │ <- image area (aspect-square)
│ │
│ [ product image ] │
│ │
├─────────────────────────────────┤
│ SKU-1234 │ <- SKU (mono)
│ Product name that may wrap │ <- name (link)
│ Extra attribute value │ <- textLabels
│ Manufacturer name │ <- showManufacturer
│ Short description text... │ <- showShortDescription
│ € 29.99 │ <- price
├─────────────────────────────────┤
│ [−] [ 1 ] [+] [ Add ] │ <- embedded AddToCart
└─────────────────────────────────┘

When columns={1}, the card switches to a compact horizontal row layout with the image on the left, details in the middle, and price + AddToCart on the right.


Usage

Minimal

import ProductCard from '@/components/propeller/ProductCard';
import { graphqlClient } from '@/lib/api';
import config from '@/data/config';

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
cartId={cart?.cartId}
configuration={config}
afterAddToCart={(updatedCart) => saveCart(updatedCart)}
/>

With SPA Routing and Post-Add Modal

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
cartId={cart?.cartId}
configuration={config}
showModal={true}
onProductClick={(p) => router.push(`/product/${p.productId}`)}
onProceedToCheckout={() => router.push('/checkout')}
afterAddToCart={(cart) => saveCart(cart)}
/>

With Attribute Badge and Text Labels

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
configuration={config}
imageLabels={['new', 'sale']}
textLabels={['brand', 'color']}
afterAddToCart={(cart) => saveCart(cart)}
/>

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

With Favourite Toggle

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
configuration={config}
enableAddFavorite={true}
onToggleFavorite={(product, isFavorite) => {
isFavorite
? wishlistService.add(product.productId)
: wishlistService.remove(product.productId);
}}
afterAddToCart={(cart) => saveCart(cart)}
/>

All Display Options with Stock

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
configuration={config}
showName={true}
showImage={true}
showSku={true}
showManufacturer={true}
showShortDescription={true}
showStock={true}
showAvailability={true}
stockLabels={{
inStock: 'In stock',
outOfStock: 'Out of stock',
available: 'Available',
notAvailable: 'Not available',
}}
afterAddToCart={(cart) => saveCart(cart)}
/>

Row Layout (Single Column)

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
configuration={config}
columns={1}
showStock={true}
afterAddToCart={(cart) => saveCart(cart)}
/>

Fully Localised (Dutch)

<ProductCard
product={product}
graphqlClient={graphqlClient}
user={authState.user}
configuration={config}
language="NL"
labels={{
addToFavorites: 'Toevoegen aan favorieten',
removeFromFavorites: 'Verwijderen uit favorieten',
}}
addToCartLabels={{
add: 'Toevoegen',
adding: 'Toevoegen...',
addedToCart: 'toegevoegd aan winkelwagen',
outOfStock: 'Onvoldoende voorraad',
errorAdding: 'Kan product niet toevoegen',
noCartId: 'Geen winkelwagen beschikbaar',
modalTitle: 'Toegevoegd aan winkelwagen',
quantity: 'Aantal',
continueShopping: 'Verder winkelen',
proceedToCheckout: 'Naar afrekenen',
}}
afterAddToCart={(cart) => saveCart(cart)}
/>

Configuration

Core

PropTypeRequiredDescription
productProductYesThe Propeller product object to display
graphqlClientGraphQLClientYesInitialised Propeller SDK GraphQL client (forwarded to AddToCart)
userContact | Customer | nullYesAuthenticated user (forwarded to AddToCart)
configurationanyYesApp configuration object from @/data/config — provides urls.getProductUrl() for link generation and image filter settings

Display Toggles

PropTypeDefaultDescription
showNamebooleantrueRenders the product name as a clickable link
showImagebooleantrueRenders the product image in an aspect-ratio container
showSkubooleantrueRenders the SKU in monospace style
showShortDescriptionbooleanfalseRenders the first localised short description (2-line clamp)
showManufacturerbooleanfalseRenders product.manufacturer
showStockbooleanfalseRenders the embedded ItemStock availability widget
showAvailabilitybooleantrueWhen showStock is true, shows the availability indicator text. Only relevant in grid layout

Attribute Labels

PropTypeDescription
imageLabelsstring[]Attribute names whose values render as badge overlays on the product image. Example: ['new', 'sale']
textLabelsstring[]Attribute names whose values render as extra text rows below the product name. Example: ['brand', 'color']

Lookup: each entry is matched against product.attributes.items[n].attributeDescription.name. The resolved value.value string is rendered. Entries with no match are dropped.

Pricing and Layout

PropTypeDefaultDescription
includeTaxbooleanOverrides the price toggle. When true, shows tax-inclusive price (price.net). When false, shows tax-exclusive price (price.gross). When omitted, the component follows the global price toggle (see Behavior section)
columnsnumberWhen set to 1, the card renders as a compact horizontal row instead of a vertical card
classNamestringExtra CSS class applied to the root <div>
languagestring'NL'Language code used for resolving localised product names, descriptions, slugs, and forwarded to CartService

Favourites

PropTypeDefaultDescription
enableAddFavoritebooleanfalseRenders a heart-icon toggle button in the top-right corner of the image
onToggleFavorite(product: Product, isFavorite: boolean) => voidCalled on every favourite state change. isFavorite = true means just added
PropTypeDescription
onProductClick(product: Product) => voidCalled when the product name or image is clicked. When provided, the default <a> navigation is suppressed so the consumer can use framework routing (e.g. router.push)

Label Overrides

PropTypeDescription
labelsRecord<string, string>Override card-level UI strings. Keys: addToFavorites, removeFromFavorites
stockLabelsRecord<string, string>Override ItemStock UI strings. Keys: inStock, outOfStock, lowStock, available, notAvailable, pieces

AddToCart Pass-Through Props

All of these are forwarded to the embedded AddToCart component. See AddToCart for full details.

PropTypeDefaultDescription
cartIdstringID of an existing cart
clusterIdnumberCluster ID for configurable products
childItemsnumber[]Product IDs of selected cluster child options
notesstringFree-text notes for the cart item
pricenumberCustom unit price override
createCartbooleanfalseAuto-create a cart when none is available
onCartCreated(cart: Cart) => voidCalled after a new cart is created
onAddToCart(product, clusterId?, quantity?, childItems?, notes?, price?, showModal?) => CartCustom add-to-cart handler that replaces the internal CartService call
afterAddToCart(cart: Cart, item?: CartMainItem) => voidCalled after every successful add
showModalbooleanfalseShow a modal after add instead of a toast notification
allowIncrDecrbooleantrueRender increment/decrement buttons around the quantity input
enableStockValidationbooleanfalseBlock add if quantity exceeds available stock
onProceedToCheckout() => voidCalled when the modal's checkout button is clicked
addToCartLabelsRecord<string, string>Override AddToCart UI strings (mapped to AddToCart's labels prop). Keys: outOfStock, noCartId, errorAdding, addedToCart, modalTitle, quantity, continueShopping, proceedToCheckout, add, adding

Labels

Card labels

KeyDescription
addToFavoritesTooltip/aria text for the add favourite button
removeFromFavoritesTooltip/aria text for the remove favourite button

Stock labels

KeyDescription
inStockText when product is in stock
outOfStockText when product is out of stock
lowStockText when stock is low
availableText when product is available
notAvailableText when product is not available
piecesUnit label for stock quantity

AddToCart labels

KeyDescription
addAdd button text
addingButton text while adding
addedToCartToast/modal message after successful add
outOfStockMessage when stock is insufficient
errorAddingMessage on add failure
noCartIdMessage when no cart is available
modalTitleTitle of the post-add modal
quantityLabel for the quantity display in modal
continueShoppingModal button text to continue shopping
proceedToCheckoutModal button text to go to checkout

Behavior

Price Toggle (VAT Switch)

The component participates in the global price toggle system:

  • On mount, it reads localStorage.getItem('price_include_tax') to determine whether to show tax-inclusive or tax-exclusive prices.
  • It listens for the priceToggleChanged custom event, dispatched by the PriceToggle component, and updates the displayed price in real time.
  • The includeTax prop overrides this automatic behavior when explicitly passed.
  • Price field mapping: product.price.net is the tax-inclusive price; product.price.gross is the tax-exclusive price. Prices are formatted as €X.XX.

Image Handling

  • The first image variant is used: product.media.images.items[0].imageVariants[0].url.
  • When no image is available, a grey SVG placeholder icon is rendered in its place.
  • Images scale up slightly on hover (group-hover:scale-105).

Product URL Generation

Product links are generated via configuration.urls.getProductUrl(product, language), which uses the configured URL pattern (default: /product/{id}/{slug}) with optional language prefixes.

Favourite Toggle

The component keeps an internal isFavorite boolean (starts as false). Clicking the heart button flips it and fires onToggleFavorite(product, newState). There is no initial-state prop -- if you need to pre-seed the favourite state, manage the UI externally.

The product name and image both render as <a> links. If onProductClick is provided, e.preventDefault() is called and the callback handles routing instead, enabling SPA navigation without a full page reload.

Row vs Grid Layout

When columns is 1, the card renders as a compact horizontal row:

  • Image is a small 80x80px thumbnail on the left
  • Name and details are inline in the middle
  • Price and AddToCart appear on the right
  • On mobile, the bottom section wraps below with a border separator

When columns is any other value (or omitted), the standard vertical card layout is used.

Embedded AddToCart

The AddToCart component is mounted in the card footer. The card's addToCartLabels prop is mapped to AddToCart's labels prop to avoid collision with the card-level labels prop (which controls the favourite button strings).


SDK Services and Types

Types Used

ImportPackagePurpose
Productpropeller-sdk-v2The main product data object
GraphQLClientpropeller-sdk-v2SDK client instance for API calls
Contactpropeller-sdk-v2B2B user type
Customerpropeller-sdk-v2B2C user type
Cartpropeller-sdk-v2Cart object returned after add-to-cart
CartMainItempropeller-sdk-v2Individual cart line item
CartChildItemInputpropeller-sdk-v2Input type for cluster child items
AttributeResultpropeller-sdk-v2Product attribute with description and value

Product Fields Accessed

DataField Path on Product
Namenames[].value (filtered by language)
SKUsku
Image URLmedia.images.items[0].imageVariants[0].url
Price (incl. VAT)price.net
Price (excl. VAT)price.gross
Short descriptionshortDescriptions[].value (filtered by language)
Manufacturermanufacturer
Slugsslugs[].value (filtered by language)
Stockinventory (forwarded to ItemStock)
Attributesattributes.items[].attributeDescription.name and attributes.items[].value.value

GraphQL Query Examples

Fetching Products for ProductCard

The Product object passed to ProductCard should include at minimum these fields:

query Products($categoryId: Int!, $language: String) {
products(categoryId: $categoryId, language: $language) {
items {
productId
sku
manufacturer
names {
value
language
}
slugs {
value
language
}
shortDescriptions {
value
language
}
price {
net
gross
}
media {
images(searchFilters: { transformations: ["fill"] }) {
items {
imageVariants(
filters: {
width: 400
height: 400
transformations: ["fill"]
}
) {
url
}
}
}
}
inventory {
totalQuantity
supplierQuantity
}
attributes {
items {
attributeDescription {
name
code
}
value {
value
}
}
}
}
}
}

Fetching a Single Product

query Product($productId: Int!, $language: String) {
product(productId: $productId, language: $language) {
productId
sku
manufacturer
names { value language }
slugs { value language }
shortDescriptions { value language }
price { net gross }
media {
images {
items {
imageVariants(filters: { width: 400, height: 400 }) {
url
}
}
}
}
inventory { totalQuantity supplierQuantity }
attributes {
items {
attributeDescription { name code }
value { value }
}
}
}
}