In this walkthrough, we’ll be examining how we built the commerce app’s homepage. Primarily, we’ll look at the different sections and what API calls were necessary to fill them out.
We need to make a few requests before we start loading content specific to the homepage, 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
Additionally, there are some other requests that we can start sending to gather additional context but which do not prevent us from loading content:
Check whether the request originated from the Admin as a preview-on-site request.
Lets admins view changes before they are pushed to production
See changes that are scheduled to activate in the future
Resolve the in-progress cart, if any, for the current user
We’ll either determine this from a previously added cookie for anonymous users or from an authenticated user’s ID
Check whether the request originated from the Admin as a Customer Service Representative (CSR) request.
Let’s a CSR shop on the site as a guest or registered customer, i.e., impersonate a customer, to help resolve issues for them
Now, we can start examining the where the content for the homepage comes from. Let’s start with the header. We see several subcomponents here from top-to-bottom, left-to-right:
Let’s go into more detail on what API requests are used to load the content for each of those.
Let’s examine the locale selector.
When the Application is resolved, the response includes a list of allowed locales. We use those to populate this selector, with the default automatically selected. Once a locale is selected we reload the content of the page to grab all of the translations that are stored on the backend. We also update the static text in the app if there are messages for the new locale.
Note
|
The flag icons you see are configured in the frontend itself. The backend only responds with the locale codes, and it’s up to the you to decide how those should be represented. |
Tip
|
Checkout the API docs for the application resolution request to see all of what it includes besides locales. And see Tenant & Application Walkthrough. |
Let’s examine the currency selector.
When the Application is resolved, the response includes a list of allowed currencies. We use those to populate this selector, with the default automatically selected. Once a currency is selected, we refetch any priceable entities on the page like products.
Tip
|
Checkout the API docs for the application resolution request to see all of what it includes besides currencies. And see Tenant & Application Walkthrough. |
Like the locales for the selector, we get this from the resolved Application.
However, we don’t actually get the asset itself from the Application response: We get its metadata like alt-text, title and URL. The asset itself is managed by the Asset Services, and to fetch it we’ll usually send a request to the content resolution endpoint using the asset’s content URL. The content URL could point to CDN or to the content resolution endpoint in Asset Services.
Tip
|
Checkout the API docs for the application resolution request to see all of what it includes besides locales (like currencies) And see Tenant & Application Walkthrough. |
Let’s take a look at the main site navigation menu.
We maintain this menu in the Admin, and derive it from the Menu Service.
To retrieve it, we just need its name: HEADER_MENU
.
We have this setup to be configurable with a property HEADER_MENU_NAME
, so it’s easy to change without getting into the code.
The name is then set as a parameter called menuName
.
GET /api/menu/menu-hierarchy?menuName=HEADER_MENU
import { ClientOptions } from '@broadleaf/commerce-core';
import { MenuClient } from '@broadleaf/commerce-menu';
async function getHeaderMenu(options: ClientOptions) {
const client = new MenuClient(options);
return client.getMenuHierarchy(process.env.NEXT_PUBLIC_HEADER_MENU_NAME);
}
{
"id": "01E00311DAH8RF0YQGXF0F10DM",
"name": "HEADER_MENU",
"submenu": [
{
"id": "01E0038S14SREB1KEKBGDP1K54",
"parentMenuId": "01E00311DAH8RF0YQGXF0F10DM",
"label": "Home",
"url": "/",
"displayOrder": 1000,
"type": "LINK",
"submenu": []
},
{
"id": "01E003BFC2S6FJ1V8K46VY00VD",
"parentMenuId": "01E00311DAH8RF0YQGXF0F10DM",
"label": "Hot Sauces",
"url": "/hot-sauces",
"displayOrder": 2000,
"type": "CATEGORY",
"submenu": []
},
{
"id": "01E003E5KJD9RR19K89WF412JG",
"parentMenuId": "01E00311DAH8RF0YQGXF0F10DM",
"label": "Merchandise",
"url": "/merchandise",
"displayOrder": 3000,
"type": "CATEGORY",
"submenu": [
{
"id": "01E003FSRDSNPQ1GBWVQ2R1N8G",
"parentMenuItemId": "01E003E5KJD9RR19K89WF412JG",
"parentMenuId": "01E00311DAH8RF0YQGXF0F10DM",
"label": "Holiday Hats",
"url": "/holiday-hats",
"displayOrder": 1000,
"type": "CATEGORY",
"submenu": []
}
]
},
{
"id": "01E003J6D6WZ4M1932HR3F179M",
"parentMenuId": "01E00311DAH8RF0YQGXF0F10DM",
"label": "Clearance",
"url": "/clearance",
"displayOrder": 4000,
"type": "CATEGORY",
"submenu": []
}
]
}
Note
|
To go into a little more detail, a
Moreover, menu items can be setup to have sub-items so as to create submenus. In the case of our header menu, we have one example: Merchandise This is just one way to setup a sub-menu. Check out Footer Navigation for another example. |
This input allows searching through the catalog.
While typing, we’ve configured it to send requests to get type-ahead suggestions. These suggestions include not just keywords, but also categories and specific products related to the search term. Each of these suggestions are clickable, so that a product suggestion will take the user to that product’s detail page.
These requests are sent to the Catalog-Browse Service, which fronts the Search Services for commerce-facing requests.
The request should include 2 parameters: query
and config
.
Query is self-explanatory, but config specifies which TypeAheadConfiguration to use.
We also include the X-Price-Context
and X-Price-Info-Context
headers so that pricing can be hydrated on the results without making a second request from the browser.
GET /api/catalog-browse/search/catalog/suggest?query=hot&config=productTypeAhead
import { BrowseClient } from '@broadleaf/commerce-browse';
import { ClientOptions } from '@broadleaf/commerce-core';
async function getTypeAheadResults(options: ClientOptions) {
const client = new BrowseClient(options);
return client.searchSuggestions({
query: 'hot',
name: 'productTypeAhead',
});
}
{
"locale": "en-US",
"currency": "USD",
"userTargets": [
{
"targetValue": "authUser.serviceId",
"targetType": "CUSTOMER"
}
],
"attributes": {
"webRequest": {
"fullUrl": "https://www.heatclinic.com/",
"pathname": "/",
"userAgentType": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36 OPR/72.0.3815.320",
"secure": true,
"searchKeywords": ""
}
}
}
{
"priceLists": [
{
"id": "string",
"name": "string",
"type": "STANDARD|SALE|CONTRACT",
"priority": 0,
"currency": "USD|MXN|CAD|EUR|GBP|etc"
}
],
"skipDetails": true
}
{
"suggestions": {
"categories": [
{
"categoryNames": "Hot Sauces",
"numResults": 2
},
{
"categoryIds": "category1",
"numResults": 2
}
],
"products": [
{
"id": "product4",
"priceInfo": {
"price": {
"amount": 8.99,
"currency": "USD"
},
"priceType": "basePrice",
"priceTypeDetails": {
"basePrice": {
"type": "basePrice",
"bestPrice": {
"amount": 8.99,
"currency": "USD"
},
"priceDetails": {}
}
}
},
"currency": "USD",
"price": {
"amount": 8.99,
"currency": "USD"
},
"options": [],
"variants": [],
"includedProducts": [],
"promotionalProducts": {},
"name": "Hoppin' Hot Sauce",
"description": "Tangy, ripe cayenne peppers flow together with garlic, onion, tomato paste and a hint of cane sugar to make this a smooth sauce with a bite. Wonderful on eggs, poultry, pork, or fish, this sauce blends to make rich marinades and soups.",
"uri": "/hot-sauces/hoppin-hot-sauce",
"primaryAssetContentUrl": "https://{demo-admin-host}/api/asset/content/Hoppin-Hot-Sauce-Bottle.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
"primaryAssetAltText": "Bottle of Hoppin' Hot Sauce"
}
]
},
"keyWords": [
{
"highlightedSuggestion": "<strong>Hot</strong> Sauce",
"suggestion": "Hot Sauce"
}
]
}
Note
|
A TypeAheadConfiguration contains the information such as
|
Once a query is submitted, we send it along to the Catalog-Browse Service again and then redirect to the Search Results page. That page includes sorting, filtering, and faceting on the results. By default, we have Apache Solr powering our search, and have it index the catalog. See the Search Results walkthrough for details
These are currently configured in the frontend itself as links to the sign-in and registration pages.
The sign-in page lives on a separate domain for security reasons, but once signed-in, the user will be redirected back here.
Likewise with the registration page:
When the form is submitted, it will redirect the user to the sign-in page so they can login. For security reasons, we do not automatically sign the user in after registering.
Once signed in, those links will change to show "My Account" links:
Here we see the link to the cart page.
This link has several additional behaviors:
It shows how many items are currently in the cart
When an item is added, a cart summary slides over with the cart’s contents and a button to proceed to checkout.
And, if clicked, this will take the user to view the cart. See our Cart walkthrough.
Next, let’s examine the body of the homepage. Here we see:
Let’s examine the banner.
We derive this from a ContentTargeter
in the Personalization Service.
Note
|
A ContentTargeter is a simple component for content management allowing different ContentItems to be served based on customizable rules. ContentItems are just the content to be displayed, whether an asset, HTML, or plain text. They also come with display rules so that you could show one ContentItem to signed-in users while another to anonymous ones for the same targeter. |
To retrieve its content, we send a request to the Personalization Services with the name of the targeter: Homepage Banner Ad
.
We have this setup to be configurable with a property HOME_HERO_CONTENT_TARGETER
, so it’s easy to change without getting into the code.
The request can also specify the selected locale and other attributes against which the ContentItems' rules may be evaluated.
POST /api/personalization/content-targeters/item
import { useAuth } from '@broadleaf/auth-react';
import { ClientOptions } from '@broadleaf/commerce-core';
import { PersonalizationClient } from '@broadleaf/commerce-personalization';
async function getContentItem(options: ClientOptions) {
const client = new PersonalizationClient(options);
const { isAuthenticated, getAccessToken } = useAuth();
const accessToken = isAuthenticated ? await getAccessToken() : undefined;
const targeterContext = {
"locale": "en-US",
"attributes": {
"webRequest": {
"fullUrl": "http://localhost:4000/",
"pathname": "/",
"userAgentType": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36 OPR/76.0.4017.123",
"secure": false,
"searchKeywords": ""
}
}
};
return client.getContentItem(process.env.NEXT_PUBLIC_HOME_HERO_CONTENT_TARGETER, {
accessToken,
targeterContext,
});
}
{
"name": "Homepage Banner Ad",
"locale": "en-US",
"attributes": {
"webRequest": {
"fullUrl": "http://localhost:4000/",
"pathname": "/",
"userAgentType": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36 OPR/76.0.4017.123",
"secure": false,
"searchKeywords": ""
}
}
}
{
"id": "01F5NQM3C18N681CMTH3S90ZWG",
"name": "Default Content Item",
"activeStartDate": "2020-02-20T16:16:05.953Z",
"priority": 2,
"type": "IMAGE",
"asset": {
"tenantId": "5DF1363059675161A85F576D",
"type": "IMAGE",
"provider": "BROADLEAF",
"url": "/default-hero.jpg",
"contentUrl": "https://admin.blcdemo.com/api/asset/content/default-hero.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
"tags": []
}
}
Warning
|
Note that the actual digital asset for the banner is not returned from the Personalization Service. Instead we get a content URL along with some metadata like alt-text and title. We’ll use this content URL to resolve the asset: It will usually either point to Broadleaf’s content resolution endpoint in the Asset Services or to a CDN. |
At the bottom of the main body of content on the homepage, we have a curated list of top-selling or featured products.
We’ve set this up as a Category
that is manually curated.
It is mapped to the homepage’s context path (i.e., /
), but you can set it up to be mapped differently.
To retrieve it and summaries of its products, we need to send a request to the category-details endpoint in the Catalog-Browse Service.
In the request, we just include the URL the category is mapped to: /
.
Alternatively, we could include the exact ID of the category, if we know it before hand.
The request can take not only the URL or ID of the category we want, but also some additional parameters to restrict the included set of products. We can include the following paging params (or any others that you configure):
Size: How many results we want.
Default for this section is 4
.
Offset: Where in the entire result set to start from.
Default is 0
.
Forward: Whether we are paging forward or backward from the offset, i.e., whether to get 4 (size) products after (backward) at the 10th (offset) result or before (forward).
Default is true
.
The response will include the top-level category info like name and description and the top-level product info like name, price, URI. This will not fully hydrate the product hierarchies (like options and assets), but only return a summary of the product’s info since we don’t normally want to display all of those details on a category details or browse page. Furthermore, like with search requests we include the pricing headers.
GET /api/catalog-browse/categories/details?categoryUrl=/
import { BrowseClient } from '@broadleaf/commerce-browse';
import { ClientOptions } from '@broadleaf/commerce-core';
async function getContentItem(options: ClientOptions) {
const client = new BrowseClient(options);
return client.getCategoryByUrl('/', {
locale,
priceContext,
});
}
{
"locale": "en-US",
"currency": "USD",
"userTargets": [
{
"targetValue": "authUser.serviceId",
"targetType": "CUSTOMER"
}
],
"attributes": {
"webRequest": {
"fullUrl": "https://www.heatclinic.com/",
"pathname": "/",
"userAgentType": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36 OPR/72.0.3815.320",
"secure": true,
"searchKeywords": ""
}
}
}
{
"priceLists": [
{
"id": "string",
"name": "string",
"type": "STANDARD|SALE|CONTRACT",
"priority": 0,
"currency": "USD|MXN|CAD|EUR|GBP|etc"
}
],
"skipDetails": true
}
Note that the content was reduced to 1 product to reduce size.
{
"id": "category7",
"products": {
"content": [
{
"id": "product1",
"sku": "HS-GG-20",
"priceInfo": {
"price": {
"amount": 9.99,
"currency": "USD"
},
"priceType": "salePrice",
"priceListId": "hc_base_sales",
"priceTypeDetails": {
"salePrice": {
"type": "salePrice",
"bestPrice": {
"amount": 9.99,
"currency": "USD"
},
"priceListId": "hc_base_sales",
"priceDetails": {}
},
"basePrice": {
"type": "basePrice",
"bestPrice": {
"amount": 11.99,
"currency": "USD"
},
"priceDetails": {}
}
}
},
"currency": "USD",
"options": [],
"variants": [],
"includedProducts": [],
"promotionalProducts": {},
"primaryAsset": {
"tenantId": "5DF1363059675161A85F576D",
"type": "IMAGE",
"provider": "BROADLEAF",
"url": "/Green-Ghost-Bottle.jpg",
"contentUrl": "https://{demo-admin-host}/api/asset/content/Green-Ghost-Bottle.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
"altText": "Bottle of Green Ghost",
"title": "Bottle of Green Ghost",
"tags": [],
"id": "product1_primary",
"productId": "product1",
"primary": true,
"sorted": true,
"parentId": "product1"
},
"inventoryType": "PHYSICAL",
"activeStartDate": "2020-11-13T08:50:48.892360Z",
"merchandisingProduct": false,
"description": "Made with Naga Bhut Jolokia, the World's Hottest pepper.",
"active": true,
"individuallySold": true,
"uri": "/hot-sauces/green-ghost",
"searchable": true,
"inventoryCheckStrategy": "NEVER",
"assets": [
{
"tenantId": "5DF1363059675161A85F576D",
"type": "IMAGE",
"provider": "BROADLEAF",
"url": "/Green-Ghost-Bottle.jpg",
"contentUrl": "https://{demo-admin-host}/api/asset/content/Green-Ghost-Bottle.jpg?contextRequest=%7B%22forceCatalogForFetch%22:false,%22tenantId%22:%225DF1363059675161A85F576D%22%7D",
"altText": "Bottle of Green Ghost",
"title": "Bottle of Green Ghost",
"tags": [],
"id": "product1_primary",
"productId": "product1",
"primary": true,
"sorted": true,
"parentId": "product1"
}
],
"inventoryReservationStrategy": "NEVER",
"fulfillmentFlatRates": {},
"eligibleForPickup": false,
"name": "Green Ghost",
"online": true,
"onSale": false,
"attributes": {},
"availableOnline": true,
"discountable": true,
"reviewsSummary": {
"numberOfReviews": 0
}
}
],
"last": false,
"numberOfElements": 4,
"size": 4,
"pageable": {
"paged": true,
"forward": true,
"offset": 0,
"underlyingSize": 4,
"pageSize": 4,
"unpaged": false
},
"sort": {
"sorted": false,
"unsorted": true,
"empty": true,
"orders": []
},
"first": true,
"empty": false
},
"displayTemplate": "DEFAULT",
"activeStartDate": "2020-11-13T08:50:48.892360Z",
"description": "The Heat Clinic's Top Selling Sauces",
"externalId": "topSellersExternalId",
"taxCode": "US",
"metaDescription": "The Heat Clinic's Top Selling Sauces",
"url": "/",
"showInSiteMap": true,
"productMembershipType": "EXPLICIT",
"metaTitle": "Top Sellers",
"name": "Top Sellers",
"promotionalProducts": {},
"attributes": {},
"breadcrumbs": [
{
"label": "Top Sellers"
}
]
}
Finally, let’s examine the footer. We see:
Let’s take a look at the footer navigation menu.
We maintain this menu in the Admin, and derive it from the Menu Service, just like with the main navigation in the header.
To retrieve it, we just need its name: FOOTER_MENU
.
We have this setup to be configurable with a property FOOTER_MENU_NAME
, so it’s easy to change without getting into the code.
This menu is setup with 3 submenus to demonstrate how we can use a single call to retrieve a menu-hierarchy.
See the main navigation section for more details on Menu
and MenuItem
.
GET /api/menu/menu-hierarchy?menuName=FOOTER_MENU
import { ClientOptions } from '@broadleaf/commerce-core';
import { MenuClient } from '@broadleaf/commerce-menu';
async function getFooterMenu(options: ClientOptions) {
const client = new MenuClient(options);
return client.getMenuHierarchy(process.env.NEXT_PUBLIC_FOOTER_MENU_NAME);
}
{
"id": "01E0016QJVTZRB08JHS8GF1H3N",
"name": "FOOTER_MENU",
"submenu": [
{
"id": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "About Us",
"displayOrder": 1000,
"type": "TEXT",
"submenu": [
{
"id": "01E001JD943P3Z1CWZEX331M7M",
"parentMenuItemId": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "Company Info",
"url": "/company-info",
"displayOrder": 1000,
"type": "LINK",
"submenu": []
},
{
"id": "01E001SW9B70RW0W3GC8AA1FVT",
"parentMenuItemId": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "Contact Us",
"url": "/contact-us",
"displayOrder": 2000,
"type": "LINK",
"submenu": []
},
{
"id": "01E001VSEBJ6W311X5HRFE0DQB",
"parentMenuItemId": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "Privacy Policy",
"url": "/privacy",
"displayOrder": 3000,
"type": "LINK",
"submenu": []
},
{
"id": "01E001XC8FTZQQ13BDE2Y71S3C",
"parentMenuItemId": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "Terms of Use & Terms of Sale",
"url": "/terms",
"displayOrder": 4000,
"type": "LINK",
"submenu": []
},
{
"id": "01E001YZRBMSEN0HQ8JHQZ0WD9",
"parentMenuItemId": "01E001B54K7E7Y0SEJW8XG1NHK",
"parentMenuId": "01E0016QJVTZRB08JHS8GF1H3N",
"label": "CA Supply Chain Act",
"url": "/ca-supply-chain-act",
"displayOrder": 5000,
"type": "LINK",
"submenu": []
}
]
}
]
}
Next let’s look at the social links.
We’ve set this up as static text in the frontend itself, but this could be setup using a managed Menu
or made a sub-menu of the main footer menu.