Magnus Rahl
Mar 18, 2015
  7781
(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
Streamlining Marketing Success: The Benefits for Optimizely One with Perficient

As an Optimizely expert, I eagerly anticipate this time of year due to the exciting Optimizely events happening worldwide. These include Opticon, t...

Alex Harris - Perficient | Sep 17, 2024 | Syndicated blog

Creating an Optimizely Addon - Packaging for NuGet

In   Part One   and   Part Two   of this series; I covered topics from having a great idea, solution structure, extending the menus and adding...

Mark Stott | Sep 16, 2024

Optimizely CMS and weekly updates

Learn how reporting bugs in Optimizely CMS not only helps improve the platform but also benefits you and the entire user community.

Tomas Hensrud Gulla | Sep 12, 2024 | Syndicated blog

Introduce the ablility to select then delete items manually on FIND UI

In FIND 16.3.0 we introduce an ability to select items and delete them manually, it will helps you to delete unexpected items from the UI without a...

Manh Nguyen | Sep 12, 2024

The composable consulting model our industry needs

The architecture of a modern consulting business is ‘composable’. Certainly, we think of ourselves a composable consulting business and have done...

Mark Everard | Sep 12, 2024 | Syndicated blog

Keynote Summary from Opticon 2024, Stockholm

At Opticon in Stockholm, marking the 30th anniversary of Optimizely, the company celebrated significant achievements. These included surpassing $40...

Luc Gosso (MVP) | Sep 11, 2024 | Syndicated blog