Sitecore Experience Commerce: Accessing the GetRawEntity API

In this article, we will take a look at why the GetRawEntity api returns a 404 Not Found response for the default admin user in Sitecore Commerce.

Note: The GetRawEntity API is intended for troubleshooting and validation purposes and would be utilised by devops and developer users.

Reviewing the commerce logs we find that the QA role in not a role in the current request.

00064 22:41:06 ERROR CtxMsg.Error.QARoleNotFound: Text=QA is not a role in the current request.
00064 22:41:06 ERROR PipelineAbort:QA is not a role in the current request.

We can resolve this by updating the role memberships assigned the admin user, or any desired user.

  1. In Sitecore, go to the User Manager
  2. Select and edit the desired user
  3. In the Edit User modal,
    1. Select the MEMBER OF tab and edit the roles.
    2. Locate the sitecore\QA role and add it to the selected roles.

Note: If the user has already has received a token from Identity Server, a new token will need to be issued to receive the new role.

Sitecore Experience Commerce: Enabling Disassociate, Edit, and Transfer Inventory Actions for Published Sellable Items and Variants

In this article, we will look at how we can enable the Disassociate Sellable Item from Inventory Set, Edit Sellable Item Inventory and Transfer Inventory actions when viewing sellable item and variant entity views in BizFx.

For entities that have been configured to utilise entity versioning (catalogs, categories, and sellable items), via the VersioningPolicy in the VersioningPolicySet, all actions are disabled by default when the entity has been published.

The actions that are enabled are due to those actions being registered in the EntityVersionsActionsPolicy in the VersioningPolicySet, under the AllowedActions property.

There is no need to restrict the Inventory Sets actions in the Sellable Item and Variant entity views as they can be executed from within the Inventory Manager.

Note: Inventory association and disassociation actions apply to all entity versions of the sellable items as inventory records don't have strong ties to specific entity versions as inventory is not content.

To enable the Inventory Sets actions, simply add their action names to the AllowedActions, and deploy and bootstrap.

{
    "$type": "Sitecore.Commerce.Plugin.EntityVersions.EntityVersionsActionsPolicy, Sitecore.Commerce.Plugin.EntityVersions",
    "AllowedActions": {
    "$type": "System.Collections.Generic.List`1[[System.String, mscorlib]], mscorlib",
    "$values": [
            "AddEntityVersion",
            "AddCatalog",
            "DeleteCatalog",
            "AddCategory",
            "DeleteCategory",
            "AddSellableItem",
            "DeleteSellableItem",
            "AddBundle",
            "AssociateCategoryToCategoryOrCatalog",
            "AssociateSellableItemToCatalog",
            "AssociateSellableItemToCategory",
            "DisassociateItem",
            "MakePurchasable",
            "DisassociateSellableItemFromInventorySet",
            "EditSellableItemInventory",
            "TransferInventory"
        ]
    }
}

These actions can now be performed, regardless of whether or not the entity has been published.

Sitecore Experience Commerce: Methods for Logging and Command Messaging

In this article, we will look at the APIs available for logging to the logging framework and applying command messages to the CommerceContext.

The reason for grouping these two subjects together is due to seeing a lot of confusion around these areas when reviewing developers' code in the field; there is some overlap between them, which is often overlooked.

In Sitecore Commerce, logging is based on Microsoft.Extensions.Logging, and the Sitecore Commerce Engine SDK is setup to utilise the SeriLog diagnostic library for logging.

The CommandMessages are flushed to calling CommerceCommands via the completion of the CommandActivity and are included in the response object of CommandsController APIs.

Logging and command messaging occurs within methods of the CommercePipelineExecutionContext and the CommerceContext.

CommercePipelineExecutionContext

LogInfoIf

public void LogInfoIf(bool conditionResult, string info);

Intuitive enough, the LogInfoIf method will log an Information level entry, info, if the conditionResult is met.

Abort

public override void Abort(string reason, object data);

The Abort method will abort the pipeline and will create an Error level log entry if the reason message doesn't contain the magic string "Ok|".

It's also worth noting that this method is intended to abort the executing pipeline first and foremost, and the log entry is secondary. It is not intended solely for the purpose of logging.

Note: The data object would normally return the current CommercePipelineExecutionContext.

CommerceContext

AddDataMessage

public virtual void AddDataMessage(string messageType, string dataMessage);

The AddDataMessage will add the dataMessage to the command messages. It will also add the dataMessage to the logger at the Information level.

Tip: Avoid setting Debug level messages to avoid spamming your local development logs, which are defaulted to the Information level.

AddMessage

public virtual void AddMessage(CommandMessage message);

AddMessage will add a CommandMessage to the command messages.

The message will not invoke the logger.

AddMessage (Alternate)

public virtual Task<string> AddMessage(string code, string commerceTermKey, object[] args, string defaultMessage = null);

The overloaded AddMessage method's logging behaviour is as follows:

  • Message codes of ValidationError or Warning will add a Warning message to the Logger.
  • All exception types will be logged using the LogException method. See LogException for more details.
  • An Error will also be logged as an Error.

For the CommandMessages, the localised message will attempted to be retrieved from Sitecore, using the commerce term key provided, and further formatted/interpolated with the args provided.

LogException

public virtual void LogException(string caller, Exception ex);

The LogException method will log an exception as an error with the exception message and stack trace details.

LogExceptionAndMessage

public virtual void LogExceptionAndMessage(string caller, Exception ex);

The LogExceptionAndMessage logs the exception as per the LogException method, however the exception message will be added to the CommandMessages at the Error level in addition.

Logger

public ILogger Logger { get; }

The Logger exposes the can be utilised to add the standard log-level entries to it, being:

  • LogDebug
  • LogInformation
  • LogWarning
  • LogError
  • LogCritical

Business Tools: The Autocomplete UI Type Control

In this article, we will review the Autocomplete UI Type control in detail to understand what capabilities we have available to us with and without further customisation.

What Does the Autocomplete UI Type Control Do?

The Autocomplete control provides the user with a list of potentially search matches to identify the entity with only a partial match. This occurs only when 4 or more characters have been entered into the field.

Upon selection, the entity is converted from its user-friendly display name to the raw entity id value required by the system.

The Implementation Behind the Autocomplete Control?

Commerce Engine Configuration

The autocomplete control is configured by setting a ViewProperty's UiType to "Autocomplete". In addition to this there are two policies that need to be added to the ViewProperty, which are required to complete its configuration.

The first policy is the SearchScopePolicy, which is utilised to retrieve the index name from, which is set in the Plugin.Search.PolicySet-1.0.0.json in the Commerce Engine. Using the GetPolicyByType method, pass in the typeof entity that the is configured to the policy's EntityTypeNames property.

Note: Only the Catalog Items Scope is supported by default.

var searchScopePolicy = SearchScopePolicy.GetPolicyByType(context.CommerceContext, context.CommerceContext.Environment, typeof(SellableItem));

The second policy is a generic policy that will be utilised by BizFx to apply some post-search filtering to the search results. This policy must have the PolicyId of "EntityType" and will contain a list of up to two models.

The first model's name must be set as the name of the entity without the "Entity-" prefix ("SellableItem", "Category", or "Catalog"). This is known as the policy scope in BizFx.

The second model's name is only applicable for sellable items and can only be set as "SearchVariants" if you want the variants to be included in the search results for selection. All other values will be ignored and you cannot set multiple entities to be included in the autocomplete list.

var policy = new Policy(new List<Model>()
{
	new Model() { Name = "SellableItem" },
	new Model() { Name = "SearchVariants" }
})
{
	PolicyId = "EntityType"
};

BizFx Implementation

The sc-bizfx-autocomplete.component.ts file that is shipped with the BizFx SDK is where some of the magic happens. A couple of magic strings and magic indexes are the keys to processing the translating the ViewProperty configuration into search parameters and post-search filtering.

In short, when 4 or more characters are available in the text field, the text is added as the search term parameter, and the search index name, which is extracted from the SearchScopePolicy, is added as the scope parameter, passed into the Commerce Engine's Search API, querying the top 100 results. The results are then processed by the BizFx component by filtering out entities that don't match the entity type, specified in the policy scope model from the EntityType policy. The results are then added to the list of results that will populate the autocomplete dropdown, using the displayname as the display name and the entityid as the value.

Where SearchVariants have been configured for sellable item searches, the BizFx component iterates over the pipe separated variantdisplayname and variantid fields to create variant entries in the autocomplete list.

Note: As BizFx uses the displayname field to render the autocomplete item list, the order and customer indexes, which do not contain a displayname field cannot be configured with autocomplete functionality without customisation. Alternatively, the Search entity views in the Customers Manager and Orders Manager are available.

Search Configuration

In the Solr core's managed-schema, copies fields over to the _text_ field, which is used to construct the search query in the Commerce Engine.

The catalog item scope index contains the Catalog, Category, and Sellable Item data, based on the SearchScopePolicy configuration of the Entity Type Names from the Commerce Engine SDK. Search queries will attempt to match fields copied into the _text_ field in the search provider's index schema.

<copyField source="displayname" dest="_text_"/>
<copyField source="variantid" dest="_text_"/>
<copyField source="variantdisplayname" dest="_text_"/>
<copyField source="productid" dest="_text_"/>
<copyField source="name" dest="_text_"/>

Note: I have only looked into the Solr configuration, so for those using Azure Search there may be some investigation work required to identify its search configuration.

What Configurations are Available for the Autocomplete Control?

Catalog Search

The catalog search is not used by the business tools by default. Instead, due to the low catalog entity count, the underlying code logic for associating a catalog to a price book or promotion book utilises the IFindEntitiesInListPipeline to populate a dropdown list control.

Note: Only the displayname and name fields will be present in the catalog entity indexes.

Category Search

The category search will return results for categories, regardless of the catalog it resides in.

Note: Only the displayname and name fields will be present in the category entity indexes.

Sellable Item Search

The sellable item search has two configurations available. One without variants included in the search results, and one with variants.

Sellable Item without Variants Search

Sellable Item with Variants Search

Sitecore Experience Commerce: Promotion Evaluation and Application Logic

In this article, we will review the default business logic that the Commerce Engine utilises to evaluate and apply promotions.

Note: References to date will indicate both date and time throughout this article.

Introduction

Before we get into the details around promotions, there are a few things we need to understand.

    • Promotions are separated into cart line level and cart level promotions, determined by the promotion benefits configured to each promotion. While multiple benefits can be added to promotions, additional benefits after the first can only be of the same benefit type.
    • Cart line calculations (subtotals, fulfillment fees, promotion discounts, taxes, totals) are evaluated and applied prior to the and cart calculations.
      Sitecore.Commerce.Plugin.Carts
      ICalculateCartLinesPipeline (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.ClearCartLinesBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.ClearCartBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartLinesSubTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Fulfillment.CalculateCartLinesFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Promotions.CalculateCartLinesPromotionsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Tax.CalculateCartLinesTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartLinesTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
      -----------------------------------------------------------------
      Sitecore.Commerce.Plugin.Carts
      ICalculateCartPipeline (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartSubTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Fulfillment.CalculateCartFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Promotions.CalculateCartPromotionsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Tax.CalculateCartTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Payments.CalculateCartPaymentsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.WriteCartTotalsToContextBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
      
    • Exclusive promotions apply against the benefit type only, therefore it is possible to apply a cart line level exclusive promotion and a cart level exclusive promotion at the same time.

Evaluating Promotions

The following diagram shows the pipelines and pipeline blocks that are called during the process of evaluating the applicable promotions and additional filtering for exclusive promotion evaluation.

There are essentially 10 steps that make up the evaluation process:

  1. Search For Promotions: Retrieves all promotions.
  2. Filter Promotions By Valid Date: Removes promotions that do not fall within the Valid From/To dates based on effective date.
  3. Filter Not Approved Promotions: Removes promotions that are not approved and, if the promotion is disabled, where the effective date is prior to the updated date (the date the promotion was disabled). The latter rule is to allow the promotion to be active when reviewed the storefront at a previous point in time.
    Note: From 9.0.1, the GlobalPromotionsPolicy was introduced to allow promotions to be previewed in the storefront prior to submitting a promotion for approval.
  4. Filter Promotions By Items: Removes promotions where the cart contains no sellable items marked as included in the ItemsCollection qualification or where the cart contains any of the sellable items marked as excluded in the ItemsCollection qualification.
  5. Filter Promotions By Book Associated Catalogs: Removes promotions where the catalog, associated to its promotion book, does not match any of the catalogs associated to the sellable items of the cart lines.
  6. Filter Promotions By Benefit Type: Removes promotions where the type of benefits configured to the promotion does not match the benefit type being evaluated. i.e. Cart line level benefits (CartLineActions) and cart level benefits (CartActions) for CalculateCartLinesPipeline and CalculateCartLinesPipeline respectively.
  7. Filter Promotions By Coupon: Removes promotions that require a coupon that has not been applied to the cart.
  8. Evaluate Promotions: Filters out promotions where promotion qualifications and benefit rules do are not applicable to the current cart.
  9. Filter Promotions With Coupons By Exclusivity: If exclusive coupon promotions are present in the promotions list, the list will be filtered down to a single exclusive coupon promotion. The promotion will be determined by the Added date that their corresponding coupons were applied to the cart.
  10. Filter Promotions By Exclusivity: If exclusive automatic promotions are present in the promotions list, the list will be filtered down to a single exclusive automatic promotion. The promotion will be determined by the earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.

Promotion Priorisation Rules

While the previous section covered how promotions are evaluated, and also provided some insight into promotion priorisation, we will now cover the prioritisation rules.

The following diagram shows the logic used to determine which promotion(s) to apply to the cart.

There are essentially 3 steps that make up the application process:

  1. Apply a single exclusive automatic promotion.
    • The promotion will be determined by the earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.
    • If a promotion is applied here no further promotions are applied.
  2. Apply a single exclusive coupon promotion.
    • The promotion will be determined by the Added date that their corresponding coupons were applied to the cart.
    • If a promotion is applied here no further promotions are applied.
  3. Apply all non-exclusive promotions.
    • The promotion order will be determined by:
      1. Automatic promotions ordered by earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.
      2. Coupon Promotions ordered by earliest Added date that their corresponding coupons were applied to the cart.

References

Sitecore Experience Commerce: Exposing Variation Properties in the Variants Entity View

In this article, we review another small plugin created, which exposes the variation properties in the Variants entity view within the Merchandising Manager.

The Habitat catalog data has been created in such a way in which the variation properties are exposed via adding the variation property value to the variant display name, e.g. Habitat Dwell Bagless Upright Vacuum (Red). While this approach works to an extent, it will get more ugly as the variation properties grow, e.g. My Test Jeans (Blue, 32, slim).

The custom plugin will traverse over the variation's components to find the the properties that match the names provided in the VariationPropertiesPolicy and render them to the Variants entity view.

In the example above, I have specified the following property names, Color, Size, Style, and Length, which works well when all properties have been specified (see below).

A couple of things to note about the platform implementation:

  • Variation Properties apply to all Sellable Items globally. If a product contains a value against a property that is specified in the Variation Properties Policy it will render as a variant selection in the Storefront.
  • There is no validation to ensure that variation properties are mandatory for variants. This is because not all products will utilise these variation properties. I didn't think it was necessary to add more overhead to remove columns that contained no values.
  • You'll notice above that the first 2 variants of my Test Pants have matching variation properties. This is again due to having no validation in place, which I believe will break the storefront. So whether you have implemented a catalog import or are entering product data manually be careful not to double up.

Source Code: Ajsuth.Feature.Catalog.VariantProperties.Engine

Sitecore Experience Commerce: Configuring Variation Properties for the Storefront

In this article, we will review the process of configuring product variations properties so that the Product Variants SXA component will render them as dropdown selection controls. This controls allows the user to select the variant property combinations, where each combination represents individual product variants of a product family.

Configure the Variation Properties

In the Commerce Engine solution:

  1. Define all variation property names in the VariationPropertyPolicy of the environment configuration files.
    1. In this sample, we will add the Style property to the VariationPropertyPolicy.
      Note: The property names are the names of properties that exist on child components of the ItemVariationComponent. The fully qualified namespace for the property is not required as the SXA Storefront logic will use a property name match against the field names produced by the Catalog Generated templates.

      {
        "$type": "Sitecore.Commerce.Plugin.Catalog.VariationPropertyPolicy, Sitecore.Commerce.Plugin.Catalog",
        "PropertyNames": {
          "$type": "System.Collections.Generic.List`1[[System.String, mscorlib]], mscorlib",
          "$values": [
            "Color",
            "Size",
            "Style"
          ]
        }
      }
      
  2. Publish the solution and Bootstrap the Commerce Engine.
  3. Verify the variation property in the Storefront.
    Note: In the sample below I updated the sellable item's Style property with the value "Modern" for demo purposes.

Add Variant Specification Label

If you create add new properties that does not have corresponding label configured the variation property label will display as "[YourProperty]".

To resolve this, go to the Sitecore Content Editor:

  1. Add user friendly label value to /sitecore/Commerce/Commerce Control Panel/Storefront Settings/Commerce Terms/Variant Specification Labels.
  2. Publish the Sitecore Item and Reindex.

Sitecore Experience Commerce: Reordering Images on Sellable Items

In this article, we introduce a small custom plugin for Sitecore Commerce Business Tools that enables the reordering of images for sellable items and variants.

In its current release (XC 9.0.3), the only way to reorder images is to remove existing images and re-adding them in the desired order. It's not the best user experience and can take a considerable length of time to relocate the original images.

The custom catalog plugin adds 2 new actions to the Images actions - Move Image Up and Move Image Down.

The functionality is still a little clunky. The confirmation dialog shows each time an image is moved, the selected image will always reset to the first image after each action, and while the move buttons are not disabled for its outer limits, the code logic prevents out of boundary exceptions from being thrown. Overall, it is definitely an upgraded approach to reordering images.

Publishing the sellable item through workflow is still required in order to have the changes show in the storefront.

Source Code: Extended Sitecore Commerce Catalog project repository

Sitecore Commerce Class Templates for Visual Studio

To speed up development with Commerce Engine customisations, I have created a library of templates for Visual Studio. This should remove the necessity of referencing the sample commerce engine plugin project or decompiling and cleaning code from certain DLLs just to get to a starting point for your customisations.

The templates can be installed in Visual Studio via the Extensions and Updates or by downloading it from Visual Studio Marketplace: Sitecore Commerce Engine Templates.

Source Code: Sitecore Commerce Engine Class Templates

Configuring and Customising SEO Friendly URLs in Sitecore Commerce SXA Storefront

In this article, we will look at the configuration and customisation options available for manipulating URLs in the Sitecore Commerce Storefront.

The goal will be to determine how to manipulate the URLs so that they are more SEO-friendly as represented by the following URL structures.

  • https://{domain}/category/{1st level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}
  • https://{domain}/category/{1st level category name}/{2nd level category name}/{3rd level category name}
  • https://{domain}/product/{product id}

This would translate to the following examples

  • https://sxa.storefront.com/category/computers-and-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets
  • https://sxa.storefront.com/category/computers-and-tablets/kids-tablets/boys-tablets
  • https://sxa.storefront.com/product/6042221

Throughout this article we will utilise the category Computers and Tablets > Kid’s Tablets and product Minnow Kid’s Tablet—7”, 8GB to review our progress. These examples also contain some non-alphanumeric characters to ensure we take these special characters into consideration.

How Storefront URLs are Generated

Storefront URLs are constructed using the configuration of the site' s linkManager. The configuration is located at sitecore/linkManager/providers/add[@name='commerce'].

Configuration Properties

An important note about the provider configuration properties is that only 3 properties actually affect the generated URLs - includeFriendlyName, useShopLinks, and encodeNames.

  • includeFriendlyName: Includes the DisplayName of the category or product in the URL segment. i.e. {category DisplayName}={category FriendlyId} and {product DisplayName}={ProductId/FriendlyId}.
  • useShopLinks: Constructs URL with shop/{category}/{product} if enabled, otherwise as category/{category} and product/{product} for category and product URLs respectively.
  • includeCatalog: Not currently supported
  • addAspxExtension: N/A
  • alwaysIncludeServerUrl: N/A
  • encodeNames: Encodes the DisplayName portion of the category and product segments. Only supported when useShopLinks is true.
  • languageEmbedding: N/A
  • languageLocation: N/A
  • lowercaseUrls: Not currently supported
  • shortenUrls: Not currently supported
  • useDisplayName: Not currently supported

URLs Generated from Various Configurations

The following decision table shows the available configurations

 Rules
Conditions12345
useShopLinksYYYNN
includeFriendlyNameYYNYN
encodeNamesYN Y 
Actions12345
ShopXXX  
Product/Category   XX
Display Name prefixXX X 
Display Name encodingX  X 

The following table shows the URLs generated from the rules in the above table.

#PageURL
1Categoryhttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid%E2%80%99sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid%E2%80%99sTablet%E2%80%947%E2%80%9D%2C8GB%3d6042221
2Categoryhttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/Kid’sTablets%3dhabitat_master-kid%20s%20tablets/MinnowKid’sTablet—7”%2C8GB%3d6042221
3Categoryhttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/shop/habitat_master-kid%20s%20tablets/6042221
4Categoryhttps://sxa.storefront.com/category/Kid’sTablets%3dhabitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/MinnowKid’sTablet—7”%2C8GB%3d6042221
5Categoryhttps://sxa.storefront.com/category/habitat_master-kid%20s%20tablets
 Producthttps://sxa.storefront.com/product/6042221

In reviewing the results of the configurations above, rule set 5 creates the desired product URL structure we set out to accomplish.

Now we will focus on customising the website solution to generate our catalog URL structure.

Customising the Solution to Complete the SEO-Friendly URLs

Updating the CatalogUrlManager

We will need to override the BuildCategoryLink method of the CatalogUrlManager class to apply the following customisations:-

  • Generate the category hierarchy (or breadcrumb)
  • Remove the catalog name from the category's Friendly Id.
public override string BuildCategoryLink(Item item, bool includeCatalog, bool includeFriendlyName)
{
    return BuildBreadcrumbCategoryUrl(item, includeCatalog, includeFriendlyName, CatalogFoundationConstants.Routes.CategoryUrlRoute);
}

protected virtual string BuildBreadcrumbCategoryUrl(Item item, bool includeCatalog, bool includeFriendlyName, string root)
{
    Assert.ArgumentNotNull(item, nameof(item));

    string catalogName = ExtractCatalogName(item, includeCatalog);
    var categoryBreadcrumbList = GetCategoryBreadcrumbList(item);

    return BuildBreadcrumbCategoryUrl(categoryBreadcrumbList, includeFriendlyName, catalogName, root);
}

protected virtual string BuildBreadcrumbCategoryUrl(List<Item> categories, bool includeFriendlyName, string catalogName, string root)
{
    Assert.ArgumentNotNull(categories, nameof(categories));

    var stringBuilder = new StringBuilder("/");
    if (IncludeLanguage)
    {
        stringBuilder.Append(Context.Language.Name);
        stringBuilder.Append("/");
    }

    if (!string.IsNullOrEmpty(catalogName))
    {
        stringBuilder.Append(EncodeUrlToken(catalogName, true));
        stringBuilder.Append("/");
    }
    stringBuilder.Append(root);

    var itemName = string.Empty;
    var itemFriendlyName = string.Empty;
    foreach (var category in categories)
    {
        stringBuilder.Append("/");
        ExtractCatalogItemInfo(category, includeFriendlyName, out itemName, out itemFriendlyName);
        if (!string.IsNullOrEmpty(itemFriendlyName))
        {
            stringBuilder.Append(EncodeUrlToken(itemFriendlyName, true));
            stringBuilder.Append(UrlTokenDelimiterEncoded);
        }

        itemName = RemoveCatalogFromItemName(root, itemName);
        stringBuilder.Append(EncodeUrlToken(itemName, false));
    }

    return StorefrontContext.StorefrontUri(stringBuilder.ToString()).Path;
}

protected virtual List<Item> GetCategoryBreadcrumbList(Item item)
{
    var categoryBreadcrumbList = new List<Item>();
    var startNavigationCategoryID = StorefrontContext.CurrentStorefront.GetStartNavigationCategory();

    while (item.ID != startNavigationCategoryID)
    {
        categoryBreadcrumbList.Add(item);
        item = item.Parent;
    }
    categoryBreadcrumbList.Reverse();

    return categoryBreadcrumbList;
}

protected virtual string RemoveCatalogFromItemName(string root, string itemName)
{
    if (root == CatalogFoundationConstants.Routes.CategoryUrlRoute)
    {
        var tokens = itemName.Split('-');
        if (tokens.Length > 1)
        {
            itemName = tokens[1];
        }
    }

    return itemName;
}

protected override void ExtractCatalogItemInfo(Item item, bool includeFriendlyName, out string itemName, out string itemFriendlyName)
{
    base.ExtractCatalogItemInfo(item, includeFriendlyName, out itemName, out itemFriendlyName);
    var parentItemName = item.Parent.Name.ToLowerInvariant();
    if (itemName.StartsWith(parentItemName))
    {
        itemName = itemName.Substring(parentItemName.Length);
    }
}

Category URL: https://sxa.storefront.com/category/computers%20and%20tablets/kid%20s%20tablets

The URLs generated are looking good. We do have an issue with the URLs still containing encoded spaces. To control character encoding there is a configuration in the content editor at /sitecore/Commerce/Commerce Control Panel/Storefront Settings/Global Configuration > URL Encoding > Catalog Item Encoding.

Unfortunately there is a quirk in which it doesn't accept space entries, so we will override the EncodeUrlToken and DecodeUrlToken methods instead. As we aren't allowed to have hyphens in the category names, we won't have any character conflicts where encoding or decoding.

protected override string EncodeUrlToken(string urlToken, bool removeInvalidPathCharacters)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        if (removeInvalidPathCharacters)
        {
            foreach (string invalidPathCharacter in _invalidPathCharacters)
            {
                urlToken = urlToken.Replace(invalidPathCharacter, string.Empty);
            }
        }
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.Delimiter, t.EncodedDelimiter));
        urlToken = urlToken.Replace(' ', '-');
        urlToken = Uri.EscapeDataString(urlToken).Replace(UrlTokenDelimiter, EncodedDelimiter);
    }

    return urlToken;
}

protected override string DecodeUrlToken(string urlToken)
{
    if (!string.IsNullOrEmpty(urlToken))
    {
        urlToken = Uri.UnescapeDataString(urlToken).Replace(EncodedDelimiter, UrlTokenDelimiter);
        urlToken = urlToken.Replace('-', ' ');
        EncodingTokenList.ForEach(t => urlToken = urlToken.Replace(t.EncodedDelimiter, t.Delimiter));
    }

    return urlToken;
}

Category URL: https://sxa.storefront.com/category/computers-and-tablets/kid-s-tablets

Now that we have our category URL structure that meets our requirements, our last step is to ensure the URLs are resolving back to their correct Sitecore items.

Updating the CatalogPageItemResolver

Now we have covered the URL generation implementation, we now need to resolve these URLs back to their correct Sitecore items.

public override void Process(PipelineArgs args)
{
	if (Context.Item == null || SiteContext.CurrentCatalogItem != null)
	{
		return;
	}

	var contextItemType = GetContextItemType();
	switch (contextItemType)
	{
		case ItemTypes.Category:
		case ItemTypes.Product:
			var isProduct = contextItemType == ItemTypes.Product;
			var catalogItemIdFromUrl = GetCatalogItemIdFromUrl(isProduct);
			if (string.IsNullOrEmpty(catalogItemIdFromUrl))
			{
				break;
			}
			var catalog = StorefrontContext.CurrentStorefront.Catalog;
			var catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			if (catalogItem == null && !isProduct)
			{
				catalogItemIdFromUrl = GetCatalogItemIdFromUrl(true);
				if (string.IsNullOrEmpty(catalogItemIdFromUrl))
				{
					break;
				}
				catalogItem = ResolveCatalogItem(catalogItemIdFromUrl, catalog, isProduct);
			}
			if (catalogItem == null)
			{
				WebUtil.Redirect("~/");
			}

			SiteContext.CurrentCatalogItem = catalogItem;
		break;
	}
}

private string GetCatalogItemIdFromUrl(bool isProduct)
{
    var catalogItemId = string.Empty;
    var rawUrl = HttpContext.Current.Request.RawUrl;
    var urlTokens = rawUrl.Split('/');
    if (urlTokens.Any())
    {
        var item = urlTokens.Last();
        var queryStringPosition = item.IndexOf("?", StringComparison.OrdinalIgnoreCase);
        if (queryStringPosition > 0)
        {
            item = item.Substring(0, queryStringPosition);
        }

        if (isProduct && urlTokens.Length >= 4)
        {
            var parentCategoryName = urlTokens[urlTokens.Length - 2];
            item = $"{parentCategoryName}{item}";
        }
        catalogItemId = CatalogUrlManager.ExtractItemId(item);
    }

    return catalogItemId;
}

Summary

We learnt that the construction of the URL can be managed via Sitecore Configuration, the Sitecore Content Editor and via code customisations, depending on the URL requirements.

Source Code: Ajsuth.Foundation.Catalog