Try our conversational search powered by Generative AI!

Magnus Rahl
Mar 18, 2015
  7541
(5 votes)

Routing context aware links in Commerce catalog

Catalog entries in the Commerce catalog can be linked to multiple categories. For each such link there will be a corresponding hierarchical route based on the category structure (e.g. /catalog/t-shirts/batman and /catalog/spring-sale/batman). If the site navigation/listings are built from the catalog structure the entry can be found in multiple locations. However, each entry has a home location (the ParentLink of the Content) that will always be used to generate URLs to it. And even if a URL is created using the path of one of the alternate categories the entry detail renderer has no (readily accessible) information about which URL/route was used to find it.

Tracing the incoming route

Hierarchical routes to catalog content is handled by the HierarchicalCatalogPartialRouter class, which is usually registered by calling CatalogRouteHelper.MapDefaultHierarchialRouter in an initialization module. Let's create a derived class and store a breadcrumb of the routed path to the content:

public class BreadcrumbHierarchicalCatalogPartialRouter : HierarchicalCatalogPartialRouter
{
    public static readonly string BreadcrumbKey = "RoutedBreadcrumb";

    public BreadcrumbHierarchicalCatalogPartialRouter(
        Func<ContentReference> routeStartingPoint,
        CatalogContentBase commerceRoot)
        : base(routeStartingPoint,
            commerceRoot,
            false,
            ServiceLocator.Current.GetInstance<IContentLoader>(),
            ServiceLocator.Current.GetInstance<LanguageSelectorFactory>(),
            ServiceLocator.Current.GetInstance<IRoutingSegmentLoader>(),
            ServiceLocator.Current.GetInstance<IContentVersionRepository>())
    {
    }

    protected override CatalogContentBase GetCatalogContentRecursive(CatalogContentBase catalogContent,
        SegmentPair segmentPair, SegmentContext segmentContext, ILanguageSelector languageSelector)
    {
        if (catalogContent != null)
        {
            AddBreadcrumbData(segmentContext, catalogContent.ContentLink);
        }
        return base.GetCatalogContentRecursive(catalogContent, segmentPair, segmentContext, languageSelector);
    }

    private void AddBreadcrumbData(SegmentContext segmentContext, ContentReference contentLink)
    {
        var breadcrumb = segmentContext.GetCustomRouteData<IList<ContentReference>>(BreadcrumbKey);
        if (breadcrumb == null)
        {
            breadcrumb = new List<ContentReference>();
            segmentContext.SetCustomRouteData(BreadcrumbKey, breadcrumb);
        }
        breadcrumb.Add(contentLink);
    }
}

We also need to register this instead of using CatalogRouteHelper.MapDefaultHierarchialRouter:

[ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public class InitializationModule : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        MapRoutes(RouteTable.Routes);
    }

    private static void MapRoutes(RouteCollection routes)
    {
        //CatalogRouteHelper.MapDefaultHierarchialRouter(routes, false);
        var referenceConverter = ServiceLocator.Current.GetInstance<ReferenceConverter>();
        var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
        var commerceRootContent = contentLoader.Get<CatalogContentBase>(referenceConverter.GetRootLink());
        routes.RegisterPartialRouter(new BreadcrumbHierarchicalCatalogPartialRouter(() => SiteDefinition.Current.StartPage, commerceRootContent));
    }

    public void Preload(string[] parameters)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}

So what this does is to store the ContentLink of every content it resolves as it is recursing the route segments, and stores them in a list on the route context. This list can then be accessed in a renderer through HttpContext.Current.Request.RequestContext. I added this method to a WebForm base class to illustrate how to access the parent (the last item is the routed content itself, so the routed parent is the second to last):

protected ContentReference GetRoutedParentLink()
{
    var breadcrumb = HttpContext.Current.Request.RequestContext
        .GetCustomRouteData<IList<ContentReference>>(BreadcrumbHierarchicalCatalogPartialRouter.BreadcrumbKey);

    if (breadcrumb != null && breadcrumb.Count > 1)
    {
        return breadcrumb.Reverse().Skip(1).First();
    }

    if (CurrentData != null)
    {
        return CurrentData.ParentLink;
    }

    return CurrentPage.ParentLink;
}

If our custom router is the active one, when the batman t-shirt from the initial example is rendered, we will be able to easily see if it was routed to using /catalog/spring-sale/batman instead of its home location, and for example promote other items from the spring-sale category in the product view.

Controlling how outgoing links are rendered

The /catalog/spring-sale/batman route is active but unless we do something special any link rendered (e.g. using UrlResolver) to the batman t-shirt will still use the /catalog/t-shirts/batman URL. To be able to control how the link is created we add another override to our router:

public static readonly string PreferredParentKey = "PreferredParentLink";

public override PartialRouteData GetPartialVirtualPath(CatalogContentBase content, string language, RouteValueDictionary routeValues, RequestContext requestContext)
{
    if (!IsValidRoutedContent(content))
    {
        return null;
    }

    if (EnableOutgoingSeoUri && requestContext.GetContextMode() == ContextMode.Default)
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var preferredParent = routeValues[PreferredParentKey] as ContentReference;
    routeValues.Remove(PreferredParentKey);
            
    if (preferredParent == null)
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();

    if (!contentLoader.GetChildren<CatalogContentBase>(preferredParent)
        .Any(c => c.ContentLink.CompareToIgnoreWorkID(content.ContentLink)))
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var parentContent = contentLoader.Get<CatalogContentBase>(preferredParent);

    string virtualPath;
    if (!TryGetVirtualPath(requestContext.HttpContext, parentContent, language, out virtualPath))
    {
        return null;
    }

    virtualPath = virtualPath.EndsWith("/")
        ? virtualPath + content.RouteSegment
        : virtualPath + "/" + content.RouteSegment;

    var segmentUrl = SegmentHelper.GetModifiedVirtualPathInEditOrPreviewMode(content.ContentLink, virtualPath, requestContext.GetContextMode());
    return new PartialRouteData { BasePathRoot = RouteStartingPoint, PartialVirtualPath = segmentUrl };
}

The beginning of this method is just a number of checks I had to duplicate from the base class. And of course you should rework the constructors to take the IContentLoader as a dependency instead of fetching it from the ServiceLocator. In addition to this, the base implementation caches the created urls in context.Items to reuse them througout the request lifetime, which would probably be a good idea here too (varying the cache key on contentlink, language and preferred parent).

But other than that the logic is simple: If there's a preferred parent supplied, see if the routed content is actually a child of that content and in that case create a URL based on that parent instead. In our example it would get the URL to the spring-sale category, /catalog/spring-sale and append batman to get the /catalog/spring-sale/batman URL we want.

To get the preferred parent to the UrlResolver, use the VirtualPathArguments in the call like in this helper method:

protected string GetUrl(CatalogContentBase content, ContentReference preferredParent)
{
    var args = new VirtualPathArguments {RouteValues = new RouteValueDictionary()};
    if (preferredParent != null)
    {
        args.RouteValues[BreadcrumbHierarchicalCatalogPartialRouter.PreferredParentKey] = preferredParent;
    }

    return content != null ?
        VirtualPathUtilityEx.ToAbsolute(UrlResolver.Current.GetUrl(content.ContentLink, content.Language.Name, args)) :
        string.Empty;
}

(Again, you probably want to get the UrlResolver injected to remove the static call).

Using the MVC Html.ContentLink helper you should be able to pass the preferred parent in the routeValues argument.

Summary

With all these parts in place you should be able to create an experience where:

  • When the user enters the sale category and moves on to a product, you can display the product with a link/breadcrumb back to the sale and promote other sale items directly in the product view.
  • When the user enters through another of the product's parent categories you can display a link/breadcrumb back to that category and other related products/information that are more relevant in that context.

And a final tip: If you do this you should consider canonical links to your content. I guess the strategy for that might vary depending on how much your views vary by the routed parent.

Mar 18, 2015

Comments

K Khan
K Khan Mar 19, 2015 12:55 PM

Bundle of Thanks :)

Brian Weeteling
Brian Weeteling Feb 20, 2019 09:46 PM

Thanks Magnus. We're using (parts of) this in some of our projects as well. Be careful with the code for outgoing links as it caused us some performance issues: https://www.brianweet.com/2019/02/20/multi-site-catalog-performance-regression.html

Please login to comment.
Latest blogs
Headless forms reloaded (beta)

Forms is used on the vast majority of CMS installations. But using Forms in a headless setup is a bit of pain since the rendering pipeline is based...

MartinOttosen | Mar 1, 2024

Uploading blobs to Optimizely DXP via PowerShell

We had a client moving from an On-Prem v11 Optimizely instance to DXP v12 and we had a lot of blobs (over 40 GB) needing uploading to DXP as a part...

Nick Hamlin | Mar 1, 2024 | Syndicated blog

DbLocalizationProvider v8.0 Released

I’m pleased to announce that Localization Provider v8.0 is finally out.

valdis | Feb 28, 2024 | Syndicated blog

Epinova DXP deployment extension – With Octopus deploy

Example how you can use Epinova DXP deployment extension in Octopus deployment.

Ove Lartelius | Feb 28, 2024 | Syndicated blog

Identify Azure web app instance id's for an Optimizely CMS site

When running Optimizely CMS in Azure, you will be using an instance bound cloud license. What instances are counted, and how can you check them? Le...

Tomas Hensrud Gulla | Feb 27, 2024 | Syndicated blog

Introducing Image Transformer - AI Assistant for Optimizely

We've got something super cool to share with you, and it's all about giving your images a fresh spin. Image Transformer, the latest feature from ou...

Luc Gosso (MVP) | Feb 26, 2024 | Syndicated blog