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:
The required requests to read a product using the Commerce SDK.
The general page design, specifically around a single product that covers Standard and Variation-Based Products.
Important
|
Key for this walkthrough will be the use of the Commerce JS SDK, particularly the Browse SDK module. |
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:
Resolve the tenant and application
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]
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.
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.
Method | Path | Params | SDK |
---|---|---|---|
|
|
|
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)
}
Unresolved directive in product-details.adoc - include::pdp-response-snippet.adoc[PDP Response]
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.
Method | Path | Params | SDK |
---|---|---|---|
|
|
|
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)
}
Unresolved directive in product-details.adoc - include::pdp-response-snippet.adoc[PDP Response]
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.
Method | Path | Params | SDK |
---|---|---|---|
|
|
|
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)
}
We will discuss page design specifically around a single standard or variation-based product.
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.
Name
SKU
Options
Description
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.
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.
|
{
"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"
},
}
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.
|
{
"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":
{}
}
}
}
}
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.
This tells us what type of price, whether standard, sale, or other. This is useful for displaying these prices distinctly.
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.
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.
See the Component Localization Guide for more.
Let’s look at a simple example using just using formatNumber
directly from Format.js.
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);
}
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>
);
};
Tip
|
Also see Cart Operations Service documentation. |
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.
Method | Path | Params | SDK |
---|---|---|---|
|
|
|
|
|
|
|
Let’s examine an example request:
{
"dependentCartItems": [],
"itemAttributeChoices": (1)
{
"color": "#594f63",
"capacity": "1 tb"
},
"productId": "989", (2)
"quantity": 1,
"variantId": "01J361EKTWTWKWS4X0RHG2JRH0", (3)
"sku": "215343", (4)
"currency": "USD"
}
These should match the selected Product Options attributes.
For variation-based products, this is required so that the backend can validate the request and populate additional information on the Cart Item.
For variation-based products, this is the ID of the Variation whose optionValues
matches the selected itemAttributeChoices
.
Alternative to passing the Variation’s ID.
{
"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"
}
}
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.
import { cartClient } from '../cart-client.js';
export async function addItemToCart(cartId, request, options) {
return cartClient.addItemToCart(cartId, request, options); (1) (2)
}
import { cartClient } from '../cart-client.js';
export async function createCart(request, options) {
return cartClient.createCart(request, options); (1) (2)
}
The response after adding an item or creating a Cart is the updated or new Cart.
{
"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"
}