Broadleaf Microservices
  • v1.0.0-latest-prod

A Product Detail Page (PDP) Walkthrough

In this walkthrough, we’ll examine how to build a Product Details Page (PDP) like in the commerce starter for standard and variation-based products.

Tip
See the Selector Product Detail Page Walkthrough for a more advance type of product.

We will cover:

  1. The required requests to read a product using the Commerce SDK.

  2. The general page design, specifically around a single product that covers Standard and Variation-Based Products.

Product Page
Important
Key for this walkthrough will be the use of the Commerce JS SDK, particularly the Browse SDK module.

Prerequisites

We need to make a few requests before we start loading content specific to the PDP, or any other page for that matter. We’ll go into more detail about these elsewhere, but here’s a brief overview:

  1. Resolve the tenant and application

  2. Send a request to authenticate the user and determine permissions, whether as anonymous or signed-in

Unresolved directive in product-details.adoc - include::browse-client-snippet.adoc[Browse Client Setup]

Reading a Product

There are multiple ways to read a Product’s Details depending on what information is available.

We may know we are fetching a Product either because of a special context path prefix in the browser’s URL (e.g., all PDPs start with /products) or because our component is specifically looking for Products like a specialize landing page. In these cases we have two methods to fetch a Product’s Details directly using the Product Details endpoint:

Tip
For more information on the backend details for reading a Product for a PDP, see Facilitating Product Details Pages.

However, it may be the case that we only have URL from the browser that could be any of Product, Category, or Content Item (or any other entity addressable by URL). In that case, we can use the Browse Details endpoint, which will attempt to resolve an entity for a given URL.

By Product ID

If we have the Product’s ID and know that it belongs to a Product, we can fetch its details directly using the Read Product Details endpoint, described below.

Table 1. Read Product Details by ID Endpoint
Method Path Params SDK

GET

/api/browse/products

productIds?: string[]

The Browse SDK makes using this endpoint quite easy for us:

import { browseClient } from '../browse-client.js';

export async function getProductDetails(productId, options) {
    return browseClient.getProductById(productId, options);  (1)
}
Product Details Response

Unresolved directive in product-details.adoc - include::pdp-response-snippet.adoc[PDP Response]

By Product URL

If we have the Product’s URL and know that it belongs to a Product, we can also fetch its details directly using the Read Product Details endpoint, described below.

Table 2. Read Product Details By URL Endpoint
Method Path Params SDK

GET

/api/browse/products

productUris?: string[]

The Browse SDK makes using this endpoint quite easy for us:

import { browseClient } from '../browse-client.js';

// example: `const url = '/hot-sauces/green-host'`
export async function getProductDetails(url, options) {
    return browseClient.getProductByURL(url, options); (1)
}
Product Details Response

Unresolved directive in product-details.adoc - include::pdp-response-snippet.adoc[PDP Response]

By Browse Entity URL

If we have a URL, but we don’t know if it belongs to Product, then we can use the Browse Details Endpoint to determine it for us.

Table 3. Read Browse Details By URL Endpoint
Method Path Params SDK

GET

/api/browse/details

uris?: string[]

The Browse SDK makes using this endpoint quite easy for us:

import { browseClient } from '../browse-client.js';

// example: `const url = '/hot-sauces/green-host'`
export async function getBrowseDetails(url, options) {
    const request = { uris: [url] };
    return browseClient.resolveBrowseEntity(request, options); (1) (2)
}

Browse Details Response

For a product, the details will be under the productDetails field, which is a list. The details should then look like in a normal Product Details Response

Unresolved directive in product-details.adoc - include::pdp-response-snippet.adoc[PDP Response]

Typical PDP Overview

We will discuss page design specifically around a single standard or variation-based product.

Product Page
Tip
In the commerce starter, we have a sample PDP in app/product/components/templates/default-pdp.tsx.

From the image above we can see that there are several sections that we need to populate from the product details to render our page.

  1. Images

  2. Name

  3. SKU

  4. Price

  5. Options

  6. Description

  7. Add-to-Cart Button

Name, SKU, and Description are first-class fields on Product with simple (non-object) values. We will explore displaying the rest of the fields in the rest of this section.

Displaying Images

Image assets are split between two properties; assets, and primaryAsset. for the page we use the assets array that is iterated though to display. The key fields on the asset to display it are the contentUrl, altText, and title. Additionally tags can be used to map an asset to specific Product Option values, e.g., color:#594f63 to map to that color variation when it is selected by the customer.

Tip
See app/product/components/product-images.tsx for example display component in the commerce starter.
Example Product Asset JSON
{
  "assets":
  [
    {
        "tags":
        [
            "color:black"
        ],
        "primary": true,
        "contentUrl": "/api/asset/content/habanero_mens_black.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22forceFilterByCatalogIncludeInheritance%22:false,%22forceFilterByCatalogExcludeInheritance%22:false,%22applicationId%22:%222%22,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
        "sorted": false,
        "altText": "Black Hawt Like a Habanero Shirt (Men's)",
        "productId": "01F9YCP2XPW25A1AKWK02X0GXT",
        "provider": "BROADLEAF",
        "tenantId": "5DF1363059675161A85F576D",
        "id": "01F9YDW1NZ802T14DE79350868",
        "applicationId": "2",
        "type": "IMAGE",
        "url": "/habanero_mens_black.jpg",
        "parentId": "01F9YCP2XPW25A1AKWK02X0GXT"
    },
    {
        "tags":
        [
            "color:red"
        ],
        "primary": false,
        "contentUrl": "/api/asset/content/habanero_mens_red.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22forceFilterByCatalogIncludeInheritance%22:false,%22forceFilterByCatalogExcludeInheritance%22:false,%22applicationId%22:%222%22,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
        "sorted": false,
        "altText": "Red Hawt Like a Habanero Shirt (Men's)",
        "productId": "01F9YCP2XPW25A1AKWK02X0GXT",
        "provider": "BROADLEAF",
        "tenantId": "5DF1363059675161A85F576D",
        "id": "01F9YDW1PPEM4F1NS1A5F217CK",
        "applicationId": "2",
        "type": "IMAGE",
        "url": "/habanero_mens_red.jpg",
        "parentId": "01F9YCP2XPW25A1AKWK02X0GXT"
    },
    {
      "tags":
      [
        "color:silver"
      ],
      "primary": false,
      "contentUrl": "/api/asset/content/habanero_mens_silver.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22forceFilterByCatalogIncludeInheritance%22:false,%22forceFilterByCatalogExcludeInheritance%22:false,%22applicationId%22:%222%22,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
      "sorted": false,
      "altText": "Silver Hawt Like a Habanero Shirt (Men's)",
      "productId": "01F9YCP2XPW25A1AKWK02X0GXT",
      "provider": "BROADLEAF",
      "tenantId": "5DF1363059675161A85F576D",
      "id": "01F9YDW1Q00TSP05GTQESA0RHH",
      "applicationId": "2",
      "type": "IMAGE",
      "url": "/habanero_mens_silver.jpg",
      "parentId": "01F9YCP2XPW25A1AKWK02X0GXT"
    }
  ],
  "primaryAsset":
  {
    "tags":
    [
        "color:black"
    ],
    "primary": true,
    "contentUrl": "/api/asset/content/habanero_mens_black.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22forceFilterByCatalogIncludeInheritance%22:false,%22forceFilterByCatalogExcludeInheritance%22:false,%22applicationId%22:%222%22,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
    "sorted": false,
    "altText": "Black Hawt Like a Habanero Shirt (Men's)",
    "productId": "01F9YCP2XPW25A1AKWK02X0GXT",
    "provider": "BROADLEAF",
    "tenantId": "5DF1363059675161A85F576D",
    "id": "01F9YDW1NZ802T14DE79350868",
    "applicationId": "2",
    "type": "IMAGE",
    "url": "/habanero_mens_black.jpg",
    "parentId": "01F9YCP2XPW25A1AKWK02X0GXT"
  },
}

Displaying Pricing

The Product’s price can be found within the pricingInfo field. PriceInfo is a rich object with details about the best price, its type, and alternate prices. The price itself is a monetary object with properties for amount and currency.

Tip
See app/product/components/product-price.tsx for example display component in the commerce starter.
Example PriceInfo
{
    "priceInfo":
    {
        "target": (1)
        {
            "targetId": "HS-GG-20",
            "targetType": "SKU",
            "targetQuantity": 1,
            "priceableFields":
            {
                "basePrice":
                {
                    "amount": 11.99,
                    "currency": "USD"
                }
            },
            "attributes":
            {
                "skuRef":
                {
                    "id": "product1"
                }
            }
        },
        "price":
        {
            "amount": 9.99,
            "currency": "USD"
        },
        "priceType": "salePrice", (2)
        "priceDataId": "hc_base_sale_product1",
        "priceListId": "hc_base_sales",
        "priceListPriceSource": "BLC_PRICE_LIST_PRICE_DATA",
        "activeStartDate": "2024-09-23T15:34:53.619463Z",
        "priceTypeDetails": (3)
        {
            "salePrice":
            {
                "type": "salePrice",
                "bestPrice":
                {
                    "amount": 9.99,
                    "currency": "USD"
                },
                "priceListId": "hc_base_sales",
                "priceDetails":
                {
                    "hc_base_sales":
                    {
                        "price":
                        {
                            "amount": 9.99,
                            "currency": "USD"
                        },
                        "priceDataId": "hc_base_sale_product1",
                        "priceList":
                        {
                            "id": "hc_base_sales",
                            "type": "SALE",
                            "name": "Base Running Sales",
                            "priority": 200,
                            "currency": "USD"
                        },
                        "priceSource": "BLC_PRICE_LIST_PRICE_DATA",
                        "activeStartDate": "2024-09-23T15:34:53.619463Z",
                        "priceType": "salePrice",
                        "priceDataTierList":
                        []
                    }
                }
            },
            "basePrice":
            {
                "type": "basePrice",
                "bestPrice":
                {
                    "amount": 11.99,
                    "currency": "USD"
                },
                "priceDetails":
                {}
            }
        }
    }
}
  1. This represents the target object based on the Product or one of its Variations that was used to determine the Pricing in the Pricing Service. This is typically not important for the frontend, but is used by the Browse Service.

  2. This tells us what type of price, whether standard, sale, or other. This is useful for displaying these prices distinctly.

  3. These are the other prices found for the target (product or variation). They are included primarily to support strike-through pricing where the best price, PriceInfo#price, will be prominent and the price it overrides, basePrice above, will be struck-through.

Formatting Currency

After reviewing the Price Info structure, we need to display it. Display currency can be somewhat complex due to the varieties of formats for different currencies included significant digits and symbols. In the commerce starter we use a third-party library from Format.js for general internationalization (i18n) support. They provide an abstraction layer between the app and the JavaScript Intl APIs.

For currency, we can use the formatNumber method, or, for React, we can use the FormattedNumber component. In the commerce starter, we have a few hooks and components set up to aid in displaying currency already.

  • useFormatNumber() in app/common/hooks/useFormatNumber.ts provides a callback for the formatNumber method

  • FormattedAmount in app/common/components/formatted-amount.tsx is the standard component for displaying prices

  • I18nMessageProvider in app/common/contexts/I18nMessageProvider.tsx sets up the intl context.

Let’s look at a simple example using just using formatNumber directly from Format.js.

Formatted Amount using JavaScript
import {createIntl, createIntlCache} from '@formatjs/intl' (1)

// included for clarity, but this should be done in a separate file or component
const cache = createIntlCache()
const intl = createIntl(
  {
    locale: 'en-US',
    messages: {},
  },
  cache
)

export const FormattedAmount = ({ price, parent }) => {
  const { amount, currency } = price;
  const formatted = intl.formatNumber(amount, { style: 'currency', currency });
  const el = document.createElement('div');
  el.innerHTML = formatted;
  el.classList.add('price');
  return parent.appendChild(el);
}
Formatted Amount using React
import { FormattedNumber } from 'react-intl';

export const FormattedAmount = ({ price }) => {
  const { amount, currency } = price;

  return (
    <span className={className}>
      <FormattedNumber style="currency" currency={currency} value={amount} />
    </span>
  );
};

Adding to Cart

After options are selected, we can pass the product’s information to our add-to-cart button, (reference app/product/components/add-to-cart-button.tsx). For any operations against a cart, we will switch from the Browse SDK to the Cart SDK.

Unresolved directive in product-details.adoc - include::cart-client-snippet.adoc[Cart Client Setup]

When clicked, our add to cart button should submit an AddItemRequest o ManageCartEndpoint#addItemToCart using CartClient#addItemToCart.

However, that assumes that there is a cart already! If we haven’t created or resolved a cart for the customer, we can instead pass the AddItemRequest as part of a CartCreationRequest to the #createCart endpoint. For this request, we can use CartClient#createCart.

Table 4. Related Cart Endpoints
Method Path Params SDK

POST

/api/cart-operations/cart

None

POST

/api/cart-operations/cart/:cartId/items

cartId: string (path)

Let’s examine an example request:

Example AddItemRequest for a Variation-Based Product
{
    "dependentCartItems": [],
    "itemAttributeChoices": (1)
    {
        "color": "#594f63",
        "capacity": "1 tb"
    },
    "productId": "989", (2)
    "quantity": 1,
    "variantId": "01J361EKTWTWKWS4X0RHG2JRH0", (3)
    "sku": "215343", (4)
    "currency": "USD"
}
  1. These should match the selected Product Options attributes.

  2. For variation-based products, this is required so that the backend can validate the request and populate additional information on the Cart Item.

  3. For variation-based products, this is the ID of the Variation whose optionValues matches the selected itemAttributeChoices.

  4. Alternative to passing the Variation’s ID.

Example CartCreationRequest
{
    "addItemRequest":
    {
        "dependentCartItems": [],
        "itemAttributeChoices":
        {
            "color": "#594f63",
            "capacity": "1 tb"
        },
        "productId": "989",
        "quantity": 1,
        "variantId": "01J361EKTWTWKWS4X0RHG2JRH0",
        "sku": "215343",
        "currency": "USD"
    },
    "priceCartRequest": (1)
    {
        "locale": "en-US",
        "currency": "USD"
    }
}
  1. This helps set up the correct currency for the Cart. A Cart can only use one currency. See also PriceCartRequest.

Making this call is simple using the Cart SDK, just pass in the request and cart ID.

Using the Cart SDK to add an item
import { cartClient } from '../cart-client.js';

export async function addItemToCart(cartId, request, options) {
    return cartClient.addItemToCart(cartId, request, options); (1) (2)
}
Using the Cart SDK to create a cart
import { cartClient } from '../cart-client.js';

export async function createCart(request, options) {
    return cartClient.createCart(request, options); (1) (2)
}

Add to Cart Response

The response after adding an item or creating a Cart is the updated or new Cart.

Add to Cart Response
{
    "id": "01J8SSH2KAGVNK4GXFKWPES3Q5",
    "type": "STANDARD",
    "status": "IN_PROCESS",
    "customerRef": {
        "registered": false
    },
    "createDate": "2024-09-27T13:08:07.914736Z",
    "locale": "en-US",
    "cartPricing": {
        "currency": "USD",
        "fulfillmentTotal": {
            "amount": 0.00,
            "currency": "USD"
        },
        "subtotal": {
            "amount": 14.99,
            "currency": "USD"
        },
        "adjustmentsTotal": {
            "amount": 0.00,
            "currency": "USD"
        },
        "total": {
            "amount": 14.99,
            "currency": "USD"
        },
        "feesTotal": {
            "amount": 0.00,
            "currency": "USD"
        },
        "taxIncludedType": "NO",
        "includedTaxAmount": {
            "amount": 0.00,
            "currency": "USD"
        }
    },
    "cartItems": [
        {
            "id": "01J8TEP9WH10PHWC6D9P1TQDDJ",
            "name": "Hawt Like a Habanero Shirt (Men's)",
            "uri": "/merchandise/hawt-like-a-habanero-shirt-mens",
            "quantity": 1,
            "overridePriceFlag": false,
            "unitPrice": {
                "amount": 14.99,
                "currency": "USD"
            },
            "unitPriceType": "basePrice",
            "adjustmentsTotal": {
                "amount": 0.00,
                "currency": "USD"
            },
            "subtotal": {
                "amount": 14.99,
                "currency": "USD"
            },
            "total": {
                "amount": 14.99,
                "currency": "USD"
            },
            "pricingStrategy": "ADD_TO_PARENT",
            "pricingKey": "4622eedb-e23e-4393-9aad-15d9b8250fd4",
            "variantId": "01F9YG6CN1XZXR1NMGC7M81K4D",
            "productId": "01F9YCP2XPW25A1AKWK02X0GXT",
            "categoryIds": [
                "category3"
            ],
            "sku": "ME-HS-WYRYTL",
            "imageAsset": {
                "contentUrl": "/api/asset/content/habanero_mens_black.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22forceFilterByCatalogIncludeInheritance%22:false,%22forceFilterByCatalogExcludeInheritance%22:false,%22applicationId%22:%222%22,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
                "altText": "Black Hawt Like a Habanero Shirt (Men's)",
                "tags": [
                    "color:black"
                ]
            },
            "discountable": true,
            "merchandisingTypeId": "merchandisingType-shirts",
            "targetDemographicId": "targetDemographic-men",
            "attributes": {
                "priceInfo": {
                    "target": {
                        "targetId": "ME-HS-WYRYTL",
                        "targetType": "SKU",
                        "targetQuantity": 1,
                        "priceableFields": {
                            "baseCost": {
                                "amount": 4.99,
                                "currency": "USD"
                            },
                            "basePrice": {
                                "amount": 14.99,
                                "currency": "USD"
                            }
                        },
                    },
                    "price": {
                        "amount": 14.99,
                        "currency": "USD"
                    },
                    "priceType": "basePrice",
                    "priceTypeDetails": {
                        "baseCost": {
                            "type": "baseCost",
                            "bestPrice": {
                                "amount": 4.99,
                                "currency": "USD"
                            },
                        },
                        "basePrice": {
                            "type": "basePrice",
                            "bestPrice": {
                                "amount": 14.99,
                                "currency": "USD"
                            },
                        }
                    }
                },
            },
            "internalAttributes": {
                "inventoryType": "PHYSICAL",
                "inventoryCheckStrategy": "NEVER",
                "inventoryReservationStrategy": "NEVER",
                "pricingKeyPriceInfos": {
                    "4622eedb-e23e-4393-9aad-15d9b8250fd4": {
                        "price": {
                            "amount": 14.99,
                            "currency": "USD"
                        },
                        "priceType": "basePrice",
                        "priceTypeDetails": {
                            "baseCost": {
                                "type": "baseCost",
                                "bestPrice": {
                                    "amount": 4.99,
                                    "currency": "USD"
                                },
                            },
                            "basePrice": {
                                "type": "basePrice",
                                "bestPrice": {
                                    "amount": 14.99,
                                    "currency": "USD"
                                },
                            }
                        }
                    }
                },
                "availableOnline": true,
                "discountable": true,
                "sku": "ME-HS-WYRYTL",
                "skuPriceInfos": {
                    "ME-HS-WYRYTL": {
                        "price": {
                            "amount": 14.99,
                            "currency": "USD"
                        },
                        "priceType": "basePrice",
                        "priceTypeDetails": {
                            "baseCost": {
                                "type": "baseCost",
                                "bestPrice": {
                                    "amount": 4.99,
                                    "currency": "USD"
                                },
                            },
                            "basePrice": {
                                "type": "basePrice",
                                "bestPrice": {
                                    "amount": 14.99,
                                    "currency": "USD"
                                },
                            }
                        }
                    }
                },
                "productType": "VARIANT_BASED"
            },
            "attributeChoices": {
                "COLOR": {
                    "optionLabel": "Color",
                    "label": "Black",
                    "value": "black"
                },
                "SIZE": {
                    "optionLabel": "Size",
                    "label": "M",
                    "value": "MEDIUM"
                }
            },
            "taxable": true,
            "cartVersion": 9,
            "type": "STANDARD",
            "unitPriceWithDependentItems": {
                "amount": 14.99,
                "currency": "USD"
            },
            "subtotalWithDependentItems": {
                "amount": 14.99,
                "currency": "USD"
            },
            "totalWithDependentItems": {
                "amount": 14.99,
                "currency": "USD"
            },
            "unitPriceWithAdjustments": {
                "amount": 14.99,
                "currency": "USD"
            },
            "adjustmentsTotalWithDependentItems": {
                "amount": 0.00,
                "currency": "USD"
            },
            "priced": true
        }
    ],
    "fulfillmentGroups": [
        {
            "referenceNumber": "01J8SSH2KAGVNK4GXFKWPES3Q5-0",
            "type": "SHIP",
            "taxAddressSource": "SHIPPING_ADDRESS",
            "fulfillmentItemsSubtotal": {
                "amount": 0.00,
                "currency": "USD"
            },
            "fulfillmentAdjustmentsTotal": {
                "amount": 0.00,
                "currency": "USD"
            },
            "overrideFulfillmentPriceFlag": false,
            "fulfillmentItems": [
                {
                    "referenceNumber": "01J8TEP9X2TV3JGQE7ETJRDFJC",
                    "cartItemId": "01J8TEP9WH10PHWC6D9P1TQDDJ",
                    "quantity": 1,
                    "merchandiseTotalAmount": {
                        "amount": 14.99,
                        "currency": "USD"
                    },
                    "fulfillmentTotal": {
                        "amount": 0.00,
                        "currency": "USD"
                    },
                    "availableOnline": true,
                    "inventoryCheckStrategy": "NEVER",
                    "inventoryReservationStrategy": "NEVER",
                    "internalAttributes": {
                        "inventoryType": "PHYSICAL",
                        "inventoryCheckStrategy": "NEVER",
                        "inventoryReservationStrategy": "NEVER",
                        "pricingKeyPriceInfos": {
                            "4622eedb-e23e-4393-9aad-15d9b8250fd4": {
                                "price": {
                                    "amount": 14.99,
                                    "currency": "USD"
                                },
                                "priceType": "basePrice",
                                "priceTypeDetails": {
                                    "baseCost": {
                                        "type": "baseCost",
                                        "bestPrice": {
                                            "amount": 4.99,
                                            "currency": "USD"
                                        },
                                    },
                                    "basePrice": {
                                        "type": "basePrice",
                                        "bestPrice": {
                                            "amount": 14.99,
                                            "currency": "USD"
                                        },
                                    }
                                }
                            }
                        },
                        "availableOnline": true,
                        "discountable": true,
                        "sku": "ME-HS-WYRYTL",
                        "skuPriceInfos": {
                            "ME-HS-WYRYTL": {
                                "price": {
                                    "amount": 14.99,
                                    "currency": "USD"
                                },
                                "priceType": "basePrice",
                                "priceTypeDetails": {
                                    "baseCost": {
                                        "type": "baseCost",
                                        "bestPrice": {
                                            "amount": 4.99,
                                            "currency": "USD"
                                        },
                                    },
                                    "basePrice": {
                                        "type": "basePrice",
                                        "bestPrice": {
                                            "amount": 14.99,
                                            "currency": "USD"
                                        },
                                    }
                                }
                            }
                        },
                        "productType": "VARIANT_BASED"
                    },
                    "cartItemDiscountable": true
                }
            ],
            "priced": false
        }
    ],
    "version": 1,
    "lastCatalogReprice": "2024-09-27T19:16:10.033362Z",
    "priced": false,
    "taxEstimated": true,
    "quantity": 1,
    "anonymous": true,
    "currency": "USD"
}