<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blog posts by Sanjay Katiyar</title><link href="http://world.optimizely.com" /><updated>2025-11-28T12:17:41.0000000Z</updated><id>https://world.optimizely.com/blogs/sanjay-katiyar/</id> <generator uri="http://world.optimizely.com" version="2.0">Optimizely World</generator> <entry><title>Using Okta and OpenID Connect with Optimizely CMS 12</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2025/11/using-okta-and-openid-connect-with-optimizely-cms-12/" /><id>&lt;p&gt;Modern CMS solutions rarely live in isolation. Your editors already log into other systems with SSO, and they expect the same from Optimizely CMS. In this post, we&amp;rsquo;ll look at how to integrate Okta and OpenID Connect into an Optimizely CMS 12 site, using a real-world implementation.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;ll cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Wiring Okta into ASP.NET Core authentication&lt;/li&gt;
&lt;li&gt;Enabling/disabling Okta per environment via configuration&lt;/li&gt;
&lt;li&gt;Mapping Okta claims to Optimizely-friendly identities&lt;/li&gt;
&lt;li&gt;Handling login, logout, and post-logout flows&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;1. Configuration: feature flag + Okta settings&lt;/strong&gt;&lt;br /&gt;First, the site treats Okta as a feature, controlled by configuration. In appsettings.Development.json, you&amp;rsquo;ll see a dedicated Okta section:&lt;/p&gt;
&lt;pre class=&quot;language-python&quot;&gt;&lt;code&gt;&quot;Okta&quot;: {
  &quot;Enabled&quot;: false,
  &quot;Domain&quot;: &quot;&quot;,
  &quot;ClientId&quot;: &quot;&quot;,
  &quot;ClientSecret&quot;: &quot;&quot;
},&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Enabled: a simple on/off switch per environment&lt;/li&gt;
&lt;li&gt;Domain, ClientId, ClientSecret: the standard Okta OIDC settings&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In &lt;strong&gt;Startup.cs, &lt;/strong&gt;these values decide whether the site runs with Okta SSO or falls back to a simple local admin registration:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;bool.TryParse(_configuration[&quot;okta:Enabled&quot;], out bool oktaEnabled);
if (oktaEnabled)
{
    services.SetupOkta(_configuration, syncUser: SyncUseDetails);
}
else
{
    services.AddAdminUserRegistration(x =&amp;gt; x.Behavior = EPiServer.Cms.Shell.UI.RegisterAdminUserBehaviors.SingleUserOnly);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   private static void SyncUserDetails(ClaimsIdentity identity)
   {
       ServiceLocator.Current.GetInstance&amp;lt;ISynchronizingUserService&amp;gt;().SynchronizeAsync(identity);
       Infrastructure.Async.AsyncHelper.RunSync(async () =&amp;gt;
       {
           //TODO :  SyncUserProfile(identity);
           //TODO :  SyncRolesAsync(identity);
       });
   }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;This pattern makes it easy to run local/dev environments without needing full Okta wiring, while production gets full SSO.&lt;br /&gt;&lt;br /&gt;&lt;strong&gt;2. Wiring Okta + cookies into ASP.NET Core authentication&lt;/strong&gt;&lt;br /&gt;The core of the integration lives in an extension method on IServiceCollection, which sets up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cookie authentication as the primary auth scheme&lt;/li&gt;
&lt;li&gt;Okta MVC as the OpenID Connect challenge handler&lt;/li&gt;
&lt;li&gt;Custom login/logout paths&lt;/li&gt;
&lt;li&gt;Claim mapping and error handling&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public static class IServiceCollectionExtensions
    {
        public static IServiceCollection SetupOkta(this IServiceCollection services,
            IConfiguration configuration,
            Action&amp;lt;ClaimsIdentity&amp;gt; syncUser)
        {
            services
                .ConfigureApplicationCookie(options =&amp;gt;
                {
                    options.LoginPath = new PathString(&quot;/account/login&quot;);
                    options.LogoutPath = &quot;/account/logout&quot;;
                })
                .AddAuthentication(options =&amp;gt;
                {
                    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                    options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
                })
                .AddCookie(x =&amp;gt;
                {
                    x.SlidingExpiration = true;
                    x.Cookie.HttpOnly = true;
                    x.ExpireTimeSpan = TimeSpan.FromMinutes(30);
                })
                .AddOktaMvc(new OktaMvcOptions
                {
                    OktaDomain = configuration[&quot;okta:Domain&quot;],
                    ClientId = configuration[&quot;okta:ClientId&quot;],
                    ClientSecret = configuration[&quot;okta:ClientSecret&quot;],
                    Scope = new List&amp;lt;string&amp;gt; { &quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;, &quot;groups&quot; },
                    PostLogoutRedirectUri = &quot;/account/postlogout&quot;,
                    GetClaimsFromUserInfoEndpoint = true,
                    OpenIdConnectEvents = new OpenIdConnectEvents
                    {
                        OnTokenValidated = async (ctx) =&amp;gt;
                        {
                            if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
                            {
                                if (claimsIdentity != null)
                                {
                                    var authClaimsIdentity = MappedClaims(ctx.Principal);
                                    syncUser?.Invoke(authClaimsIdentity);
                                    ctx.Principal = new ClaimsPrincipal(authClaimsIdentity);
                                }
                            }
                        },
                        OnRemoteFailure = context =&amp;gt;
                        {
                            if (context.Failure is OpenIdConnectProtocolException oidcException)
                            {
                                var log = LogManager.GetLogger();
                                log.Error($&quot;Remote login OpenIdConnectProtocolException: {Uri.EscapeDataString(oidcException.Message)}&quot;);
                                context.HandleResponse();
                                context.Response.Redirect(&quot;/account/login?error=authentication_failed&quot;); 
                            }
                            else if (context.Failure is Exception e)
                            {
                                var log = LogManager.GetLogger();
                                log.Error($&quot;Remote login Exception: {Uri.EscapeDataString(e.Message)}&quot;);
                                context.HandleResponse();
                               context.Response.Redirect(&quot;/account/login?error=authentication_failed&quot;); 
                            }

                            return Task.CompletedTask;
                        }
                    }
                });

            services.PostConfigureAll&amp;lt;OpenIdConnectOptions&amp;gt;(options =&amp;gt;
            {
                if (options.TokenValidationParameters != null)
                {
                    options.TokenValidationParameters.RoleClaimType = &quot;optimizelyGroups&quot;;
                    options.TokenValidationParameters.NameClaimType = &quot;email&quot;;
                }
            });
            return services;
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key Points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cookies remain the primary session mechanism inside the CMS, while Okta handles sign-in via OIDC.&lt;/li&gt;
&lt;li&gt;DefaultChallengeScheme is OpenIdConnectDefaults.AuthenticationScheme, so any Challenge() will redirect to Okta.&lt;/li&gt;
&lt;li&gt;Scope includes &quot;groups&quot;, which is handy for mapping Okta groups into Optimizely roles.&lt;/li&gt;
&lt;li&gt;PostLogoutRedirectUri is handled by an MVC action (shown below).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The PostConfigureAll&amp;lt;OpenIdConnectOptions&amp;gt; step ensures that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RoleClaimType is optimizelyGroups (matching how Optimizely expects role data)&lt;/li&gt;
&lt;li&gt;NameClaimType is email, which is typically what you want in corporate setups&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3. Mapping Okta claims to Optimizely-friendly identities&lt;/strong&gt;&lt;br /&gt;Rather than using Okta&amp;rsquo;s raw claim set, the site reshapes claims into something friendlier for Optimizely and downstream services.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; private static ClaimsIdentity MappedClaims(ClaimsPrincipal claimsPrincipal)
 {
            var authClaimsIdentity = new ClaimsIdentity(claimsPrincipal.Claims, claimsPrincipal?.Identity?.AuthenticationType, &quot;sub&quot;, ClaimTypes.Role);
            var name = claimsPrincipal?.Claims?.FirstOrDefault(x =&amp;gt; x.Type == &quot;name&quot;)?.Value;
            var email = claimsPrincipal?.Claims?.FirstOrDefault(x =&amp;gt; x.Type == &quot;email&quot;)?.Value;
            var userId = claimsPrincipal?.Claims?.FirstOrDefault(x =&amp;gt; x.Type == &quot;sub&quot;)?.Value ?? email;
            var nameAry = name?.Split(&quot; &quot;, StringSplitOptions.RemoveEmptyEntries);

            if (nameAry != null &amp;amp;&amp;amp; nameAry.Length &amp;gt; 0 &amp;amp;&amp;amp; !string.IsNullOrEmpty(userId) &amp;amp;&amp;amp; !string.IsNullOrEmpty(email))
            {
                var givenName = nameAry[0];
                var surName = string.Empty;
                if (nameAry.Length &amp;gt;= 2)
                {
                    surName = nameAry[1];
                }
                else
                {
                    surName = nameAry[2];
                }
                authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Name, userId));
                authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Email, email));
                authClaimsIdentity.AddClaim(new Claim(ClaimTypes.GivenName, givenName));
                authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Surname, surName));
            }
            return authClaimsIdentity;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chooses a stable userId (subject or email)&lt;/li&gt;
&lt;li&gt;Parses the Okta name claim into first/last name&lt;/li&gt;
&lt;li&gt;Adds standard ClaimTypes (Name, Email, GivenName, Surname) that are widely used across .NET and Optimizely APIs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You also get a hook via the syncUser delegate passed into SetupOkta, so you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create/update users in the Optimizely database&lt;/li&gt;
&lt;li&gt;Sync roles based on Okta groups&lt;/li&gt;
&lt;li&gt;Apply custom profile logic whenever a user logs in&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;4.MVC endpoints for login and logout&lt;/strong&gt;&lt;br /&gt;On the MVC side, login and logout are kept intentionally simple.&lt;br /&gt;Login is just a challenge to the Okta MVC scheme if the user isn&amp;rsquo;t already authenticated:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;  public IActionResult Login()
  {
      var userIdentity = HttpContext.User.Identity;
      if (userIdentity == null || !userIdentity.IsAuthenticated)
       {
         return Challenge(OktaDefaults.MvcAuthenticationScheme);
       }
      return Redirect(&quot;/&quot;);
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;Logout signs out of your local app session and, if appropriate, from Okta/OpenID Connect as well:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public IActionResult Logout()
{
            _userService.SignOut();
            var userIdentity = HttpContext.User.Identity;
            if (userIdentity != null &amp;amp;&amp;amp;
                userIdentity.IsAuthenticated &amp;amp;&amp;amp;
                !string.Equals(&quot;Identity.Application&quot;, userIdentity.AuthenticationType))
            {
                return new SignOutResult(
                    new[] { CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme },
                    new AuthenticationProperties { RedirectUri = &quot;/&quot; });
            }

            return Redirect(&quot;/&quot;);
}

public IActionResult PostLogout()
{
    return Redirect(&quot;/&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The PostLogout action matches the PostLogoutRedirectUri configured in OktaMvcOptions, keeping the entire sign-out flow under your control.&lt;br /&gt;&lt;br /&gt;&lt;strong&gt;5. Optimizely&amp;rsquo;s own OpenID Connect:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Finally:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;      services.AddOpenIDConnect&amp;lt;SiteUser&amp;gt;(
                useDevelopmentCertificate: _webHostingEnvironment.IsDevelopment(),
                signingCertificate: null,
                encryptionCertificate: null,
                createSchema: true);

        services.AddOpenIDConnectUI();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This combination gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Okta-based SSO for users in the CMS UI&lt;/li&gt;
&lt;li&gt;Optimizely&amp;rsquo;s own OIDC infrastructure for API access, headless clients, and integration scenarios&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Using configuration to toggle Okta per environment,&lt;/li&gt;
&lt;li&gt;Wiring Okta + cookies into ASP.NET Core authentication,&lt;/li&gt;
&lt;li&gt;Mapping claims into Optimizely-friendly identities,&lt;/li&gt;
&lt;li&gt;And keeping login/logout flows simple and explicit,&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You get a robust, testable, and production-ready Okta + OpenID Connect integration for Optimizely CMS 12.&lt;/p&gt;</id><updated>2025-11-28T12:17:41.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Operational observability using application insights to trace checkout end to end</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2025/11/operational-observability-using-application-insights-to-trace-checkout-end-to-end/" /><id>&lt;p&gt;You can&amp;rsquo;t fix what you can&amp;rsquo;t see. In a modern, distributed e‑commerce system, the checkout flow touches multiple services from the front-end UI to backend APIs, payment gateways, and inventory systems. Failures, performance issues, or unexpected latencies often hide in the gaps between these components.&lt;/p&gt;
&lt;p&gt;This is where &lt;strong&gt;operational observability&lt;/strong&gt; becomes critical. By leveraging &lt;strong&gt;Azure Application Insights&lt;/strong&gt; together with your existing structured logging, you can achieve actionable, end-to-end visibility into every checkout transaction.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;What&amp;nbsp;to&amp;nbsp;trace&lt;/strong&gt;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo1; tab-stops: list .5in;&quot;&gt;Page/controller&amp;nbsp;actions: enter/exit, validation&amp;nbsp;failures.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l1 level1 lfo2; tab-stops: list .5in;&quot;&gt;Payment&amp;nbsp;orchestration: gateway&amp;nbsp;called, result, latency, timeouts.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l3 level1 lfo3; tab-stops: list .5in;&quot;&gt;Business&amp;nbsp;events: cart&amp;nbsp;created, booking&amp;nbsp;info&amp;nbsp;saved, order&amp;nbsp;created.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l2 level1 lfo4; tab-stops: list .5in;&quot;&gt;Correlation keys: cartReference, paymentMethod, user/device attributes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Let&#39;s Configure and Code&lt;/strong&gt;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo1; tab-stops: list .5in;&quot;&gt;Application Insights is configured:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;ApplicationInsights&quot;:{
  &quot;ConnectionString&quot;: &quot;InstrumentationKey={my_key};IngestionEndpoint= {applicationinsights_endpoint}&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;span style=&quot;font-size: 11.0pt; line-height: 115%; font-family: &#39;Calibri&#39;,sans-serif; mso-ascii-theme-font: minor-latin; mso-fareast-font-family: Calibri; mso-fareast-theme-font: minor-latin; mso-hansi-theme-font: minor-latin; mso-bidi-font-family: &#39;Times New Roman&#39;; mso-bidi-theme-font: minor-bidi; mso-ansi-language: EN-US; mso-fareast-language: EN-US; mso-bidi-language: AR-SA;&quot;&gt;Checkout logs key breadcrumbs with a cart reference (great for correlation)&lt;br /&gt;&lt;strong&gt;Note:&lt;/strong&gt; In this implementation, cartReference is used as a meta‑key to identify and track each cart / checkout session across telemetry. You are free to replace it with any custom key / value that suits your domain model (e.g., orderId, sessionId, checkoutId).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;&lt;span style=&quot;font-size: 11.0pt; line-height: 115%; font-family: &#39;Calibri&#39;,sans-serif; mso-ascii-theme-font: minor-latin; mso-fareast-font-family: Calibri; mso-fareast-theme-font: minor-latin; mso-hansi-theme-font: minor-latin; mso-bidi-font-family: &#39;Times New Roman&#39;; mso-bidi-theme-font: minor-bidi; mso-ansi-language: EN-US; mso-fareast-language: EN-US; mso-bidi-language: AR-SA;&quot;&gt;Entry:&lt;/span&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;_fluentLogger
    .Data((&quot;CartReference&quot;, cart.GetCartReference()),
          (&quot;BookingInfo&quot;, model.BookingInfo))
    .LogInfo(&quot;Booking calendar&quot;);
return View(GetViewPath(currentPage), model);
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;&lt;span style=&quot;font-size: 11.0pt; line-height: 115%; font-family: &#39;Calibri&#39;,sans-serif; mso-ascii-theme-font: minor-latin; mso-fareast-font-family: Calibri; mso-fareast-theme-font: minor-latin; mso-hansi-theme-font: minor-latin; mso-bidi-font-family: &#39;Times New Roman&#39;; mso-bidi-theme-font: minor-bidi; mso-ansi-language: EN-US; mso-fareast-language: EN-US; mso-bidi-language: AR-SA;&quot;&gt;Exit:&lt;/span&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;_fluentLogger
    .Data((nameof(checkoutBookingInfo), checkoutBookingInfo),
          (&quot;CartReference&quot;, cart.GetCartReference()))
    .LogInfo(&quot;Booking POST&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Add&amp;nbsp;rich&amp;nbsp;telemetry&amp;nbsp;with&amp;nbsp;correlation&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Use cartReference as your primary correlation property across logs, traces, and metrics. If you&amp;rsquo;re already using ILogger (or Serilog) with AI export, enrich the scope; otherwise, send custom events via TelemetryClient.New code (example pattern):&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.DataContracts;

public class CheckoutTelemetryService
{
    private readonly TelemetryClient _telemetryClient;

    public CheckoutTelemetryService(TelemetryClient telemetryClient)
    {
        _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient));
    }

    /// &amp;lt;summary&amp;gt;
    /// Tracks a specific step in the checkout process.
    /// &amp;lt;/summary&amp;gt;
    /// &amp;lt;param name=&quot;stepName&quot;&amp;gt;Name of the checkout step (e.g., &quot;cartValidation&quot;).&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;cartReference&quot;&amp;gt;Unique identifier for the cart.&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;properties&quot;&amp;gt;Optional custom properties.&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;durationMs&quot;&amp;gt;Optional duration of the step in milliseconds.&amp;lt;/param&amp;gt;
    public void TrackCheckoutStep(
        string stepName, 
        string cartReference, 
        IDictionary&amp;lt;string, string&amp;gt;? properties = null, 
        double? durationMs = null)
    {
        var telemetryEvent = new EventTelemetry($&quot;checkout_{stepName}&quot;);
        telemetryEvent.Properties[&quot;cartReference&quot;] = cartReference;

        if (properties != null)
        {
            foreach (var kvp in properties)
            {
                telemetryEvent.Properties[kvp.Key] = kvp.Value;
            }
        }

        if (durationMs.HasValue)
        {
            telemetryEvent.Metrics[&quot;duration_ms&quot;] = durationMs.Value;
        }

        _telemetryClient.TrackEvent(telemetryEvent);
    }

    /// &amp;lt;summary&amp;gt;
    /// Starts a dependency telemetry operation, such as payment or inventory check.
    /// &amp;lt;/summary&amp;gt;
    /// &amp;lt;param name=&quot;dependencyName&quot;&amp;gt;Name of the dependency operation.&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;cartReference&quot;&amp;gt;Cart reference ID.&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;dependency&quot;&amp;gt;Outputs the created DependencyTelemetry object.&amp;lt;/param&amp;gt;
    /// &amp;lt;returns&amp;gt;IDisposable to stop the operation when disposed.&amp;lt;/returns&amp;gt;
    public IDisposable StartDependencyOperation(
        string dependencyName, 
        string cartReference, 
        out DependencyTelemetry dependency)
    {
        dependency = new DependencyTelemetry(&quot;checkoutDependency&quot;, dependencyName, DateTimeOffset.UtcNow, TimeSpan.Zero, success: true);
        dependency.Properties[&quot;cartReference&quot;] = cartReference;

        return new TelemetryOperation(_telemetryClient, dependency);
    }

    /// &amp;lt;summary&amp;gt;
    /// Helper class to manage the lifetime of telemetry operations.
    /// &amp;lt;/summary&amp;gt;
    private sealed class TelemetryOperation : IDisposable
    {
        private readonly TelemetryClient _telemetryClient;
        private readonly IOperationHolder&amp;lt;DependencyTelemetry&amp;gt; _operationHolder;

        public TelemetryOperation(TelemetryClient telemetryClient, DependencyTelemetry telemetry)
        {
            _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient));
            _operationHolder = _telemetryClient.StartOperation(telemetry);
        }

        public void Dispose()
        {
            _telemetryClient.StopOperation(_operationHolder);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Use it in checkout/payment paths:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var cartRef = cart.GetCartReference();
_checkoutTelemetry.TrackCheckoutStep(&quot;shipping_viewed&quot;, cartRef, new Dictionary&amp;lt;string,string&amp;gt; {
    [&quot;device&quot;] = Request.Headers[&quot;User-Agent&quot;].ToString().Contains(&quot;Mobile&quot;) ? &quot;mobile&quot; : &quot;desktop&quot;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Around a payment call&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using (_checkoutTelemetry.StartDependencyOperation(&quot;bank_charge&quot;, cartRef, out var dep))
{
    var sw = Stopwatch.StartNew();
    var result = await _paymentService.ProcessBankCheckoutAsync(chargeId, transactionId, cartRef);
    sw.Stop();
    dep.Duration = sw.Elapsed;
    dep.Success = result.Success;
    dep.Properties[&quot;paymentMethod&quot;] = &quot;CreditCard&quot;;
    dep.Properties[&quot;timeout&quot;] = result.Timeout.ToString();
    _checkoutTelemetry.TrackStep(&quot;payment_processed&quot;, cartRef, new Dictionary&amp;lt;string,string&amp;gt;{
        [&quot;paymentMethod&quot;] = &quot;CreditCard&quot;,
        [&quot;errorType&quot;] = result.ErrorType.ToString()
    }, sw.Elapsed.TotalMilliseconds);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Kusto queries (copy‑paste in Logs) examples&lt;/strong&gt;:&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo1; tab-stops: list .5in;&quot;&gt;End‑to‑end for a single cart:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-ruby&quot;&gt;&lt;code&gt;union traces, customEvents, dependencies, requests
| extend cartReference = tostring(customDimensions.cartReference)
| where isnotempty(cartReference) and cartReference == &quot;&amp;lt;CART_REF&amp;gt;&quot;
| project timestamp, itemType = itemType, name, message, resultCode, success, duration, cartReference, customDimensions
| order by timestamp asc
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo1; tab-stops: list .5in;&quot;&gt;Payment gateway health:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-ruby&quot;&gt;&lt;code&gt;dependencies
| where type == &quot;payment&quot;
| summarize count(), failures = countif(success == false), p95=percentile(duration,95) by name, bin(timestamp, 5m)
| extend failureRate = todouble(failures) / todouble(count())
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo1; tab-stops: list .5in;&quot;&gt;Checkout bottlenecks&amp;nbsp;by&amp;nbsp;step:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-ruby&quot;&gt;&lt;code&gt;customEvents
| where name startswith &quot;checkout_&quot;
| summarize p95=percentile(todouble(tostring(customMeasurements[&quot;duration_ms&quot;])),95)
          , count() by name
| order by p95 desc
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Guardrails&lt;/strong&gt;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l5 level1 lfo1; tab-stops: list .5in;&quot;&gt;Don&amp;rsquo;t&amp;nbsp;log&amp;nbsp;PII, whitelist&amp;nbsp;properties. Treat&amp;nbsp;cartReference&amp;nbsp;as&amp;nbsp;non‑PII.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l1 level1 lfo2; tab-stops: list .5in;&quot;&gt;Sample high‑volume&amp;nbsp;events&amp;nbsp;if needed; never drop&amp;nbsp;error&amp;nbsp;paths.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l3 level1 lfo3; tab-stops: list .5in;&quot;&gt;Secure the AI connection&amp;nbsp;string&amp;nbsp;via&amp;nbsp;secrets/Key Vault; avoid plain text in&amp;nbsp;config.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Outcomes&lt;/strong&gt;&lt;/p&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l0 level1 lfo4; tab-stops: list .5in;&quot;&gt;Fast root&amp;nbsp;cause: correlate&amp;nbsp;a&amp;nbsp;customer&amp;rsquo;s failed&amp;nbsp;payment&amp;nbsp;through&amp;nbsp;controller, dependency, and&amp;nbsp;custom&amp;nbsp;events.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l2 level1 lfo5; tab-stops: list .5in;&quot;&gt;Trend insights: detect&amp;nbsp;gateway&amp;nbsp;degradation and&amp;nbsp;timeouts&amp;nbsp;before&amp;nbsp;conversion&amp;nbsp;drops.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;margin-top: 0in;&quot;&gt;
&lt;li class=&quot;MsoNormal&quot; style=&quot;mso-list: l4 level1 lfo6; tab-stops: list .5in;&quot;&gt;Measurable improvements: reducing MTTR (Mean Time To Resolve/Recover) and payment‑related abandonment with targeted fixes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;</id><updated>2025-11-19T10:28:13.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Automated Page Audit for Large Content Sites in Optimizely</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2025/10/content-management-audit-system-a-comprehensive-guide/" /><id>&lt;p&gt;Large content sites often face significant challenges in maintaining visibility and control over thousands of pages. Content managers struggle to track publishing status, identify outdated or unpublished content, and ensure overall content quality. Without automation, auditing becomes time-consuming, error-prone, and inefficient, creating risks in governance, compliance, and user experience.&lt;/p&gt;
&lt;p&gt;Managing content at enterprise scale is difficult because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Limited Visibility&lt;/strong&gt; &amp;ndash; No consolidated view of all site content.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Publishing Confusion&lt;/strong&gt; &amp;ndash; Hard to track what is published, unpublished, or pending.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Lifecycle Issues&lt;/strong&gt; &amp;ndash; Identifying outdated or expired content requires manual checks.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Change Tracking&lt;/strong&gt; &amp;ndash; Monitoring edits across thousands of pages is resource-intensive.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Data Exporting&lt;/strong&gt; &amp;ndash; Manual CSV exports are slow, error-prone, and not scalable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;High Effort&lt;/strong&gt; &amp;ndash; Auditing consumes substantial IT and administrative time.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use Cases:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp; &amp;nbsp;&lt;strong&gt; &amp;nbsp; 1. Scheduled Report Export:&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Extend the reports feature by integrating a scheduled job that automatically exports selected page and content data (e.g., title, URL, author, last modified date) into a structured CSV or Excel format for content audit and review.&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;2. Metadata Validation and Updates&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Identify and update missing or incomplete metadata (e.g., Title, Description, or Keywords) for published pages to maintain content accuracy, SEO compliance, and adherence to governance standards.&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;3. Expired or Archived Content&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Detect and list archived or expired pages that are no longer active or relevant, enabling content teams to clean up and improve site performance and user experience.&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;4. Long-standing Draft Pages&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Highlight draft pages that have been sitting unpublished for an extended period. This helps content managers review, update, or remove outdated drafts to ensure content freshness.&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;5. SEO-friendly Simplified URLs&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Automate updates to simple addresses (SEO-friendly URLs) to ensure consistency with naming conventions and improve search visibility.&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;&lt;strong&gt;6. Approved but Unpublished Pages&lt;/strong&gt;&lt;/p&gt;
&lt;p style=&quot;padding-left: 20pt;&quot;&gt;Identify pages that have been approved by authors or editors but not yet published, allowing administrators to review and publish them efficiently.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A custom &lt;strong&gt;PageReportsJob &lt;/strong&gt;automates page auditing for large content sites. It generates CSV reports that give administrators and content managers an overview of content structure, publishing status, and updates.&lt;/p&gt;
&lt;p&gt;&lt;br /&gt;We solves these issues with an automated job process that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Automatically scans and identifies all content types and statuses.&lt;/li&gt;
&lt;li&gt;Generates standardized CSV reports for consistency.&lt;/li&gt;
&lt;li&gt;Efficiently processes large datasets using paging and async execution.&lt;/li&gt;
&lt;li&gt;Provides near real-time insights on content status and updates.&lt;/li&gt;
&lt;li&gt;Secures access with role-based permissions.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Logging;
using EPiServer.PlugIn;
using EPiServer.Scheduler;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Microsoft.Extensions.Options;
using Nito.AsyncEx;
using MyCompany.Web.Features.Reports.ExistingPages.Models;
using MyCompany.Web.Infrastructure.Cms;
using MyCompany.Web.Infrastructure.Cms.Settings;
using static MyCompany.Web.Features.Core.Constants.CommonConstants;

namespace MyCompany.Web.Features.Jobs
{
    [ScheduledPlugIn(
        DisplayName = &quot;Page Report Job &quot;,
        Description = &quot;Exports existing pages to CSV for administrators and content managers&quot;,
        IntervalType = ScheduledIntervalType.None,
        SortIndex = -1004 + &#39;D&#39;)]
    public class PageReportJob : ScheduledJobBase
    {
        private readonly ILogger _logger;
        private readonly IMediaFileService _mediaFileService;
        private readonly IContentRepository _contentRepository;
        private readonly IContentReportQueryService _contentReportQueryService;
        private readonly IContentVersionRepository _contentVersionRepository;
        private readonly ISettingsService _settingsService;
        private readonly ExistingPagesReportOptions _options;

        private bool _stopSignaled;

        public PageReportJob (
            IMediaFileService mediaFileService,
            IContentRepository contentRepository,
            IContentReportQueryService contentReportQueryService,
            IContentVersionRepository contentVersionRepository,
            ISettingsService settingsService,
            IOptions&amp;lt;ExistingPagesReportOptions&amp;gt; options)
        {
            _mediaFileService = mediaFileService;
            _contentRepository = contentRepository;
            _contentReportQueryService = contentReportQueryService;
            _contentVersionRepository = contentVersionRepository;
            _settingsService = settingsService;
            _options = options?.Value ?? new ExistingPagesReportOptions();
            _logger = LogManager.GetLogger();
            IsStoppable = true;
        }

        public override void Stop() =&amp;gt; _stopSignaled = true;

        public override string Execute()
        {
            try
            {
                return AsyncContext.Run(GenerateReports);
            }
            catch (Exception ex)
            {
                _logger.Error(ex.StackTrace);
                return &quot;The job stopped due to an exception.&quot;;
            }
        }

        private async Task&amp;lt;string&amp;gt; GenerateReports()
        {
            var reportQueryTypes = Enum.GetValues(typeof(ContentReportQueryType))
                .Cast&amp;lt;ContentReportQueryType&amp;gt;()
                .Select(v =&amp;gt; v.ToString())
                .ToList();

            // Adding a custom query type
            reportQueryTypes.Add(&quot;NotPublishedPages&quot;);

            foreach (var reportQueryType in reportQueryTypes)
            {
                if (_stopSignaled) break;
                await CreateReport(reportQueryType);
            }

            return &quot;Exported all pages successfully.&quot;;
        }

        private async Task CreateReport(string reportQueryType)
        {
            try
            {
                _logger.Information($&quot;Starting report creation for type &#39;{reportQueryType}&#39;.&quot;);

                var csvFolderPath = _mediaFileService.GetOrCreateFolder(
                    ExistingPageReportTypes.FolderName,
                    SiteDefinition.Current.SiteAssetsRoot);

                var pageCollection = await SetPagesByTypeOfQuery(reportQueryType);

                var pages = pageCollection
                    .Select(x =&amp;gt; ExistingPageViewModel.Make(
                        x, 
                        _contentVersionRepository.List(x.ContentLink).Where(v =&amp;gt; v.LanguageBranch == x.Language?.Name)))
                    .Where(x =&amp;gt; !string.IsNullOrWhiteSpace(x.Url))
                    .ToList();

                var fileName = $&quot;{ExistingPageReportTypes.PrefixFileName}_{reportQueryType.ToLower()}&quot;;
                var media = _mediaFileService.SaveFile(
                    fileName,
                    ExistingPageReportTypes.FileExtension,
                    csvFolderPath,
                    new byte[0]);

                _mediaFileService.WriteCsv(media, pages);
                _contentRepository.Save(media, SaveAction.Publish, AccessLevel.NoAccess);

                _logger.Information($&quot;Report &#39;{fileName}&#39; created and published with {pages.Count} rows.&quot;);
            }
            catch (Exception ex)
            {
                _logger.Error($&quot;Error in CreateReport for type &#39;{reportQueryType}&#39;: {ex}&quot;);
                throw;
            }
        }

        private async Task&amp;lt;PageDataCollection?&amp;gt; SetPagesByTypeOfQuery(string typeOfQuery)
        {
            var retryCounter = 0;
            do
            {
                try
                {
                    return await ReadPageDataCollection(typeOfQuery);
                }
                catch (Exception ex)
                {
                    retryCounter++;
                    _logger.Warning($&quot;Error in SetPagesByTypeOfQuery (attempt {retryCounter}) for query &#39;{typeOfQuery}&#39;: {ex}&quot;);
                }
            }
            while (retryCounter &amp;lt; 3);

            _logger.Error($&quot;Failed to get pages by type of query &#39;{typeOfQuery}&#39; after 3 attempts.&quot;);
            return null;
        }

        private async Task&amp;lt;PageDataCollection&amp;gt; ReadPageDataCollection(string typeOfQuery)
        {
            const int MaximumRows = 50;
            int receivedRecords, pageNumber = 0;

            var queryType = string.Equals(&quot;NotPublishedPages&quot;, typeOfQuery, StringComparison.OrdinalIgnoreCase)
                ? &quot;ReadyToPublish&quot;
                : typeOfQuery;

            var typeEnum = GetType(queryType);

            var startDate = (typeEnum == ContentReportQueryType.Changed &amp;amp;&amp;amp; _options?.ChangedWindowDays is int days &amp;amp;&amp;amp; days &amp;gt; 0)
                ? DateTime.Now.AddDays(-days)
                : new DateTime(1900, 1, 1);

            if (typeEnum == ContentReportQueryType.Changed)
            {
                _logger.Information($&quot;Using ChangedWindowDays of {_options.ChangedWindowDays}, start date {startDate:yyyy-MM-dd}&quot;);
            }

            var query = new ContentReportQuery
            {
                Root = ContentReference.StartPage,
                StartDate = startDate,
                EndDate = DateTime.Now,
                PageSize = 1,
                PageNumber = pageNumber,
                TypeOfQuery = typeEnum,
                IsReadyToPublish = string.Equals(&quot;ReadyToPublish&quot;, typeOfQuery, StringComparison.OrdinalIgnoreCase)
            };

            var totalRecords = await Task.Run(() =&amp;gt;
            {
                _contentReportQueryService.Get(query, out int outRows);
                return outRows;
            });

            var pageDataCollection = new PageDataCollection(totalRecords);

            do
            {
                query.PageNumber = pageNumber;
                query.PageSize = MaximumRows;

                var enumerable = await Task.Run(() =&amp;gt; _contentReportQueryService.Get(query, out int _));
                foreach (ContentReference item in enumerable)
                {
                    if (_contentRepository.Get&amp;lt;IContent&amp;gt;(item) is PageData page)
                    {
                        pageDataCollection.Add(page);
                    }
                }

                pageNumber++;
                receivedRecords = MaximumRows * pageNumber + 1;
            }
            while (totalRecords &amp;gt; receivedRecords);

            _logger.Information($&quot;Retrieved {pageDataCollection.Count} pages for query type &#39;{typeOfQuery}&#39;.&quot;);

            var filtered = ApplyGuards(pageDataCollection, typeEnum, typeOfQuery);
            return new PageDataCollection(filtered);
        }

        private IEnumerable&amp;lt;PageData&amp;gt; ApplyGuards(IEnumerable&amp;lt;PageData&amp;gt; items, ContentReportQueryType type, string typeOfQuery)
        {
            var now = DateTime.Now;

            return type switch
            {
                ContentReportQueryType.Published =&amp;gt; items.Where(p =&amp;gt;
                    p.CheckPublishedStatus(PagePublishedStatus.Published) &amp;amp;&amp;amp;
                    p.StartPublish &amp;lt;= now &amp;amp;&amp;amp;
                    (!p.StopPublish.HasValue || p.StopPublish &amp;gt; now)),

                ContentReportQueryType.ReadyToPublish when 
                    string.Equals(typeOfQuery, &quot;NotPublishedPages&quot;, StringComparison.OrdinalIgnoreCase) =&amp;gt;
                    items.Where(p =&amp;gt; (!p.StopPublish.HasValue || p.StopPublish &amp;gt; now) &amp;amp;&amp;amp; (!p.StartPublish.HasValue || p.StartPublish &amp;gt; now)),

                ContentReportQueryType.ReadyToPublish =&amp;gt; items,
                ContentReportQueryType.Expired =&amp;gt; items.Where(p =&amp;gt; p.StopPublish.HasValue &amp;amp;&amp;amp; p.StopPublish.Value &amp;lt;= now),
                ContentReportQueryType.Changed =&amp;gt; items,
                ContentReportQueryType.SimpleAddress =&amp;gt; items.Where(p =&amp;gt; !string.IsNullOrWhiteSpace(p.URLSegment)),

                _ =&amp;gt; items
            };
        }

        private ContentReportQueryType GetType(string type) =&amp;gt;
            type switch
            {
                &quot;Published&quot; =&amp;gt; ContentReportQueryType.Published,
                &quot;ReadyToPublish&quot; =&amp;gt; ContentReportQueryType.ReadyToPublish,
                &quot;Expired&quot; =&amp;gt; ContentReportQueryType.Expired,
                &quot;Changed&quot; =&amp;gt; ContentReportQueryType.Changed,
                &quot;SimpleAddress&quot; =&amp;gt; ContentReportQueryType.SimpleAddress,
                _ =&amp;gt; ContentReportQueryType.Published
            };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp;&lt;/strong&gt;The system automatically generates six types of page reports: Published, Not Published, Ready to Publish, Expired, Changed, and Simple Address Pages.&lt;/p&gt;
&lt;p&gt;&#129534; &lt;strong&gt;Example: Page Audit Report (CSV Output)&lt;/strong&gt;&lt;br /&gt;&lt;br /&gt;&lt;img src=&quot;/link/9db92206724d48ddaf0c19aaa907da03.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt;&lt;br /&gt;The &lt;strong&gt;PagesAuditJob &lt;/strong&gt;represents a robust, enterprise-grade solution for content auditing and reporting. Its combination of scheduled automation, web interface accessibility, and comprehensive reporting capabilities makes it an essential tool for maintaining content quality and governance standards in Optimizely content management system.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Hope this post helps you strengthen and streamline your page audit process.&lt;/p&gt;
&lt;p&gt;Cheers! &#129346;&lt;/p&gt;</id><updated>2025-10-06T04:57:40.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely Product Recommendation Troubleshooting</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2025/4/optimizely-product-recommendation-troubleshooting/" /><id>&lt;p class=&quot;MsoNormal&quot;&gt;In today&amp;rsquo;s fast-paced digital landscape, &lt;strong&gt;personalization is everything&lt;/strong&gt;. Customers expect relevant, tailored experiences whenever they interact with a brand &amp;mdash; and meeting that expectation can make or break your success. That&amp;rsquo;s where &lt;strong&gt;Optimizely&#39;s Product Recommendation&lt;/strong&gt; feature shines.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;What is Optimizely Product Recommendation?&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Optimizely&amp;rsquo;s Product Recommendation feature is a &lt;strong&gt;powerful, AI-driven tool&lt;/strong&gt; designed to help brands offer hyper-personalized product suggestions to their customers. It&amp;rsquo;s built into the Optimizely Commerce platform and integrates seamlessly with both &lt;strong&gt;Commerce Cloud&lt;/strong&gt; and &lt;strong&gt;Customized Commerce (formerly Episerver Commerce)&lt;/strong&gt;.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Instead of relying on static product lists, Optimizely uses &lt;strong&gt;machine learning algorithms&lt;/strong&gt; and &lt;strong&gt;real-time customer behavior&lt;/strong&gt; to dynamically surface the right products to the right audience at the right time.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Why Product Recommendations Matter?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Boost Conversion Rates&lt;/strong&gt;&lt;br /&gt;Relevant product suggestions keep customers engaged and help them discover products they might not have found otherwise, leading to increased sales.&lt;/li&gt;
&lt;li class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Enhance Customer Experience&lt;/strong&gt;&lt;br /&gt;When users feel like a site &amp;ldquo;understands&amp;rdquo; them, they&amp;rsquo;re more likely to stay, browse, and buy.&lt;/li&gt;
&lt;li class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Increase Average Order Value&lt;/strong&gt;&lt;br /&gt;Smart cross-selling and upselling through recommendations can encourage customers to add more to their cart.&lt;/li&gt;
&lt;li class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Reduce Bounce Rates&lt;/strong&gt;&lt;br /&gt;Presenting enticing alternatives or related items keeps visitors on your site longer.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Troubleshooting&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;During the implementation, we faced a few challenges, which I have outlined here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compatible Package&lt;/strong&gt;&lt;br /&gt;Before starting, ensure you verify the Commerce version you are using and identify the compatible versions of related packages.&lt;br /&gt;For example, in my case, I was using EPiServer.Commerce 13.33.0, so I installed EPiServer.Personalization.Commerce and EPiServer.Tracking.Commerce version 3.2.37.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configuration&amp;nbsp;&lt;/strong&gt;&lt;br /&gt;If you are using a single catalog, follow the &lt;strong&gt;single-site&lt;/strong&gt; configuration; otherwise, use the &lt;strong&gt;multi-site&lt;/strong&gt; configuration approach.&amp;nbsp;Since I was working with a multi-site, channel-based setup, I made a mistake during the configuration &amp;mdash; I used &lt;strong&gt;Web&lt;/strong&gt; instead of &lt;strong&gt;web&lt;/strong&gt;, and similarly &lt;strong&gt;Mobile&lt;/strong&gt; instead of &lt;strong&gt;mobile&lt;/strong&gt;. Please note that these values are&amp;nbsp;&lt;strong&gt;case-sensitive&lt;/strong&gt;, so ensure you use the correct lower-case terms to avoid configuration issues.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Single Site&lt;/strong&gt;&lt;strong&gt;&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-html&quot;&gt;&lt;code&gt;&amp;lt;add key=&quot;episerver:personalization.BaseApiUrl&quot;  value=&quot;https://mysite.uat.productrecs.optimizely.com&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.Site&quot; value=&quot;MySite&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.ClientToken&quot; value=&quot;MyClientToken&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.AdminToken&quot; value=&quot;MyAdminToken&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Multi-Site:&lt;/strong&gt;&lt;br /&gt;The multi-site configuration is &lt;strong&gt;scope-based&lt;/strong&gt;. If you are using more than one catalog, you need to set up the configuration in a repeated form as shown below.&lt;/p&gt;
&lt;pre class=&quot;language-html&quot;&gt;&lt;code&gt;&amp;lt;add key=&quot;episerver:personalization.ScopeAliasMapping.MyScope&quot; value=&quot;SiteId&quot;/&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.CatalogNameForFeed.MyScope&quot; value=&quot;CatalogName&quot;/&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.BaseApiUrl.MyScope&quot;  value=&quot;https://sitename.uat.productrecs.episerver.net&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.Site.MyScope&quot; value=&quot;sitename&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.AdminToken.MyScope&quot; value=&quot;MyAdminToken&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.ClientToken.MyScope.web&quot; value=&quot;MyWebToken&quot; /&amp;gt;
&amp;lt;add key=&quot;episerver:personalization.ClientToken.MyScope.mobile&quot; value=&quot;MyMobileToken&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Sync Specific Pricing (Group Price)&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;The &#39;Product Export Job&#39; syncs only the &#39;All Customers&#39; pricing group. To sync prices for a specific pricing group, use IEntryPriceService to fetch the price for that group.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;e.g.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class DefaultEntryPriceService : IEntryPriceService
{
    private readonly IPromotionEngine _promotionEngine;
    private readonly IMarketService _marketService;
    private readonly IPriceService _priceService;
    private readonly bool _calculateDiscountPrices;
    private readonly IPricingService _pricingService;

    public DefaultEntryPriceService(IPromotionEngine promotionEngine, IMarketService marketService, IPricingService pricingService)
    {
        _promotionEngine = promotionEngine;
        _marketService = marketService;
        _pricingService = pricingService;
        _calculateDiscountPrices = !bool.TryParse(ConfigurationManager.AppSettings[&quot;episerver:personalization.CalculateDiscountPrices&quot;], out _calculateDiscountPrices) || _calculateDiscountPrices;
    }

    public IEnumerable&amp;lt;EntryPrice&amp;gt; GetPrices(IEnumerable&amp;lt;EntryContentBase&amp;gt; entries, DateTime validOn, string scope)
    {
        CustomEntryPriceService entryPriceService = this;

        List&amp;lt;CatalogKey&amp;gt; catalogKeys = entries
            .Where(x =&amp;gt; x is IPricing)
            .Select(new Func&amp;lt;EntryContentBase, CatalogKey&amp;gt;(entryPriceService.GetCatalogKey))
            .ToList();

        Dictionary&amp;lt;string, ContentReference&amp;gt; codeContentLinkMap = entries.ToDictionary(c =&amp;gt; c.Code, c =&amp;gt; c.ContentLink);
        IEnumerable&amp;lt;IMarket&amp;gt; source1 = entryPriceService._marketService.GetAllMarkets().Where(x =&amp;gt; x.IsEnabled);
        List&amp;lt;IPriceValue&amp;gt; source2 = new List&amp;lt;IPriceValue&amp;gt;();
        foreach (IMarket market in source1)
        {
            foreach (CatalogKey key in catalogKeys)
            {
				//Read price for specific group from your service
                var price = _pricingService.GetConsumerListPricing(key.CatalogEntryCode, market);
                source2.Add(new PriceValue
                {
                    CatalogKey = key,
                    MarketId = market.MarketId,
                    UnitPrice = new Money(price ?? decimal.Zero, market.DefaultCurrency)
                });
            }
        }

        foreach (var priceValue in source2.GroupBy(c =&amp;gt; new
        {
            c.CatalogKey,
            c.UnitPrice.Currency
        }).Select(g =&amp;gt; g.OrderBy(x =&amp;gt; x.UnitPrice.Amount).First()).ToList())
        {
            ContentReference contentLink;
            if (codeContentLinkMap.TryGetValue(priceValue.CatalogKey.CatalogEntryCode, out contentLink))
            {
                Money salePrice = priceValue.UnitPrice;
                if (entryPriceService._calculateDiscountPrices)
                {
                    var market = _marketService.GetMarket(priceValue.MarketId);
					
					//Read Discounted Price
                    var discountPrice = _pricingService.GetConsumerDiscountPricing(
                        priceValue.CatalogKey.CatalogEntryCode,
                        market) ?? decimal.Zero;

                    salePrice = discountPrice == 0
                        ? priceValue.UnitPrice
                        : new Money(discountPrice, market.DefaultCurrency);
                }

                yield return new EntryPrice(contentLink, priceValue.UnitPrice, salePrice);
            }
        }
    }

    private CatalogKey GetCatalogKey(EntryContentBase entryContent)
    {
        return new CatalogKey(entryContent.Code);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Target Specific Markets:&lt;br /&gt;&lt;/strong&gt;When targeting products for specific markets, use ICatalogItemFilter to filter the products from your catalog before syncing the feed into product recommendations.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;In our case, for one of the sites, we are targeting more than 16 markets. However, we encountered an issue syncing the feed for the China market, &lt;em&gt;as the Unicode product URLs were not compatible with the product recommendations feed (Optimizely need to fix the Unicode issue)&lt;/em&gt;. As a result, we decided to launch this feature for specific markets only.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;e.g.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; public class DefaultCatalogItemFilter : ICatalogItemFilter
 {
     private readonly IPublishedStateAssessor _publishedStateAssessor;
     private List&amp;lt;string&amp;gt; languages = new List&amp;lt;string&amp;gt;() { &quot;en&quot;, &quot;en-CA&quot;, &quot;fr-CA&quot; };

     public DefaultCatalogItemFilter(IPublishedStateAssessor publishedStateAssessor)
     {
         _publishedStateAssessor = publishedStateAssessor;
     }

     public bool ShouldFilter(CatalogContentBase content, string scope)
     {
         if (_publishedStateAssessor.IsPublished(content, PublishedStateCondition.None) &amp;amp;&amp;amp;
             languages.Any(x =&amp;gt; string.Equals(x, content.Language.Name, StringComparison.InvariantCultureIgnoreCase)))
         {
             return false;
         }

         return !_publishedStateAssessor.IsPublished(content, PublishedStateCondition.None);
     }
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Sync Specific Attributes in the Product Feed&lt;/strong&gt;&lt;br /&gt;To target specific attributes for syncing into the feed for catalog entries, use IEntryAttributeService. This service helps you apply certain rules to control how products are displayed in the recommendation area.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;In our case, we are using a single codebase for over 14 catalogs. However, the products and variants have some uncommon properties that cannot be included in the feed for other sites.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class DefaultEntryAttributeService : IEntryAttributeService
{
    private readonly CatalogFeedSettings _catalogFeedSettings;
    private readonly IVariantAttributeService _variantAttributeService;

    public DefaultEntryAttributeService(CatalogFeedSettings catalogFeedSettings, IVariantAttributeService variantAttributeService)
    {
        _catalogFeedSettings = catalogFeedSettings;
        _variantAttributeService = variantAttributeService;
    }

    public bool CanBeRecommended(EntryContentBase content, decimal stock, string scope)
    {
        return stock &amp;gt; 0M;
    }

    public IDictionary&amp;lt;string, string&amp;gt; GetAttributes(EntryContentBase content, string scope)
    {
        List&amp;lt;string&amp;gt; userMetaFieldNames = GetUserMetaFields(content).ToList();
        Dictionary&amp;lt;string, string&amp;gt; attributes = new Dictionary&amp;lt;string, string&amp;gt;();

        if (!userMetaFieldNames.Any())
            return attributes;

		// Read attributes from your list that you would like to exclude from feed
        _catalogFeedSettings.ExcludedAttributes = RecommendationHelper.ExcludeAttributes; 

        HashSet&amp;lt;string&amp;gt; excludedAttributes = new HashSet&amp;lt;string&amp;gt;(_catalogFeedSettings.ExcludedAttributes, StringComparer.OrdinalIgnoreCase);

        foreach (PropertyData propertyData in content.Property.Where(x =&amp;gt; IsValidContentProperty(x, userMetaFieldNames, excludedAttributes)))
        {
            attributes.Add(propertyData.Name, propertyData.Value.ToString());
        }

        if (content is MyVariant variant)
        {
            foreach (var attr in RecommendationHelper.IncludeAttributes) // Include specific attributes
            {
                if (attributes.Any(x =&amp;gt; string.Equals(x.Key, attr, StringComparison.OrdinalIgnoreCase)))
                    continue;

				// Read the value for attribute if exists in catalog entries
                var value = _variantAttributeService.GetAttributeValueByName(attr, variant); 
                if (!string.IsNullOrWhiteSpace(value))
                {
                    attributes.Add(attr, value);
                }
            }            
        }

        return attributes;
    }

    public string GetDescription(EntryContentBase entryContent, string scope)
    {
        return entryContent[_catalogFeedSettings.DescriptionPropertyName]?.ToString();
    }

    public string GetTitle(EntryContentBase entryContent, string scope)
    {
        return !string.IsNullOrEmpty(entryContent.DisplayName) ? entryContent.DisplayName : entryContent.Name;
    }

    private bool IsValidContentProperty(
       PropertyData property,
       IEnumerable&amp;lt;string&amp;gt; userMetaFieldNames,
       HashSet&amp;lt;string&amp;gt; excludedAttributes)
    {
        return userMetaFieldNames.Any(x =&amp;gt; x.Equals(property.Name))
            &amp;amp;&amp;amp; !property.Name.Equals(&quot;_ExcludedCatalogEntryMarkets&quot;)
            &amp;amp;&amp;amp; !property.Name.Equals(&quot;DisplayName&quot;)
            &amp;amp;&amp;amp; !property.Name.Equals(&quot;ContentAssetIdInternal&quot;)
            &amp;amp;&amp;amp; !property.Name.StartsWith(&quot;Epi_&quot;)
            &amp;amp;&amp;amp; property.Value != null
            &amp;amp;&amp;amp; !excludedAttributes.Contains(property.Name);
    }

    public IEnumerable&amp;lt;string&amp;gt; GetUserMetaFields(EntryContentBase content)
    {
        var metaClass = Mediachase.MetaDataPlus.Configurator.MetaClass.Load(new MetaDataContext()
        {
            UseCurrentThreadCulture = false,
            Language = content.Language.Name
        }, content.MetaClassId);

        return 
             metaClass != null 
            ? metaClass.GetUserMetaFields().Select(x =&amp;gt; x.Name).ToList() 
            : null ?? Enumerable.Empty&amp;lt;string&amp;gt;();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;strong&gt;Product Page Tracking&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Use the product code instead of the variant code to track the product.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&lt;img src=&quot;/link/47871e11ed3048428b9f49763a18a8d0.aspx&quot; width=&quot;591&quot; height=&quot;275&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;If you&#39;re encountering any challenges with the native implementation, feel free to reach out for assistance.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;Cheers!&lt;/p&gt;</id><updated>2025-04-28T10:57:39.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Integration Bynder (DAM) with Optimizely</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2024/7/integration-bynder-dam-with-optimizely/" /><id>&lt;p&gt;Bynder is a comprehensive digital asset management (DAM) platform that enables businesses to efficiently manage, store, organize, and share their digital content and branding assets, including images, videos, documents, and other media files. For more information, visit &lt;a href=&quot;https://www.bynder.com/en/&quot;&gt;Bynder&lt;/a&gt;. This blog will guide you through integrating Bynder with the Optimilzey platform, ensuring a smooth development process, and leveraging Bynder&#39;s extensive features to meet your project needs&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;To integrate Bynder with Optimizely, follow these steps:&lt;/strong&gt;&lt;/p&gt;
&lt;div class=&quot;flex flex-grow flex-col max-w-full&quot;&gt;
&lt;div class=&quot;min-h-[20px] text-message flex w-full flex-col items-end gap-2 whitespace-pre-wrap break-words [.text-message+&amp;amp;]:mt-5 overflow-x-auto&quot;&gt;
&lt;div class=&quot;flex w-full flex-col gap-1 empty:hidden first:pt-[3px]&quot;&gt;
&lt;div class=&quot;markdown prose w-full break-words dark:prose-invert light&quot;&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Contact Bynder Support:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Contact your Customer Success Manager or Bynder support at &lt;a&gt;support@bynder.com&lt;/a&gt; to learn more about the Optimizely integration and request the integration package.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Obtain the Installation Package:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You will receive an installation package after discussing your needs with Bynder support.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Review the Documentation:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check the &lt;code&gt;readme.md&lt;/code&gt; file included in the package for detailed installation and configuration instructions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Installation and Configuration:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Since the Bynder package is unavailable on nuget.org, follow the steps outlined in the &lt;code&gt;readme.md&lt;/code&gt; file to manually install and configure the integration.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;For additional information and support, visit the &lt;a href=&quot;https://support.bynder.com/hc/en-us/articles/360015350220-Optimizely-Integration&quot;&gt;Bynder Optimizely Integration Guide&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;mt-1 flex gap-3 empty:hidden ml-3&quot;&gt;
&lt;div class=&quot;items-center justify-start rounded-xl p-1 z-10 -mt-1 bg-token-main-surface-primary md:absolute md:border md:border-token-border-light flex&quot;&gt;
&lt;div class=&quot;flex items-center&quot;&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;&lt;strong&gt;Installation&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Step 1: Add the provided Bynder.x.x.x.nupkg package by support into the project.&lt;/p&gt;
&lt;p&gt;Step 2: Click on the &amp;lsquo;Package Manager Console&amp;rsquo; setting icon and add the package source for Bynder from the physical project directory location.&lt;/p&gt;
&lt;p&gt;Step 3: Add the Bynder package relative folder path reference into &lt;strong&gt;nuget.config&lt;/strong&gt; because when you start the build and deployment nuget package picks the package location from the given soruce.&lt;/p&gt;
&lt;p&gt;Step 4: Run command: Install-Package Bynder&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/defe49e98cf04e54a7e7d84ec8f92367.aspx&quot; width=&quot;686&quot; height=&quot;314&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;font-size: 14pt;&quot;&gt;&lt;strong&gt;Configuration:&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Step 1: Register the Bynder into Startup.cs&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/6272b96493c041508173edc17f5594c5.aspx&quot; width=&quot;685&quot; height=&quot;304&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;appsettings.json&lt;/code&gt;&lt;/strong&gt;: For environment-specific settings, this file is commonly used. It&amp;rsquo;s a good place to store configuration values like &lt;code&gt;BaseUrl&lt;/code&gt;, &lt;code&gt;ClientId&lt;/code&gt;, and &lt;code&gt;ClientSecret&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Workflow Files&lt;/strong&gt;: If you prefer, you can also manage these settings in workflow files, depending on your deployment or CI/CD setup.&lt;/li&gt;
&lt;li&gt;The recommendations in this article we simplified for demonstration purposes. For production, make sure to follow security best practices, such as storing secrets securely and using environment-specific configurations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Step 2:&amp;nbsp;Login into CMS area --&amp;nbsp; Add-ons -- Bynder&lt;/p&gt;
&lt;p&gt;Step 3:&amp;nbsp;To integrate Bynder with Optimizely and manage assets, follow these steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Get Authorization Token&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click on &lt;strong&gt;&amp;lsquo;Get Token&amp;rsquo;&lt;/strong&gt; to initiate the authorization process. This token will allow you to add items from Bynder DAM into Optimizely.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Whitelist Redirect URL&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ensure that your &lt;strong&gt;&amp;lsquo;RedirectUrl&amp;rsquo;&lt;/strong&gt; is whitelisted in Bynder. This step is crucial for successful authentication and authorization.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Troubleshooting&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you encounter issues with adding Bynder assets, try deleting the existing token and generating a new one. This often resolves authorization problems.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;/link/26ef027898fa46f0b760d0d3c4780117.aspx&quot; width=&quot;685&quot; height=&quot;303&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Step 4: To configure &lt;code&gt;AllowedTypes&lt;/code&gt; and &lt;code&gt;UIHints&lt;/code&gt; for Bynder and Optimizely assets at the property level, you would typically do this in your code where you define asset properties. Here&amp;rsquo;s how you might set this up:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Display(GroupName = GroupNames.Content,Order = 10)]
[AllowedTypes(typeof(VideoFile), typeof(BynderVideoAsset))]
public virtual ContentReference? Video { get; set; }

[Display(GroupName = GroupNames.Content,    Order = 10)]
[UIHint(UIHint.Image)]
[AllowedTypes(typeof(ImageFile), typeof(BynderImageAsset))]
public virtual ContentReference? Image { get; set; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;AllowedTypes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;VideoFile&lt;/strong&gt;: Use this if you want to optimize or make changes to existing video assets in Optimizely, without removing any current functionality.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ImageFile&lt;/strong&gt;: Use this for optimizing or updating existing image assets in Optimizely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BynderVideoAsset&lt;/strong&gt;: Use this to refer to or use video assets stored in Bynder, rather than those uploaded directly to Optimizely&#39;s media folder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BynderImageAsset&lt;/strong&gt;: Use this to refer to or use image assets stored in Bynder, rather than those uploaded directly to Optimizely&#39;s media folder.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;UIHints &lt;/strong&gt;- You can use one of the UIHints from below to display the thumbnail image.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[UIHint(UIHint.Image)] &amp;ndash; This will display a thumbnail image for both types of assets&amp;mdash;Bynder and Optimizely. It&#39;s a general UI hint that applies to any asset type you specify as an image.&lt;/li&gt;
&lt;li&gt;[UIHint(Bynder.UIHints.Bynder)] &amp;ndash; This will specifically display a thumbnail image only for Bynder assets. It won&#39;t affect Optimizely assets.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; Note&lt;/strong&gt;: If you are unable to see the thumbnail image for the Bynder image asset then Login into the Bynder portal and edit the image with permission &lt;strong&gt;&amp;lsquo;Mark as public&amp;rsquo;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;img src=&quot;/link/cdf4ea66993944b49edf57759fcad524.aspx&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Step 5: Upload Your Assets from Bynder or Optimizely media asset and test.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/2e296e3ba0404fc6b441d5a90d5269dc.aspx&quot; width=&quot;413&quot; height=&quot;299&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Step 6: Bynder Jobs to sync and update the contents.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/9c3301aebb194589a75245f001d1c1ff.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Here is some additional help to understand the model type and rendering for other Bynder media assets:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class BynderBlock : BlockData
{
      [AllowedTypes(typeof(BynderImageAsset))]
      [Display(GroupName = GroupNames.Content, Order = 10)]
      [UIHint(Bynder.UIHints.Bynder)]
      public virtual ContentReference? BynderImage { get; set; }

      [AllowedTypes(typeof(BynderAudioAsset))]
      [Display(GroupName = GroupNames.Content, Order = 20)]
      public virtual ContentReference? BynderAudio { get; set; }

      [AllowedTypes(typeof(BynderVideoAsset))]
      [Display(GroupName = GroupNames.Content, Order = 30)]
      public virtual ContentReference? BynderVideo { get; set; }

      [AllowedTypes(typeof(BynderDocumentAsset))]
      [Display(GroupName = GroupNames.Content, Order = 40)]
      public virtual ContentReference? BynderDocument { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rendering:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;@Html.PropertyFor(x =&amp;gt; x.CurrentBlock.BynderImage)

OR
 
var url= Model.CurrentBlock.BynderImage.GetUrl()
&amp;lt;img src=&quot;@url&quot; alt =&quot;Bynder asset&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To create an extension method that reads the image URL from Bynder media asset types, you can follow these steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a static class to hold the extension method.&lt;/li&gt;
&lt;li&gt;Define the extension method to extract the URL from the media asset type.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here&#39;s an example of how you might implement such an extension method:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static string GetUrl(this ContentReference contentReference)
{
    if (!string.equals(contentReference.ProviderName, &quot;bynder-assets-provider&quot;))
    {
      return _urlResolver.GetUrl(contentReference);
    }

    IBynderAsset assetData = _contentLoader.Get&amp;lt;IBynderAsset&amp;gt;(contentReference);
    if (assetData != null &amp;amp;&amp;amp; assetData.AssetType.Equals(&quot;image&quot;))
    {
        return assetData.ImageUrl;
    }
    else if (assetData != null &amp;amp;&amp;amp; assetData.AssetType.Equals(&quot;video&quot;))
    {
        var videoAsset = assetData as BynderVideoAsset;
        return videoAsset?.VideoPreviewURLs.FirstOrDefault() ?? string.Empty;
    }
    else if (assetData != null &amp;amp;&amp;amp; assetData.AssetType.Equals(&quot;document&quot;))
    {
        var documentAsset = assetData as BynderAssetData;
        return documentAsset?.TransformBaseUrl ?? documentAsset?.Original ?? string.Empty;
    }
    else if (assetData != null &amp;amp;&amp;amp; assetData.AssetType.Equals(&quot;audio&quot;))
    {
        var audioAsset = assetData as BynderAssetData;
        return audioAsset?.TransformBaseUrl ?? audioAsset?.Original ?? string.Empty;
    }

    return string.Empty;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Hope this guide helps you set up the basic configuration for integrating Bynder with Optimizely.&lt;/p&gt;
&lt;p&gt;Please leave your feedback in the comment box.&lt;/p&gt;
&lt;p&gt;Thanks for your visit!&lt;/p&gt;</id><updated>2024-07-22T06:33:02.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Remove a segment from the URL in CMS 12</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2024/6/remove-a-segment-from-the-url-in-cms-12/" /><id>&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: I have created thousands of pages dynamically using schedule jobs with different templates (e.g. one column, two columns, etc..) and stored them under one of the specific container page templates but some of the page&amp;rsquo;s URLs were renamed.&lt;/p&gt;
&lt;p&gt;So, I have two problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;How to ignore the container page name segment from the URL,&lt;/li&gt;
&lt;li&gt;Redirect to the new page without using any Add-ons or mapping the old one to the new URL.&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;Page Hierarchy&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;Name in URL&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;New URL (simple address url)&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;Expected result&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;Start Page -&amp;gt; Container Page -&amp;gt; One col page&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;study&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;students/study&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;- Remove the &amp;lsquo;container-page&amp;rsquo; segment from URL.&lt;/p&gt;
&lt;p&gt;- Apply 301 redirection for New URLs without adding any Add-Ons&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;https://local.alloy.com/container-page/study&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&amp;nbsp;https://local.alloy.com/students/study&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;https://local.alloy.com/study&lt;/p&gt;
&lt;p&gt;https://local.alloy.com/students/study&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;: To fix the above problem we need to add the simple address for new URLs in each page and implement custom 301 redirections.&lt;/p&gt;
&lt;p&gt;Register middleware into &lt;strong&gt;startup.cs &lt;/strong&gt;file to bypass the container page segment from the URL&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IConfiguration configuration)
 {
      app.UseMiddleware&amp;lt;SkipContainerPageMiddleware&amp;gt;();
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the container page middleware to read the simple address and apply 301 redirection:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class SkipContainerPageMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ISettingsService _settingsService;
    private readonly IContentLoader _contentLoader;

    public SkipContainerPageMiddleware(RequestDelegate next, ISettingsService settingsService, IContentLoader contentLoader)
    {
        _next = next;
        _settingsService = settingsService;
        _contentLoader = contentLoader;
    }

    public async Task Invoke(HttpContext context)
    {
       //Create a content reference type property in GlobalPageSettings or HomePage to read the container page location where all pages created
        var folderLocation = _settingsService.GetSiteSettings&amp;lt;GlobalPageSettings&amp;gt;()?.OEmbedFolderLocation ?? ContentReference.EmptyReference; 
        var folderName = &quot;/&quot; + _contentLoader.Get&amp;lt;IContent&amp;gt;(folderLocation).Name;

        if (context.Request.Path.StartsWithSegments(folderName, StringComparison.OrdinalIgnoreCase))
        {
            context.Request.Path = context.Request.Path.HasValue
                ? context?.Request?.Path.Value.Substring(folderName.Length)
                : string.Empty;

            if (context != null)
            {
                var url = GetSimpleAddress(folderName, context);
                if (url != null)
                {
                    var newURl = $&quot;{context.Request.Scheme}://{context.Request.Host}/{url.Trim(&#39;/&#39;)}&quot;;
                    context.Response.Redirect(newURl, true);
                    return;
                }
            }
        }

        if (context != null)
            await _next(context);
    }

    private string? GetSimpleAddress(string parentSegment, HttpContext context)
    {
        var parentPage = _contentLoader
            .GetChildren&amp;lt;ContainerPage&amp;gt;(ContentReference.StartPage)
            .FirstOrDefault(x =&amp;gt; string.Equals(parentSegment.Trim(&#39;/&#39;), x.URLSegment, StringComparison.InvariantCultureIgnoreCase))?
            .ContentLink
            ?? ContentReference.EmptyReference;

        var url = context.Request.Path.HasValue ? context?.Request.Path.Value.Trim(&#39;/&#39;) : string.Empty;
        if (string.IsNullOrWhiteSpace(url) || parentPage == ContentReference.EmptyReference)
            return default;

        PageData? page = null;
        foreach (var item in url.Split(&#39;/&#39;))
        {
            page = RecursivePageBySegment(parentPage, item);
            if (page != null)
                parentPage = page.ContentLink;
        }

        return page?.ExternalURL ?? url;
    }

    private PageData? RecursivePageBySegment(ContentReference parentPage, string urlSegment)
    {
        var children = _contentLoader.GetChildren&amp;lt;PageData&amp;gt;(parentPage);
        if (children.Count() == 0)
        {
            return _contentLoader.Get&amp;lt;PageData&amp;gt;(parentPage);
        }

        var page = children.FirstOrDefault(page =&amp;gt; page.URLSegment.Equals(urlSegment, StringComparison.OrdinalIgnoreCase));
        return page ?? default;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I hope this blog helps you achieve similar type functionality!&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</id><updated>2024-06-21T04:58:46.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Custom promotion - Buy at least X items from catalog entries and get a discount on related catalog entries.</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2022/12/buy-2x-products-from-a-category-and-get-x-product-discount-on-b-category-products-/" /><id>&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: Buy at least X items from catalog entries and get related catalog entries at a discount that satisfy the below formula.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;or&lt;/p&gt;
&lt;p&gt;Create a custom promotion to &amp;lsquo;Buy Products for Discount from Other Selections&amp;rsquo; and apply the below formula to get a discount on other selections.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;&lt;em&gt;Formula = m x n,&amp;nbsp; then get the discounts on n items.&lt;/em&gt;&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;m = Spend at least X items&lt;/p&gt;
&lt;p&gt;n = Multiplier&lt;/p&gt;
&lt;p&gt;e.g.&lt;/p&gt;
&lt;p&gt;m = 2&lt;/p&gt;
&lt;p&gt;n = 1, 2,3&amp;hellip;&amp;hellip;&lt;/p&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CART&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main Product SKU Qty.&lt;/td&gt;
&lt;td&gt;Other Selection SKU Qty.&lt;/td&gt;
&lt;td&gt;Eligible Discount Qty.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 x 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 x 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 x 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 x 4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;....&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;....&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;....&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;m x n&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;....&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;n&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you notice the above table &amp;lsquo;Other Selection SKU Qty.&amp;rsquo; column, customer added more than the eligible discounted qty in the cart. So, we need to make sure the discount is only eligible for &#39;Eligible Discount Qty.&#39; not for all &amp;lsquo;Other Selection SKU Qty.&amp;rsquo;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a custom entry promotion&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   [ContentType(
        GUID = &quot;CB99C622-A170-4EF6-B28D-077B18BAFD81&quot;,
        GroupName = &quot;Custom - Promotions&quot;,
        DisplayName = &quot;Custom - Buy products for discount from other selection&quot;,
        Description = &quot;Buy at least X items from catalog entries and get related catalog entries at a discount e.g. (2 + 1),  (4 + 2),  (6 + 3) ... &quot;,
        Order = 30000)]
    [PromotionSettings(FixedRedemptionsPerOrder = 1)]
    [ImageUrl(&quot;~/Static/Img/CustomBuyQuantityGetItemDiscount.png&quot;)]
    public class CustomBuyQuantityGetItemDiscount : EntryPromotion, IMonetaryDiscount
    {
        [Display(Order = 10)]
        [PromotionRegion(&quot;Condition&quot;)]
        public virtual PurchaseQuantity Condition { get; set; }

        [Display(Order = 20)]
        [PromotionRegion(&quot;Reward&quot;)]
        public virtual DiscountItems DiscountTarget { get; set; }

        [Display(Order = 30)]
        [PromotionRegion(&quot;Discount&quot;)]
        public virtual MonetaryReward Discount { get; set; }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2. Create custom discount processor and override following methods:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GetPromotionItems
&lt;ul&gt;
&lt;li&gt;Return promotion conditions and reward items.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;RewardDescription
&lt;ul&gt;
&lt;li&gt;Read all discounted SKUs with the max eligible qty for discounts.&lt;/li&gt;
&lt;li&gt;Create Redemption Description into GetRedemptions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class CustomBuyQuantityGetItemDiscountProcessor : EntryPromotionProcessorBase&amp;lt;CustomBuyQuantityGetItemDiscount&amp;gt;
{
        private readonly IContentLoader _contentLoader;

        public CustomBuyQuantityGetItemDiscountProcessor(
            RedemptionDescriptionFactory redemptionDescriptionFactory,
            IContentLoader contentLoader)
            : base(redemptionDescriptionFactory)
        {
            _contentLoader = contentLoader ?? throw new ArgumentNullException(nameof(contentLoader));
        }

        protected override PromotionItems GetPromotionItems(CustomBuyQuantityGetItemDiscount promotionData)
        {
            return new PromotionItems(
                promotionData,
                new CatalogItemSelection(promotionData.Condition.Items, CatalogItemSelectionType.Specific, true),
                new CatalogItemSelection(promotionData.DiscountTarget.Items, CatalogItemSelectionType.Specific, true));
        }

        protected override RewardDescription Evaluate(
            CustomBuyQuantityGetItemDiscount promotionData,
            PromotionProcessorContext context)
        {
            var allLineItems = this.GetLineItems(context.OrderForm)?
                .Where(x =&amp;gt; !x.IsGift)?
                .ToList();

            if (promotionData?.DiscountTarget?.Items == null ||
                promotionData?.Condition?.Items == null ||
                promotionData?.Condition?.RequiredQuantity == 0 ||
                allLineItems.Count() == 0)
            {
                return
                    RewardDescription.CreateNotFulfilledDescription(
                    promotionData,
                    FulfillmentStatus.NotFulfilled);
            }

            //Read all excluded variants 
            var excludedVariants = new List&amp;lt;string&amp;gt;() { };
            if (promotionData.ExcludedItems != null &amp;amp;&amp;amp; promotionData.ExcludedItems.Count &amp;gt; 0)
            {
                foreach (ContentReference item in promotionData.ExcludedItems)
                {
                    if (_contentLoader.TryGet(item, out NodeContent content))
                    {
                        excludedVariants.AddRange(
                            _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(content.ContentLink)
                            .Select(x =&amp;gt; x.Code)
                            .ToList());
                    }
                    else if (_contentLoader.TryGet(item, out ProductContent productContent))
                    {
                        excludedVariants.AddRange(
                            _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(productContent.ContentLink)
                            .Select(x =&amp;gt; x.Code)
                            .ToList());
                    }
                    else
                    {
                        excludedVariants.Add(_contentLoader.TryGet&amp;lt;VariationContent&amp;gt;(item)?.Code);
                    }
                }
            }
          
           //Read all condition variants
            var conditionSkus = new List&amp;lt;string&amp;gt;() { };
            foreach (ContentReference item in promotionData.Condition.Items)
            {
                if (_contentLoader.TryGet(item, out NodeContent content))
                {
                    conditionSkus.AddRange(
                        _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(content.ContentLink)
                        .Select(x =&amp;gt; x.Code)
                        .ToList());
                }
                else if (_contentLoader.TryGet(item, out ProductContent productContent))
                {
                    conditionSkus.AddRange(
                        _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(productContent.ContentLink)
                        .Select(x =&amp;gt; x.Code)
                        .ToList());
                }
                else
                {
                    conditionSkus.Add(_contentLoader.TryGet&amp;lt;VariationContent&amp;gt;(item)?.Code);
                }
            }
            //Read all discounted variants
            var discountSkus = new List&amp;lt;string&amp;gt;() { };
            foreach (ContentReference item in promotionData.DiscountTarget.Items)
            {
                if (_contentLoader.TryGet(item, out NodeContent content))
                {
                    discountSkus.AddRange(
                        _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(content.ContentLink)
                        .Select(x =&amp;gt; x.Code)
                        .ToList());
                }
                else if (_contentLoader.TryGet(item, out ProductContent productContent))
                {
                    discountSkus.AddRange(
                        _contentLoader.GetDescendantsOfType&amp;lt;VariationContent&amp;gt;(productContent.ContentLink)
                        .Select(x =&amp;gt; x.Code)
                        .ToList());
                }
                else
                {
                    discountSkus.Add(_contentLoader.TryGet&amp;lt;VariationContent&amp;gt;(item)?.Code);
                }
            }

            //remove the discounted SKUs if exist in the excluded list.
            if (excludedVariants.Count() &amp;gt; 0)
            {
                discountSkus = discountSkus.Where(x =&amp;gt; !excludedVariants.Contains(x)).ToList();
                conditionSkus = conditionSkus.Where(x =&amp;gt; !excludedVariants.Contains(x)).ToList();
            }

            var totalPurchasedQty = allLineItems.Where(x =&amp;gt; conditionSkus.Contains(x.Code)).Sum(x =&amp;gt; x.Quantity);
            var maxDiscountedQty = Math.Floor(totalPurchasedQty / promotionData.Condition.RequiredQuantity);
            if (promotionData.DiscountTarget.MaxQuantity != null &amp;amp;&amp;amp;
                maxDiscountedQty &amp;gt; promotionData.DiscountTarget.MaxQuantity)
            {
                maxDiscountedQty = (decimal)promotionData.DiscountTarget.MaxQuantity;
            }

            if (maxDiscountedQty == decimal.Zero)
            {
                return RewardDescription.CreateNotFulfilledDescription(
                       promotionData,
                       FulfillmentStatus.InvalidCoupon);
            }

            var discountedLineItems = allLineItems
                .Where(x =&amp;gt; discountSkus.Contains(x.Code))
                .OrderByDescending(x =&amp;gt; x.PlacedPrice)
                .ThenBy(x =&amp;gt; x.Quantity)
                .ToList();

            return
                RewardDescription.CreateMoneyOrPercentageRewardDescription(
                         FulfillmentStatus.Fulfilled,
                         GetRedemptions(promotionData, context, discountedLineItems, maxDiscountedQty),
                         promotionData,
                         promotionData.Discount,
                         context.OrderGroup.Currency,
                         promotionData.Name);
        }

        private IEnumerable&amp;lt;RedemptionDescription&amp;gt; GetRedemptions(
          CustomBuyQuantityGetItemDiscount promotionData,
          PromotionProcessorContext context,
          List&amp;lt;ILineItem&amp;gt; discountedLineItems,
          decimal maxQty)
        {
            List&amp;lt;RedemptionDescription&amp;gt; redemptionDescriptionList = new List&amp;lt;RedemptionDescription&amp;gt;();
            var applicableCodes = discountedLineItems.Select(x =&amp;gt; x.Code);
            decimal val2 = GetLineItems(context.OrderForm).Where(li =&amp;gt; applicableCodes.Contains(li.Code)).Sum(li =&amp;gt; li.Quantity);
            decimal num = Math.Min(GetMaxRedemptions(promotionData.RedemptionLimits), val2);

            for (int index = 0; index &amp;lt; num; ++index)
            {
                AffectedEntries entries = context.EntryPrices.ExtractEntries(applicableCodes, Math.Min(maxQty, val2), promotionData);
                if (entries != null)
                {
                    redemptionDescriptionList.Add(this.CreateRedemptionDescription(affectedEntries: entries));
                }
            }

            return redemptionDescriptionList;
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wishing you a year full of blessing and filled with a new adventure.&lt;/p&gt;
&lt;p&gt;Happy new year 2023!&lt;/p&gt;
&lt;p&gt;Cheers&#129346;&lt;/p&gt;</id><updated>2023-01-03T16:05:53.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Delete unused properties and content types in CMS 12</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2022/10/delete-unused-properties-and-content-types-in-cms-12/" /><id>&lt;p&gt;The purpose of this blog is to delete unused properties, content references, and content types programmatically and keep clean content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: I have created a block type (e.g. TeaserBlock) and using this block created multiple contents and used it in different places, but after some time the requirement was changed and I removed this block type completely from the code. So for cleanup, we need to remove this block type from the Admin --&amp;gt; Content Types area in CMS because it no longer exists. But when I tried to delete it, we got content reference warnings because we already created content using a specific block type and added those references at many places (e.g. Main Content Area of other pages).&lt;/p&gt;
&lt;p&gt;Then the question comes to mind how to delete it? So I tried the following solution and fixed it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/80aa19bf006d491caf7e754c575e93f5.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;We have two options two remove the missing content type and its references:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Remove all references from each content type and then delete it from the Admin -&amp;gt; Content Types area. - This is a time-consuming activity because you need to visit and delete each content type (moving and emptying the Trash folder).&lt;/li&gt;
&lt;li&gt;Write the code and clean up it programmatically.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I am using the second (2) option to achieve this.&lt;/p&gt;
&lt;p&gt;Where do you write the code? I will suggest in the &lt;strong&gt;Initialization Module&lt;/strong&gt; or create a&lt;strong&gt; Schedule Job &lt;/strong&gt;to delete the unused properties and content types. It&amp;rsquo;s totally up to you&amp;nbsp; :)&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;span style=&quot;font-size:&amp;#32;12pt;&quot;&gt;Delete the missing properties which are no longer exist in the code for Content Type (PageType/BlockType): (e.g. TeaserBlock -&amp;gt; Sub Title)&lt;/span&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; private void DeleteUnUsedProperties()
 {
            var pageTypeRepository = ServiceLocator.Current.GetInstance&amp;lt;IContentTypeRepository&amp;lt;PageType&amp;gt;&amp;gt;();
            var propertyDefinitionRepository = ServiceLocator.Current.GetInstance&amp;lt;IPropertyDefinitionRepository&amp;gt;();
            foreach (var type in pageTypeRepository.List())
            {
                foreach (var property in type.PropertyDefinitions)
                {
                    if (property != null &amp;amp;&amp;amp; !property.ExistsOnModel)
                    {
                        propertyDefinitionRepository.Delete(property);
                    }
                }

               this.DeleteContentType(type);
            }

            var blockTypeRepository = ServiceLocator.Current.GetInstance&amp;lt;IContentTypeRepository&amp;lt;BlockType&amp;gt;&amp;gt;();
            foreach (var type in blockTypeRepository.List())
            {
                foreach (var property in type.PropertyDefinitions)
                {
                    if (property != null &amp;amp;&amp;amp; !property.ExistsOnModel)
                    {                     
                        propertyDefinitionRepository.Delete(property);
                    }
                }

                this.DeleteContentType(type);
            }
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;&lt;span style=&quot;font-size:&amp;#32;12pt;&quot;&gt;Delete the content references and missing content type (e.g. TeaserBlock)&lt;/span&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;  private void DeleteContentType(ContentType contentType)
  {
            if (contentType.ModelType != null)
            {
                return;
            }

            if (contentType.Saved != null &amp;amp;&amp;amp;
                contentType.Created.HasValue &amp;amp;&amp;amp;
                contentType.Created.Value.Year &amp;gt; 2021 &amp;amp;&amp;amp;
                contentType.IsAvailable)
            {
                // Find and deletes the content based on type.
                var contentModelUsage = ServiceLocator.Current.GetInstance&amp;lt;IContentModelUsage&amp;gt;();
                var contentUsages = contentModelUsage.ListContentOfContentType(contentType);
                var contentReferences = contentUsages
                    .Select(x =&amp;gt; x.ContentLink.ToReferenceWithoutVersion())
                    .Distinct();

                var contentRepository = ServiceLocator.Current.GetInstance&amp;lt;IContentRepository&amp;gt;();
                foreach (var contentItem in contentReferences)
                {
                    contentRepository.Delete(contentItem, true, EPiServer.Security.AccessLevel.NoAccess);
                }

                // Delete type of content.
                var contentTypeRepository = ServiceLocator.Current.GetInstance&amp;lt;IContentTypeRepository&amp;gt;();
                if (contentType.ID &amp;gt; 4)
                {
                    contentTypeRepository.Delete(contentType.ID);
                }
            }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: CotentType.ID &amp;gt; 4 means this will exclude the system/predefined page types e.g. Root Page.&lt;/p&gt;
&lt;p&gt;Please leave your feedback in the comment box.&lt;/p&gt;
&lt;p&gt;Thanks for your visit!&lt;/p&gt;</id><updated>2022-10-08T03:22:33.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Create a new Optimizely Commerce 14 Project</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2021/12/create-a-new-optimizely-commerce-14-project/" /><id>&lt;p&gt;The purpose of blog to create a new Optimizely commerce project in version &lt;strong&gt;14.x&lt;/strong&gt; using dotnet-episerver CLI and helps to remember the steps for developer to create a new commerce project. I have also mentioned few errors solution which I experienced during the project setup.&lt;/p&gt;
&lt;p&gt;Before to jump on steps make sure the &lt;a href=&quot;/link/b351930dd81d451ba795ac042b56a63c.aspx&quot;&gt;development environment&lt;/a&gt; &amp;amp; &lt;a href=&quot;/link/15a45be2c1664fde9b1c588592fa99f6.aspx&quot;&gt;system requirement&lt;/a&gt; is ready.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Install EPiServer templates:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;dotnet new -i EPiServer.Net.Templates --nuget-source https://nuget.optimizely.com/feed/packages.svc/ --force&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;--force:&lt;/strong&gt;&amp;nbsp;forces the modification and overwriting of required files for installation if required files exist.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;nbsp;&lt;strong&gt;Install EPiServer CLI:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;dotnet tool install EPiServer.Net.Cli --global --add-source https://nuget.optimizely.com/feed/packages.svc/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Open the Visual Studio and click on Create a new project link and search &amp;ldquo;episerver&amp;rdquo;, you will see EPiServer installed templates for both CMS and Commerce. We are going to create a commerce project so choose &amp;ldquo;Episerver Commerce Empty Project&amp;rdquo; and click on next.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/3f2fc87e526f4cb6ad90fcfe07fabe03.aspx&quot; width=&quot;752&quot; height=&quot;271&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Enter the project name e.g. QuickDemo and create.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/3c807a16ed7a4c18bd1574ec82163997.aspx&quot; width=&quot;337&quot; height=&quot;338&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Create database with admin user.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Open the package manager console in visual studio and select default project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you are using other command prompt, then reach out the project location using &lt;strong&gt;cd&lt;/strong&gt; command e.g., &amp;lsquo;C:\Optimizely\QuickDemo&amp;rsquo; and type EPiServer cli commands as mentioned below.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CMS Database:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;dotnet-episerver create-cms-database QuickDemo.csproj -S [ServerName] -U [ServerLoginUserName] -P [ServerLoginPassword] -dn QuickDemoCms -du [DataBaseUserName] -dp [DataBasePassword]&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&amp;nbsp;&lt;strong&gt;Commerce Database:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;dotnet-episerver create-commerce-database QuickDemo.csproj -S [ServerName] -U [ServerLoginUserName] -P [ServerLoginPassword] -dn QuickDemoCommerce -du [DataBaseUserName] -dp [DataBasePassword]&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Add admin user&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;dotnet-episerver add-admin-user QuickDemo.csproj -u admin -p Episerver123! -e admin@example.com -c EcfSqlConnection&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&amp;nbsp; -c = connection string name is the case sensitive so make sure the name is same as in your appsetting.json&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-S:&lt;/strong&gt; Database server name&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-U:&lt;/strong&gt; Database server login username&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-P:&lt;/strong&gt; Database server login password&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-dn:&lt;/strong&gt; Database name e.g. epiCms&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-du:&lt;/strong&gt; Database username e.g. sa&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-dp: &lt;/strong&gt;Database password&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-u:&lt;/strong&gt; Admin user username/loginname e.g. admin or email&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-p:&lt;/strong&gt; Admin user password&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-e:&lt;/strong&gt; Admin user email&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;-c:&lt;/strong&gt; connection string name e.g. EcfSqlConnection&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Setup the launch browser for empty site in debug mode and press ctr+f5 or f5 (in windows) to run the site.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For login use &lt;strong&gt;/util/login&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;For CMS view type:&lt;strong&gt; /episerver/cms&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/1ce516ddbb4d48d49b743e0f33ba86ce.aspx&quot; width=&quot;625&quot; height=&quot;344&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 5: &lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enable Commerce Tab: &lt;/strong&gt;Once you login into the CMS area then make sure the &lt;img src=&quot;/link/070fdf6e1ce64b989a9beba7a0862c1b.aspx&quot; /&gt; &amp;nbsp;icon is visible for you if not exist then follow up the below steps and enable it&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Add new Administrators group:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Admin --&amp;gt; Access Rights --&amp;gt; Administer Groups --&amp;gt; And create a new Administrators group&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/8e15dde184b94fc9a746f303442dc2b5.aspx&quot; width=&quot;583&quot; height=&quot;214&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Assign the Administrators group to the created user:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Admin --&amp;gt; Access Rights --&amp;gt; Admin Users --&amp;gt; Click on the username&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/ce504e1b5301405b918be0c89928f7d5.aspx&quot; width=&quot;495&quot; height=&quot;446&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Now logout and login again then you will see the missing dotted icon on top navigation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/2d8fde655c5746a088d826dc73c867e3.aspx&quot; width=&quot;577&quot; height=&quot;112&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Okay, now time to visit into the commerce area so click on the dotted icon and select the commerce tab and you will see the new features of commerce 14.x.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;---&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;[Optional]&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;During the commerce empty project setup I faced below errors which is highlighted with the solution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Error 1:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Are you getting &amp;ldquo;Something Went Wrong&amp;rdquo; error when entering in commerce area?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/53d7bb6e95b548acb89fa3e9a4af4ebf.aspx&quot; width=&quot;519&quot; height=&quot;259&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set the access rights for catalog root node&lt;strong&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class EnableCatalogRoot
{
        private readonly IContentLoader _contentLoader;
        private readonly ReferenceConverter _referenceConverter;
        private readonly IContentSecurityRepository _contentSecurityRepository;

        public EnableCatalogRoot(
            IContentLoader contentLoader,
            ReferenceConverter referenceConverter,
            IContentSecurityRepository contentSecurityRepository)
        {
            _contentLoader = contentLoader;
            _referenceConverter = referenceConverter;
            _contentSecurityRepository = contentSecurityRepository;
        }

        public void SetCatalogAccessRights()
        {
            if (_contentLoader.TryGet(_referenceConverter.GetRootLink(), out IContent content))
            {
                var contentSecurable = (IContentSecurable)content;
                var writableClone = (IContentSecurityDescriptor)contentSecurable.GetContentSecurityDescriptor().CreateWritableClone();
                writableClone.AddEntry(new AccessControlEntry(Roles.Administrators, AccessLevel.FullAccess, SecurityEntityType.Role));
                writableClone.AddEntry(new AccessControlEntry(Roles.WebAdmins, AccessLevel.FullAccess, SecurityEntityType.Role));
                writableClone.AddEntry(new AccessControlEntry(EveryoneRole.RoleName, AccessLevel.Read, SecurityEntityType.Role));

                _contentSecurityRepository.Save(content.ContentLink, writableClone, SecuritySaveType.Replace);
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Register the class into Startup.cs&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;e.g. services.AddSingleton&amp;lt;EnableCatalogRoot&amp;gt;();&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Call the SetCatalogAccessRights() method into the SiteInitialization.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/18202b811ade427c9097a8333d914e1d.aspx&quot; width=&quot;585&quot; height=&quot;129&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Now error is no more after the running the site.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Error 2:&amp;nbsp; &amp;ldquo;An error occurred while starting the application&amp;rdquo;&amp;nbsp;&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/0aefa5824e0342b38b8967a812d42c53.aspx&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Upgrade or downgrade &lt;strong&gt;Configuration.ConfigurationManager&lt;/strong&gt; according to your version 5.0.0.0 or 6.0.0.0 in my case I used 5.0.0.0 version.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Merry Christmas!!!&lt;/strong&gt;&lt;/p&gt;</id><updated>2021-12-16T10:48:08.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Optimizely Developers Meetup India - 26th July</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2021/7/optimizely-developers-meetup-india/" /><id>&lt;p&gt;&lt;span style=&quot;font-size:&amp;#32;14pt;&quot;&gt;&lt;strong&gt;Monday, July 26, 2021, 11:30 AM to 12:30 PM (IST)&amp;nbsp;&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;RSVP: &lt;a href=&quot;https://www.meetup.com/Optimizely-formerlyEpiserver-Meetup-India/events/279586090/&quot;&gt;https://www.meetup.com/Optimizely-formerlyEpiserver-Meetup-India/events/279586090/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agenda:&lt;/strong&gt;&lt;span&gt;&lt;br /&gt;Welcome and introductions.&lt;br /&gt;Optimizely commerce walkthrough&lt;br /&gt;Commerce 14.x highlights&lt;/span&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;Your presence will be highly appreciated.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</id><updated>2021-07-21T14:18:11.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Update GeoIP2 database automatically</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2021/5/update-geoip2-database-automatically/" /><id>&lt;p&gt;The purpose of the blog to update the latest GeoIP2 database automatically. Because the GeoLite2 Country, City, and ASN databases are updated weekly, every Tuesday of the week. So we required to update the database up to date, which we can accomplish through the schedule job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;About GeoIP2:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MaxMind GeoIP2 offerings provide IP geolocation and proxy detection for a wide range of applications including content customization, advertising, digital rights management, compliance, fraud detection, and security.&lt;/p&gt;
&lt;p&gt;The GeoIP2 Database MaxMind provides both binary and CSV databases for GeoIP2. Both formats provide additional data not available in our legacy databases including localized names for cities, subdivisions, and countries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Requirement:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In one of my current projects, we required to read the zip code/postal code on basis of the client IP Address and populate into the address area. Similarly, you can retrieve the Country, City Name, Latitude &amp;amp; Longitude, Metro Code etc.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution: &lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create the schedule job and download GeoLiteCity.dat.gz.&lt;/li&gt;
&lt;li&gt;Uncompressed the file and copy the &lt;strong&gt;GeoLite2-City.mmdb&lt;/strong&gt; database file on physical location which you have define in basePath.&lt;/li&gt;
&lt;li&gt;Read the client IP Address.&lt;/li&gt;
&lt;li&gt;Read the downloaded &lt;strong&gt;GeoLite2-City.mmdb&lt;/strong&gt; database through &lt;em&gt;DatabaseReader&lt;/em&gt; and search the IP address into city database and retrieve zip code/postal code.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Okay, so let&#39;s do some coding for achieving the above approaches:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step1: &lt;/strong&gt;Create the schedule job.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class GeoIp2DataBaseUpdateJob : ScheduledJobBase
{
        private bool _stopSignaled;
        private const string GeoLiteCityFileDownloadUrl = &quot;https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&amp;amp;suffix=tar.gz&quot;;

        public GeoIp2DataBaseUpdateJob()
        {
            this.IsStoppable = true;
        }

        /// &amp;lt;summary&amp;gt;
        /// Called when a user clicks on Stop for a manually started job, or when ASP.NET shuts down.
        /// &amp;lt;/summary&amp;gt;
        public override void Stop()
        {
            base.Stop();
            _stopSignaled = true;
        }

        /// &amp;lt;summary&amp;gt;
        /// Called when a scheduled job executes
        /// &amp;lt;/summary&amp;gt;
        /// &amp;lt;returns&amp;gt;A status message to be stored in the database log and visible from admin mode&amp;lt;/returns&amp;gt;
        public override string Execute()
        {
                   // 1. Download file
            var result = DownloadFile()
                // 2. Unzip file
                .Bind(UnzipFile)
                // 3. Replace at physical location
                .Bind(ReplaceCurrentFile)
                .Match(
                    right =&amp;gt; $&quot;&#128077; GeoIP2 database updated successfully.&quot;,
                    left =&amp;gt; $&quot;&#128078; GeoIP2 database update failed.&quot;);

            if (_stopSignaled)
            {
                return &quot;Stop of job was called&quot;;
            }

            return result;
        }

        private static Either&amp;lt;Exception, string&amp;gt; DownloadFile()
        {
            var licenseKey = ConfigurationManager.AppSettings[&quot;Geolocation.LicenseKey&quot;];
            var uri = new Uri(GeoLiteCityFileDownloadUrl + $&quot;&amp;amp;license_key={licenseKey}&quot;);

            return Prelude.Try(
                    () =&amp;gt;
                    {
                        using (var client = new WebClient())
                        {
                            var tempDir = Path.GetDirectoryName(Path.GetTempPath());
                            var localFile = Path.Combine(tempDir, &quot;MaxMind/GeoLite2-City.tar.gz&quot;);
                            var maxMindFolderPath = Path.GetDirectoryName(localFile);
                            if (!Directory.Exists(maxMindFolderPath) &amp;amp;&amp;amp; !string.IsNullOrWhiteSpace(maxMindFolderPath))
                            {
                                Directory.CreateDirectory(maxMindFolderPath);
                            }

                            client.DownloadFile(uri, localFile);
                            return localFile;
                        }
                    })
                .Succ(localFile =&amp;gt; Prelude.Right&amp;lt;Exception, string&amp;gt;(localFile))
                .Fail(ex =&amp;gt; Prelude.Left&amp;lt;Exception, string&amp;gt;(ex));
        }

        private static Either&amp;lt;Exception, string&amp;gt; ReplaceCurrentFile(string unzippedFileName)
        {
            return Prelude.Try(
                    () =&amp;gt;
                    {
                        var maxMindDbFilePath = Path.Combine(EPiServerFrameworkSection.Instance.AppData.BasePath, &quot;MaxMind/GeoLite2-City.mmdb&quot;);
                        File.Copy(unzippedFileName, maxMindDbFilePath, true);

                        //Delete extracted folder
                        var dir = Path.GetDirectoryName(unzippedFileName);
                        if (Directory.Exists(dir))
                        {
                            Directory.Delete(dir, true);
                        }
                        return unzippedFileName;

                    })
                .Succ(fileName =&amp;gt; Prelude.Right&amp;lt;Exception, string&amp;gt;(fileName))
                .Fail(ex =&amp;gt; Prelude.Left&amp;lt;Exception, string&amp;gt;(ex));
        }

        private static Either&amp;lt;Exception, string&amp;gt; UnzipFile(string downloadedFileName)
        {
            return Prelude.Try(
                    () =&amp;gt;
                    {
                        var dir = Path.GetDirectoryName(downloadedFileName);
                        FileInfo tarFileInfo = new FileInfo(downloadedFileName);

                        DirectoryInfo targetDirectory = new DirectoryInfo(dir ?? string.Empty);
                        if (!targetDirectory.Exists)
                        {
                            targetDirectory.Create();
                        }
                        using (Stream sourceStream = new GZipInputStream(tarFileInfo.OpenRead()))
                        {
                            using (TarArchive tarArchive = TarArchive.CreateInputTarArchive(sourceStream, TarBuffer.DefaultBlockFactor))
                            {
                                tarArchive.ExtractContents(targetDirectory.FullName);
                            }
                        }

                        var filePath = Directory.GetFiles(dir ?? string.Empty, &quot;*.mmdb&quot;, SearchOption.AllDirectories)?.LastOrDefault();

                        //Delete .tar.gz file
                        if (File.Exists(downloadedFileName))
                        {
                            File.Delete(downloadedFileName);
                        }
                        return filePath;
                    })
                .Succ(fileName =&amp;gt; Prelude.Right&amp;lt;Exception, string&amp;gt;(fileName))
                .Fail(ex =&amp;gt; Prelude.Left&amp;lt;Exception, string&amp;gt;(ex));
        }

    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2: &lt;/strong&gt;Update the web.config settings&lt;strong&gt;&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Set the database file path for geolocation provider if you are using for personalization.&lt;/li&gt;
&lt;li&gt;Add the &lt;em&gt;basePath &lt;/em&gt;for updating the file on a given physical location.&lt;/li&gt;
&lt;li&gt;Set MaxMind license key&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;episerver.framework&amp;gt;
  ...
  &amp;lt;geolocation defaultprovider=&quot;maxmind&quot;&amp;gt;
    &amp;lt;providers&amp;gt;
      &amp;lt;add databasefilename=&quot;C:\Program Files (x86)\MaxMind\GeoLite2-City.mmdb&quot; name=&quot;maxmind&quot; type=&quot;EPiServer.Personalization.Providers.MaxMind.GeolocationProvider, EPiServer.ApplicationModules&quot;&amp;gt;
    &amp;lt;/add&amp;gt;&amp;lt;/providers&amp;gt;
  &amp;lt;/geolocation&amp;gt;
  &amp;lt;appData basePath=&quot;C:\Program Files (x86)\MaxMind&quot; /&amp;gt;
&amp;lt;/episerver.framework&amp;gt;

&amp;lt;appSettings&amp;gt;
 ...
 &amp;lt;add key=&quot;Geolocation.LicenseKey&quot; value=&quot;{YourLicenseKey}&quot; 
&amp;lt;/appSettings&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Run the job and make sure the file is updating, if the code cause the error then update accordingly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt; Step4: &lt;/strong&gt;Create the &lt;em&gt;GeoLocationUtility &lt;/em&gt;class and read the database file for client IP Address.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; public class GeoLocationUtility
 {
        private static string _maxMindDatabaseFileName = &quot;GeoLite2-City.mmdb&quot;;

        public static GeoLocationViewModel GetGeoLocation(IPAddress address, NameValueCollection config)
        {
            string text = config[&quot;databaseFileName&quot;];

            if (!string.IsNullOrEmpty(text))
            {
                _maxMindDatabaseFileName = VirtualPathUtilityEx.RebasePhysicalPath(text);
                config.Remove(&quot;databaseFileName&quot;);
            }

            if (string.IsNullOrWhiteSpace(_maxMindDatabaseFileName)
                || !File.Exists(_maxMindDatabaseFileName)
                || address.AddressFamily != AddressFamily.InterNetwork &amp;amp;&amp;amp; address.AddressFamily != AddressFamily.InterNetworkV6)
            {
                return null;
            }

            var reader = new DatabaseReader(_maxMindDatabaseFileName);
            try
            {
                var dbResult = reader.City(address);
                var result = GeoLocationViewModel.Make(dbResult);
                return result;
            }
            catch
            { 
                //ignore exception
            }
            finally
            {
                reader.Dispose();
            }

            return null;
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 5: &lt;/strong&gt;Finally, call the utility method where you want to get the zip code/postal code value such as:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;  var maxMindDbFilePath = Path.Combine(EPiServerFrameworkSection.Instance.AppData.BasePath, &quot;MaxMind/GeoLite2-City.mmdb&quot;);
   var config = new NameValueCollection
   {
    {&quot;databaseFileName&quot;, maxMindDbFilePath}
   };

   var  postalCode= GeoLocationUtility.GetGeoLocation(ipAddress, config)?.PostalCode;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I hope you found this informative and helpful, Please leave your valuable comments in the comment box.&lt;/p&gt;
&lt;p&gt;Thanks for your visit!&lt;/p&gt;</id><updated>2021-05-04T06:49:16.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Extends order status in Episerver Commerce</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2021/2/extends-order-status-in-episerver-commerce/" /><id>&lt;p&gt;The purpose of the blog to create a new custom order status into the Episerver commerce and display that order status into commerce manager area for purchase order.&lt;/p&gt;
&lt;p&gt;Typically, EPiServer commerce provides some default order status e.g., OnHold, PartiallyShipped, InProgress, Completed, Cancelled and AwaitingExchange but in one of my project we need to create a new order status with the name of &amp;lsquo;Open&amp;rsquo; and display that order status into commerce area after the order submit or convert from cart to purchase order (_orderRepository.SaveAsPurchaseOrder(cart)).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note: &lt;/strong&gt;The extendable order status is available in Episerver Commerce Version 13&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/614f99ac5756479699256279b768207d.aspx&quot; width=&quot;1016&quot; height=&quot;352&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ticket Reference&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;The same issue has been reported in this ticket.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/link/fa763174b6114902847607de46244fd6.aspx&quot;&gt;https://world.episerver.com/forum/developer-forum/Episerver-Commerce/Thread-Container/2020/1/changing-order-status-away-from-a-custom-order-status/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Okay, So let&#39;s solve the problem with help of below steps...&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt;: Create an order status custom helper class.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public static class OrderStatusCustom
{
  public static readonly OrderStatus Open = new OrderStatus(1001, &quot;Open&quot;); 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2: &lt;/strong&gt;&amp;nbsp;Register the new order status into Episerver commerce initialization process.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    [ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
    public class CustomOrderStatusInitialization : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
           var customOrderStatus = OrderStatusCustom.Open;
            OrderStatus.RegisterStatus(customOrderStatus);       
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt;: Set the custom order status for the purchase order and save it on the order submit action.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var orderRepository = ServiceLocator.Current.GetInstance&amp;lt;IOrderRepository&amp;gt;();
var po = _orderRepository.Load&amp;lt;IPurchaseOrder&amp;gt;(orderLink.OrderGroupId);
po.OrderStatus = OrderStatusCustom.Open;
orderRepository.Save(purchaseOrder);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From the step 1 to 3 we have changed the purchased order status from &#39;InProgress&#39; (default) to &#39;Open&#39;. Now times to display the custom order status in the commerce manager for the purchase order detail and list.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Add the order status string into the listed localization files in the format &lt;strong&gt;OrderStatus_&lt;/strong&gt;[CustomOrderStatusName]&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;App_GlobalResources\Orders\OrderStrings.resx&amp;nbsp;&lt;/li&gt;
&lt;li&gt;App_GlobalResources\SharedStrings.resx e.g.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Key: OrderStatus_Open&amp;nbsp;&lt;br /&gt;Value: Open&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 5:&amp;nbsp;&lt;/strong&gt; The order status display process in commerce area is like a hacking process where we need to change some existing files and place their own code.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/e6a2f579aa5348efa37a2ecf6835ce28.aspx&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Include the &lt;strong&gt;/Apps/Order/CustomPrimitives/OrderStatusTitle.ascx&lt;/strong&gt; &amp;amp; &lt;strong&gt;/Apps/Order/GridTemplatesOrderStatusTemplate.ascx&lt;/strong&gt; files into the CommerceManager project.&lt;/li&gt;
&lt;li&gt;Create &lt;strong&gt;OrderStatusTitle.cs &lt;/strong&gt;and &lt;strong&gt;GridTemplatesOrderStatusTemplate.cs&lt;/strong&gt; file and replace the code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;OrderStatusTitle.cs&amp;nbsp;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;public class OrderStatusTitle : UserControl, IFormDocumentControl
{
        protected Label Label8;
        protected Label lblOrderStatus;
        protected Label Label1;
        protected Label lblCouponCode;

        public void LoadControlValues(object Sender)
        {
            IOrderGroup orderGroup = (IOrderGroup)Sender;
            if (orderGroup == null)
                return;

            var status = (string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, typeof(OrderStatus).Name + &quot;_&quot; + ((OrderGroup)orderGroup).Status);
            if (string.IsNullOrEmpty(status))
            {
                status = (string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, typeof(OrderStatus).Name + &quot;_&quot; + OrderStatusManager.GetOrderGroupStatus(orderGroup));
            }
            this.lblOrderStatus.Text = status;
        }

        public void SaveControlValues(object Sender)
        {
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;GridTemplatesOrderStatusTemplate.cs&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class OrderStatusTemplate : Mediachase.Commerce.Manager.Apps.Order.GridTemplates.OrderStatusTemplate
{
        protected void Page_Load(object sender, EventArgs e)
        {
            string str1 = this.DataItem is DataRowView ? &quot;&quot; : &quot;[undefined]&quot;;
            string str2 = string.Empty;
            IOrderGroup dataItem = this.DataItem as IOrderGroup;
            if (dataItem != null)
            {
                str1 = (string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, typeof(OrderStatus).Name + &quot;_&quot; + ((OrderGroup)dataItem).Status);
                if (string.IsNullOrEmpty(str1))
                {
                    str1 = (string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, typeof(OrderStatus).Name + &quot;_&quot; + OrderStatusManager.GetOrderGroupStatus(dataItem));
                }

                List&amp;lt;string&amp;gt; source = new List&amp;lt;string&amp;gt;();
                if (dataItem is IPurchaseOrder purchaseOrder)
                {
                    if (purchaseOrder.HasAwaitingStockReturns())
                        source.Add((string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, &quot;OrderStatus_HaveAwaitingStockReturns&quot;));
                    if (purchaseOrder.HasAwaitingReturnCompletable())
                        source.Add((string)this.GetGlobalResourceObject(&quot;OrderStrings&quot;, &quot;OrderStatus_HaveAwaitingReturnCompletable&quot;));
                    if (source.Count&amp;lt;string&amp;gt;() &amp;gt; 0)
                        str2 = &quot;(&quot; + string.Join(&quot;,&quot;, source.ToArray()) + &quot;)&quot;;
                }
            }
            this.label1.Text = str1;
            if (string.IsNullOrEmpty(str2))
                return;
            this.divAdditionalStatus.Visible = true;
            this.label2.Text = str2;
        }
 }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Now open the OrderStatusTitle.ascx file and change the &#39;XXXXXXX&#39; &lt;strong&gt;Inherits&lt;/strong&gt; from the qualified namespace of commerce project e.g. QuickSilver.Commerce.Apps.Order.CustomPrimitives.OrderStatusTitle&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp; &amp;nbsp;&amp;nbsp; &amp;nbsp;&amp;nbsp; &amp;nbsp;&lt;code&gt;&amp;lt;%@ Control Language=&quot;C#&quot; AutoEventWireup=&quot;true&quot; CodeBehind=&quot;OrderStatusTitle.ascx.cs&quot; Inherits=&quot;XXXXXXX.Apps.Order.CustomPrimitives.OrderStatusTitle&quot; %&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Repeat the same process for GridTemplatesOrderStatusTemplate.acsx&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RESULT :&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Purchase order List:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/1653f96b80c6484192db435877eab02e.aspx&quot; width=&quot;1001&quot; height=&quot;173&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Purchase order Detail:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c3f2635f2270473882255a3d196c2b8a.aspx&quot; width=&quot;992&quot; height=&quot;454&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Enjoy the coding and share your thoughts &#128522;&lt;/p&gt;
&lt;p&gt;Thanks for your visit!&lt;/p&gt;</id><updated>2021-02-22T07:09:30.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Multilingual cart validation message </title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2021/1/validates-a-cart-in-multi-language/" /><id>&lt;p&gt;The purpose of this blog to display the cart validation messages market&#39;s language specific and make it user-friendly to the customer for the better understanding.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;About the Market in Episerver Commerce?&lt;/strong&gt;&lt;br /&gt;Market is a central of Episerver Commerce. A single site can have multiple markets, each with its own product catalog, language, currency, and promotions. Classes in this topic are available in the &lt;code&gt;Mediachase.Commerce&lt;/code&gt; or &lt;code&gt;Mediachase.Commerce.Markets&lt;/code&gt; namespaces.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt;&lt;br /&gt;In one of my site, I have implemented multi-market functionality and managed the content accordingly but there is no feature to display the cart validation message in readable format to the customer for market language specific.&lt;/p&gt;
&lt;p&gt;e.g.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;United State (en) : Display the cart validation message in &lt;strong&gt;English &lt;/strong&gt;language.&lt;/li&gt;
&lt;li&gt;France (fr) : Display the cart validation message in &lt;strong&gt;French &lt;/strong&gt;language. &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Episerver provides a class method &lt;code&gt;OrderValidationService.ValidateOrder(cart)&lt;/code&gt; to validate your cart before to save, using &lt;code&gt;IOrderRepository.Save(cart)&lt;/code&gt; method. With the help of this method we make sure the cart has enough quantity, prices are correct and up-to-date, and any promotions are applied correctly.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ValidateOrder(cart)&lt;/code&gt; method returns &lt;code&gt;IDictionary&amp;lt;ILineItem, IList&amp;lt;ValidationIssue&amp;gt;&amp;gt; &lt;/code&gt;validation issue per ILineItem but&amp;nbsp; &lt;code&gt;ValidationIssue&lt;/code&gt; is an enum type that returns validation message in below formats which are not user-friendly and market language specific.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CannotProcessDueToMissingOrderStatus&lt;/li&gt;
&lt;li&gt;RemovedDueToCodeMissing&lt;/li&gt;
&lt;li&gt;RemovedDueToNotAvailableInMarket&lt;/li&gt;
&lt;li&gt;RemovedDueToUnavailableCatalog&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So let&amp;rsquo;s make cart validation message user-friendly in the simple way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Create an XML file language specific and place the all possible cart validation message like below and placed into the &lt;strong&gt;lang &lt;/strong&gt;folder under the site root.&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot; standalone=&quot;yes&quot;?&amp;gt;
&amp;lt;languages&amp;gt;
  &amp;lt;language name=&quot;English&quot; id=&quot;en&quot;&amp;gt;
    &amp;lt;cart&amp;gt;
      &amp;lt;validation&amp;gt;
        &amp;lt;CannotProcessDueToMissingOrderStatus&amp;gt;Cannot process due to missing order status.&amp;lt;/CannotProcessDueToMissingOrderStatus&amp;gt;
        &amp;lt;RemovedDueToCodeMissing&amp;gt;The catalog entry code that maps to the line item has been removed or changed.&amp;lt;/RemovedDueToCodeMissing&amp;gt;
        &amp;lt;RemovedDueToNotAvailableInMarket&amp;gt;Item has been removed from the cart because it is not available in your market.&amp;lt;/RemovedDueToNotAvailableInMarket&amp;gt;
        &amp;lt;RemovedDueToUnavailableCatalog&amp;gt;Item has been removed from the cart because the catalog of this entry is not available.&amp;lt;/RemovedDueToUnavailableCatalog&amp;gt;
        &amp;lt;RemovedDueToUnavailableItem&amp;gt;Item has been removed from the cart because it is not available at this time.&amp;lt;/RemovedDueToUnavailableItem&amp;gt;
        &amp;lt;RemovedDueToInsufficientQuantityInInventory&amp;gt;Item has been removed from the cart because there is not enough available quantity.&amp;lt;/RemovedDueToInsufficientQuantityInInventory&amp;gt;
        &amp;lt;RemovedDueToInactiveWarehouse&amp;gt;Item has been removed from the cart because the selected warehouse is inactive.&amp;lt;/RemovedDueToInactiveWarehouse&amp;gt;
        &amp;lt;RemovedDueToMissingInventoryInformation&amp;gt;Item has been removed due to missing inventory information.&amp;lt;/RemovedDueToMissingInventoryInformation&amp;gt;
        &amp;lt;RemovedDueToInvalidPrice&amp;gt;Item has been removed due to an invalid price.&amp;lt;/RemovedDueToInvalidPrice&amp;gt;
        &amp;lt;RemovedDueToInvalidMaxQuantitySetting&amp;gt;Item has been removed due to an invalid setting for maximum quantity.&amp;lt;/RemovedDueToInvalidMaxQuantitySetting&amp;gt;
        &amp;lt;AdjustedQuantityByMinQuantity&amp;gt;Item quantity has been adjusted due to the minimum quantity threshold.&amp;lt;/AdjustedQuantityByMinQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByMaxQuantity&amp;gt;Item quantity has been adjusted due to the maximum quantity threshold.&amp;lt;/AdjustedQuantityByMaxQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByBackorderQuantity&amp;gt;Item quantity has been adjusted due to backorder quantity threshold.&amp;lt;/AdjustedQuantityByBackorderQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByPreorderQuantity&amp;gt;Item quantity has been adjusted due to the preorder quantity threshold.&amp;lt;/AdjustedQuantityByPreorderQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByAvailableQuantity&amp;gt;Item quantity has been adjusted due to the available quantity threshold.&amp;lt;/AdjustedQuantityByAvailableQuantity&amp;gt;
        &amp;lt;PlacedPricedChanged&amp;gt;This item&#39;s price has changed since it was added to your cart.&amp;lt;/PlacedPricedChanged&amp;gt;
        &amp;lt;RemovedGiftDueToInsufficientQuantityInInventory&amp;gt;Gift item has been removed from the cart because there is not enough available quantity.&amp;lt;/RemovedGiftDueToInsufficientQuantityInInventory&amp;gt;
        &amp;lt;RejectedInventoryRequestDueToInsufficientQuantity&amp;gt;The inventory request for item has been rejected because there is not enough available quantity.&amp;lt;/RejectedInventoryRequestDueToInsufficientQuantity&amp;gt;
      &amp;lt;/validation&amp;gt;
    &amp;lt;/cart&amp;gt;
  &amp;lt;/language&amp;gt;
  &amp;lt;language name=&quot;French&quot; id=&quot;fr&quot;&amp;gt;
    &amp;lt;cart&amp;gt;
      &amp;lt;validation&amp;gt;
        &amp;lt;CannotProcessDueToMissingOrderStatus&amp;gt;Il ne peut pas &amp;ecirc;tre trait&amp;eacute; en raison d&#39;un statut de commande manquant.&amp;lt;/CannotProcessDueToMissingOrderStatus&amp;gt;
        &amp;lt;RemovedDueToCodeMissing&amp;gt;Le code d&#39;entr&amp;eacute;e de catalogue qui correspond &amp;agrave; l&#39;&amp;eacute;l&amp;eacute;ment de campagne a &amp;eacute;t&amp;eacute; supprim&amp;eacute; ou modifi&amp;eacute;.&amp;lt;/RemovedDueToCodeMissing&amp;gt;
        &amp;lt;RemovedDueToNotAvailableInMarket&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car il n&#39;est pas disponible sur votre march&amp;eacute;.&amp;lt;/RemovedDueToNotAvailableInMarket&amp;gt;
        &amp;lt;RemovedDueToUnavailableCatalog&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car le catalogue de cette entr&amp;eacute;e n&#39;est pas disponible.&amp;lt;/RemovedDueToUnavailableCatalog&amp;gt;
        &amp;lt;RemovedDueToUnavailableItem&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car il n&#39;est pas disponible pour le moment.&amp;lt;/RemovedDueToUnavailableItem&amp;gt;
        &amp;lt;RemovedDueToInsufficientQuantityInInventory&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car la quantit&amp;eacute; disponible est insuffisante.&amp;lt;/RemovedDueToInsufficientQuantityInInventory&amp;gt;
        &amp;lt;RemovedDueToInactiveWarehouse&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car l&#39;entrep&amp;ocirc;t s&amp;eacute;lectionn&amp;eacute; est inactif.&amp;lt;/RemovedDueToInactiveWarehouse&amp;gt;
        &amp;lt;RemovedDueToMissingInventoryInformation&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; supprim&amp;eacute; en raison d&#39;informations d&#39;inventaire manquantes.&amp;lt;/RemovedDueToMissingInventoryInformation&amp;gt;
        &amp;lt;RemovedDueToInvalidPrice&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; supprim&amp;eacute; en raison d&#39;un prix non valide.&amp;lt;/RemovedDueToInvalidPrice&amp;gt;
        &amp;lt;RemovedDueToInvalidMaxQuantitySetting&amp;gt;L&#39;article a &amp;eacute;t&amp;eacute; supprim&amp;eacute; en raison d&#39;un param&amp;egrave;tre non valide pour la quantit&amp;eacute; maximale.&amp;lt;/RemovedDueToInvalidMaxQuantitySetting&amp;gt;
        &amp;lt;AdjustedQuantityByMinQuantity&amp;gt;La quantit&amp;eacute; d&#39;articles a &amp;eacute;t&amp;eacute; ajust&amp;eacute;e en raison du seuil de quantit&amp;eacute; minimale.&amp;lt;/AdjustedQuantityByMinQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByMaxQuantity&amp;gt;La quantit&amp;eacute; d&#39;articles a &amp;eacute;t&amp;eacute; ajust&amp;eacute;e en raison du seuil de quantit&amp;eacute; maximale.&amp;lt;/AdjustedQuantityByMaxQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByBackorderQuantity&amp;gt;La quantit&amp;eacute; d&#39;articles a &amp;eacute;t&amp;eacute; ajust&amp;eacute;e en raison du seuil de quantit&amp;eacute; de commandes en souffrance.&amp;lt;/AdjustedQuantityByBackorderQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByPreorderQuantity&amp;gt;La quantit&amp;eacute; d&#39;articles a &amp;eacute;t&amp;eacute; ajust&amp;eacute;e en raison du seuil de quantit&amp;eacute; de pr&amp;eacute;commande.&amp;lt;/AdjustedQuantityByPreorderQuantity&amp;gt;
        &amp;lt;AdjustedQuantityByAvailableQuantity&amp;gt;La quantit&amp;eacute; d&#39;articles a &amp;eacute;t&amp;eacute; ajust&amp;eacute;e en raison du seuil de quantit&amp;eacute; disponible.&amp;lt;/AdjustedQuantityByAvailableQuantity&amp;gt;
        &amp;lt;PlacedPricedChanged&amp;gt;Le prix de cet article a chang&amp;eacute; depuis qu&#39;il a &amp;eacute;t&amp;eacute; ajout&amp;eacute; &amp;agrave; vos favoris.&amp;lt;/PlacedPricedChanged&amp;gt;
        &amp;lt;RemovedGiftDueToInsufficientQuantityInInventory&amp;gt;L&#39;article cadeau a &amp;eacute;t&amp;eacute; retir&amp;eacute; du panier car la quantit&amp;eacute; disponible est insuffisante.&amp;lt;/RemovedGiftDueToInsufficientQuantityInInventory&amp;gt;
        &amp;lt;RejectedInventoryRequestDueToInsufficientQuantity&amp;gt;La demande d&#39;inventaire pour l&#39;article a &amp;eacute;t&amp;eacute; rejet&amp;eacute;e car la quantit&amp;eacute; disponible n&#39;est pas suffisante.&amp;lt;/RejectedInventoryRequestDueToInsufficientQuantity&amp;gt;
      &amp;lt;/validation&amp;gt;
    &amp;lt;/cart&amp;gt;
  &amp;lt;/language&amp;gt;
&amp;lt;/languages&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2: &lt;/strong&gt;Create a model class that will hold the error message and variant code.&lt;strong&gt;&lt;br /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class CartValidationIssue
{
        public string Message { get; set; }
 
        public string Code{ get; set; }

        public bool IsBlank =&amp;gt; string.IsNullOrWhiteSpace(this.Message);
       
        public static CartValidationIssue Make(string message, string code)
        {
            return new CartValidationIssue
            {
                Message = message,
                Code = code,
            };
        }
 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3: &lt;/strong&gt;Create described methods where you are validating your cart and returns the validation messages in the list format after reading from XML file and show on the cart page or mini-cart area.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;ICurrentMarket&lt;/code&gt; interface and get the current market culture&lt;/li&gt;
&lt;li&gt;Make sure you have selected correct default language for the current market in commerce manager for e.g. France choose default language &lt;em&gt;francais&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/1fa2f9c7ab8a4d1fa01689b17e515a0d.aspx&quot; width=&quot;556&quot; height=&quot;222&quot; /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public List&amp;lt;CartValidationIssue&amp;gt; ValidateCart(ICart cart)
{

            var validationResult = _orderValidationService.ValidateOrder(cart);

            var errors =
                validationResult
                    ?.Select(lineItemIssueEntry =&amp;gt; new
                    {
                        LineItemIssues =
                            lineItemIssueEntry.Value
                                .Select(validationIssue =&amp;gt; new
                                {
                                    ValidationIssueMessage = this.GetCartValidationMessage(validationIssue),
                                    LineItemCode = lineItemIssueEntry.Key.Code,
                                })
                                .ToList(),
                    })
                    .SelectMany(lineItemIssueGroup =&amp;gt; lineItemIssueGroup.LineItemIssues)
                    .Select(x =&amp;gt; CartValidationIssue.Make(x.ValidationIssueMessage, x.LineItemCode))
                    .Where(x =&amp;gt; !x.IsBlank)
                    .ToList() ?? new List&amp;lt;CartValidationIssue&amp;gt;();

            return errors;
 }&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; private string GetCartValidationMessage(ValidationIssue issue)
 {
            var market = _currentMarket.GetCurrentMarket();
            var cultureInfo = market.DefaultLanguage;

            switch (issue)
            {
                default:
                case ValidationIssue.None:
                    return null;

                case ValidationIssue.CannotProcessDueToMissingOrderStatus:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/CannotProcessDueToMissingOrderStatus&quot;, &quot;It cannot process due to missing order status.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToCodeMissing:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToCodeMissing&quot;, &quot;The catalog entry code that maps to the line item has been removed or changed.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToNotAvailableInMarket:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToNotAvailableInMarket&quot;, &quot;The catalog entry code that maps to the line item has been removed or changed.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToUnavailableCatalog:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToUnavailableCatalog&quot;, &quot;The catalog entry code that maps to the line item has been removed or changed.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToUnavailableItem:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToUnavailableItem&quot;, &quot;Item has been removed from the cart because it is not available at this time.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToInsufficientQuantityInInventory:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToInsufficientQuantityInInventory&quot;, &quot;Item has been removed from the cart because there is not enough available quantity.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToInactiveWarehouse:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToInactiveWarehouse&quot;, &quot;Item has been removed from the cart because the selected warehouse is inactive.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToMissingInventoryInformation:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToMissingInventoryInformation&quot;, &quot;Item has been removed due to missing inventory information.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToInvalidPrice:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToInvalidPrice&quot;, &quot;Item has been removed due to an invalid price.&quot;, cultureInfo);

                case ValidationIssue.RemovedDueToInvalidMaxQuantitySetting:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedDueToInvalidMaxQuantitySetting&quot;, &quot;Item has been removed due to an invalid setting for maximum quantity.&quot;, cultureInfo);

                case ValidationIssue.AdjustedQuantityByMinQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/AdjustedQuantityByMinQuantity&quot;, &quot;Item quantity has been adjusted due to the minimum quantity threshold&quot;, cultureInfo);

                case ValidationIssue.AdjustedQuantityByMaxQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/AdjustedQuantityByMaxQuantity&quot;, &quot;Item quantity has been adjusted due to the maximum quantity threshold.&quot;, cultureInfo);

                case ValidationIssue.AdjustedQuantityByBackorderQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/AdjustedQuantityByBackorderQuantity&quot;, &quot;Item quantity has been adjusted due to backorder quantity threshold.&quot;, cultureInfo);

                case ValidationIssue.AdjustedQuantityByPreorderQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/AdjustedQuantityByPreorderQuantity&quot;, &quot;Item quantity has been adjusted due to the preorder quantity threshold.&quot;, cultureInfo);

                case ValidationIssue.AdjustedQuantityByAvailableQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/AdjustedQuantityByAvailableQuantity&quot;, &quot;Item quantity has been adjusted due to the available quantity threshold.&quot;, cultureInfo);

                case ValidationIssue.PlacedPricedChanged:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/PlacedPricedChanged&quot;, &quot;This item&#39;s price has changed since it was added to your favorites.&quot;, cultureInfo);

                case ValidationIssue.RemovedGiftDueToInsufficientQuantityInInventory:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RemovedGiftDueToInsufficientQuantityInInventory&quot;, &quot;Gift item has been removed from the cart because there is not enough available quantity.&quot;, cultureInfo);

                case ValidationIssue.RejectedInventoryRequestDueToInsufficientQuantity:
                    return LocalizationService.Current.GetStringByCulture(&quot;/cart/validation/RejectedInventoryRequestDueToInsufficientQuantity&quot;, &quot;The inventory request for item has been rejected because there is not enough available quantity.&quot;, cultureInfo);
            }
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;e.g. &lt;/strong&gt;The validation message display for France(fr) market in the French language.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;img src=&quot;/link/a09257ddf81f44d08058111b8f53f13c.aspx&quot; width=&quot;668&quot; height=&quot;307&quot; /&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Enjoy the coding and share your thoughts &#128522;&lt;/p&gt;
&lt;p&gt;Thanks for your visit!&lt;/p&gt;</id><updated>2021-02-01T05:49:54.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Customize order management in commerce manager</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2020/10/customize-order-management-in-commerce-manager/" /><id>&lt;p&gt;The purpose of the blog to customize the order management UI in the commerce manager and handle out-of-box functionality.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;In this blog I am going to cover two scenarios:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Display the Customer Name into the Customer Information section from the shipping address (First Name + Last Name) if the order placed by an anonymous/guest user.&lt;img src=&quot;/link/e369a023eee4410ca7c98eed7dd61b0e.aspx&quot; /&gt;&lt;/li&gt;
&lt;li&gt;Add a complete order button within the order summary section and trigger the button event.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;nbsp;&lt;img src=&quot;/link/86b9c45f55824a5e9c160d678c7451b0.aspx&quot; width=&quot;653&quot; height=&quot;265&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The customization is based on the &lt;strong&gt;QuickSilver&lt;/strong&gt; solution but you can try the recommended file and code changes in your solution.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5bef62b679e04d6bb48ae82a57c3dcb6.aspx&quot; width=&quot;349&quot; height=&quot;535&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So, Let&amp;rsquo;s get start coding fun &#128522;&lt;/p&gt;
&lt;h4&gt;&lt;/h4&gt;
&lt;h3&gt;- Display the Customer Name into the Customer Information section from the shipping address (First Name + Last Name) if the order placed by an anonymous/guest user.&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Goto the EPiServer.Reference.Commerce.Manager site and expand the all folder as above screen and visit the folder.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;Folder Path:&lt;/em&gt; ..\EPiServer.Reference.Commerce.Manager\Apps\Order\CustomPrimitives&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Include the OrderCustomer.ascx and add a new class with the same name e.g. &amp;lsquo;OrderCustomer.cs&amp;rsquo;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/8832d4c684ab4fd1a680dcfafa26df12.aspx&quot; width=&quot;411&quot; height=&quot;416&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Open OrderCustomer.ascx and copy the highlighted &lt;strong&gt;Inherits&lt;/strong&gt; tag value (&lt;em&gt;Mediachase.Commerce.Manager.Apps.Order.CustomPrimitives.OrderCustomer&lt;/em&gt;)and create the OrderCustomer.cs class and derived from the inherits value.&lt;img src=&quot;/link/a6b02b07da2045a59d26d09da1426e27.aspx&quot; /&gt;&lt;/li&gt;
&lt;li&gt;Override the OnPreRender(EventArgs e) method and fetch the purchase order shipping address detail using the order helper class
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using System;
using EPiServer.Commerce.Order;
using Mediachase.Commerce.Manager.Apps_Code.Order;

namespace EPiServer.Reference.Commerce.Manager.Apps.Order.CustomPrimitives
{
    public class OrderCustomer : Mediachase.Commerce.Manager.Apps.Order.CustomPrimitives.OrderCustomer
    {
        protected override void OnPreRender(EventArgs e)
        {
            base.OnPreRender(e);
            if (string.IsNullOrEmpty(this.lblCustomerName.Text))
            {
                var purchaseOrder = this.OrderGroupId &amp;gt; 0
                    ? OrderHelper.GetPurchaseOrderById(this.OrderGroupId)
                    : null;

                var address = purchaseOrder?.GetFirstShipment()?.ShippingAddress;
                if (address != null)
                {
                    this.lblCustomerName.Text = $@&quot;{address.FirstName} {address.LastName}&quot;;
                }
            }
        }
    }
}
​&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;And in the last re-place the &lt;strong&gt;&lt;em&gt;Inherits&lt;/em&gt;&lt;/strong&gt; attribute value from your created class including full qualified namespace + class name.&lt;img src=&quot;/link/a67714cd51e348c58b933b2ed1462214.aspx&quot; /&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Result (1):&lt;/strong&gt; Now refresh the purchase order and see the changes.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/36a49e0d58fd45f8b5d88d74d2ade13b.aspx&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;- Add a complete order button within the order summary section and trigger the button event:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Goto the EPiServer.Reference.Commerce.Manager site and expand all folder and open the folder.\&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; Folder path:&lt;/em&gt; &amp;hellip;\EPiServer.Reference.Commerce.Manager\Apps\Order\Config\View\Forms.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Include the PurchaseOrder-ObjectView.xml in your solution, If you notice in this file you will find numbers are buttons are already added and display in the order summary section, so similarly add a new button with the command name &amp;lsquo;btn_CompleteOrderBtn&amp;rsquo; and permissions.&lt;img src=&quot;/link/f93ed9fef2de4074bdd01d4a3f3e288a.aspx&quot; width=&quot;1046&quot; height=&quot;318&quot; /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Create the CompletePurchaseOrderHandler class and inherit it from the TransactionCommandHandler and override the DoCommand method.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/64da555f1fad424c800c7eab3c8bd8d1.aspx&quot; width=&quot;440&quot; height=&quot;366&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can choose a transaction command handler on basis of the requirements e.g. purchase order, payment plan, and return form handler, etc...&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Commerce.Manager.Order.CommandHandlers.PurchaseOrderHandlers&lt;/li&gt;
&lt;li&gt;Commerce.Manager.Order.CommandHandlers.PaymentPlanHandlers&lt;/li&gt;
&lt;li&gt;Commerce.Manager.Order.CommandHandlers.ReturnFormHandlers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And also enable or disable the button on basis of requirement in the given example the IsCommandEnable method enables the button if the order status is InProgress.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;namespace EPiServer.Reference.Commerce.Manager.Features.Orders.OrderProcessing.Handlers
{
    public class CompletePurchaseOrderHandler: TransactionCommandHandler
    {
        protected override void DoCommand(IOrderGroup order, CommandParameters commandParameters)
        {
            order.OrderStatus = OrderStatus.Completed;
            var purchaseOrder = order as IPurchaseOrder;

            var shipments = purchaseOrder.GetFirstForm()?.Shipments?.ToList();
            
            if (shipments != null)
            {
                this.ShipmentProcessor.CompleteShipment(purchaseOrder, shipments);
            }

            this.SavePurchaseOrderChanges(purchaseOrder);
        }

        protected override bool IsCommandEnable(IOrderGroup order, CommandParameters cp)
        {
            // Enable button if 
            return order.OrderStatus.Equals(OrderStatus.InProgress) ;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Register the newly created button into the commands and add a handler and confirmation text that will ask for the confirmation before performing the action.&lt;img src=&quot;/link/5912abba34e9464b877fcfd828f9dee6.aspx&quot; /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Result (2):&lt;/strong&gt; Now build your solution and visit in the commerce area and refresh the purchase order screen, you will see a new button with the name &amp;lsquo;Complete Order&amp;rsquo; and when the user clicks on the button then the order will be mark as complete.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/11148cc0dd6447d4be2ae846de7d0422.aspx&quot; width=&quot;925&quot; height=&quot;383&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Enjoy the coding and share your thoughts &#128522;&lt;/p&gt;</id><updated>2020-10-07T13:51:37.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Exclusion of the partial routed folder/category from the Geta.Seo.Sitemaps</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2020/8/geta-seo-sitemap-customization/" /><id>&lt;p&gt;Purpose of the blog to exclude&amp;nbsp;&lt;strong&gt;routed&amp;nbsp;&lt;/strong&gt;commerce catalog folder/category content Urls from the&amp;nbsp;Geta.Seo.Sitemaps sitemap.xml before to it generates. This is out of box functionality in the current&amp;nbsp;Geta.Seo.Sitemaps version thus we have customized it using&amp;nbsp;the`CommerceSitemapXmlGenerator` or `CommerceAndStandardSitemapXmlGenerator` class in my current project for the solution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Introduction:&amp;nbsp;&amp;nbsp;&lt;/strong&gt;&lt;span&gt;This tool allows you to generate XML sitemaps for search engines to better index your EPiServer sites with&amp;nbsp;&lt;/span&gt;&lt;span&gt;some additional specific features.&lt;/span&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sitemap generation as a scheduled job&lt;/li&gt;
&lt;li&gt;filtering pages by virtual directories&lt;/li&gt;
&lt;li&gt;ability to include pages that are in a different branch than the one of the start page&lt;/li&gt;
&lt;li&gt;ability to generate sitemaps for mobile pages&lt;/li&gt;
&lt;li&gt;it also supports multi-site and multi-language environments&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;/link/278b1fe0add64c2d820a201defa9dcf0.aspx&quot; width=&quot;317&quot; height=&quot;239&quot; /&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;/link/b16fb2e5f2814c5db139cf11faccf6a8.aspx&quot; width=&quot;783&quot; height=&quot;385&quot; /&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: The Geta.Seo.Sitemaps is not able to&amp;nbsp;&lt;strong&gt;avoid&lt;/strong&gt;&amp;nbsp;the &#39;Services&#39; folder Urls from the sitemap.xml because&amp;nbsp;the folder is partially &lt;strong&gt;routed&lt;/strong&gt; using the IPartialRouter interface. And in the URLs, the folder name does not exist like&amp;nbsp;&lt;a href=&quot;https://www.xyz.com/services/laundry/dry-clean&quot;&gt;https://www.xyz.com/services/laundry/dry-clean&lt;/a&gt;,&amp;nbsp;&lt;a href=&quot;https://www.xyz.com/service/laundary/normal-clean&quot;&gt;https://www.xyz.com/service/laundary/normal-clean&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;However, the ServiceFolderPage is&amp;nbsp;already inherited from &lt;strong&gt;IExcludeFromSitemap &lt;/strong&gt;as below, but still not able to avoid/exclude content from the sitemap.xml.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ServiceFolderPage : NodeContent, IExcludeFromSitemap
{
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;:&amp;nbsp;&amp;nbsp;Avoid Urls&amp;nbsp;e.g.&amp;nbsp; &lt;a href=&quot;https://www.xyz.com/dry-clean&quot;&gt;https://www.xyz.com/dry-clean&lt;/a&gt;,&amp;nbsp;&lt;a href=&quot;https://www.xyz.com/normal-clean&quot;&gt;https://www.xyz.com/normal-clean&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;from the generated sitemap.xml because these are services folder content URLs.&lt;/p&gt;
&lt;p&gt;1. Create a utility class like CustomCatalogUrlFilter and pass the current language content and avoid folders list into the &lt;strong&gt;IsUrlFiltered &lt;/strong&gt;method.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;   public class CustomCatalogUrlFilter
    {
        private readonly IContentLoader _contentLoader;

        public CustomCatalogUrlFilter(IContentLoader contentLoader)
        {
            _contentLoader = contentLoader ?? throw new ArgumentNullException(nameof(contentLoader));
        }

        /// &amp;lt;summary&amp;gt;
        /// Gets whether the current content should be filtered out of the Sitemap.
        /// &amp;lt;/summary&amp;gt;
        public bool IsUrlFiltered(IContent page, IList&amp;lt;string&amp;gt; avoidPaths)
        {
            // If the inputs are bad, do nothing.
            if (page == null || avoidPaths?.Any() != true)
                return false;

            // Get the URL segments of the current page and all its ancestors.
            var ancestorsAndSelfRouteSegments =
                _contentLoader
                    .GetAncestorsAndSelf(page)
                    ?.OfType&amp;lt;IRoutable&amp;gt;()
                    .Select(x =&amp;gt; x.RouteSegment)
                    .Where(x =&amp;gt; string.IsNullOrWhiteSpace(x) == false)
                    .ToList();

            // If there are no route segments then something. Return false to be safe.
            if (ancestorsAndSelfRouteSegments?.Any() != true)
                return false;

            // Combine the route segments into a path:
            string pagePathUpper = string.Join(&quot;/&quot;, ancestorsAndSelfRouteSegments).ToUpperInvariant();

            // Check to see whether any path to avoid exists within the current page&#39;s path.
            foreach (string avoidPathUpper in avoidPaths)
            {
                if (string.IsNullOrWhiteSpace(avoidPathUpper))
                    continue;

                // If the page&#39;s path contains a path to avoid, then the page should be filtered out. Return true.
                if (pagePathUpper.Contains(avoidPathUpper.ToUpperInvariant()))
                    return true;
            }

            return false;
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2. Create a custom&amp;nbsp;sitemap XML generator class and derived from the`CommerceSitemapXmlGenerator` or `CommerceAndStandardSitemapXmlGenerator` and override `AddFilteredContentElement` method.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt; public class CustomCommerceCatalogSitemapXmlGenerator : CommerceSitemapXmlGenerator
    {
        private readonly CustomCatalogUrlFilter _customCatalogUrlFilter;

        public CustomCommerceCatalogSitemapXmlGenerator(
            ISitemapRepository sitemapRepository,
            IContentRepository contentRepository,
            UrlResolver urlResolver,
            ISiteDefinitionRepository siteDefinitionRepository,
            ILanguageBranchRepository languageBranchRepository,
            ReferenceConverter referenceConverter,
            IContentFilter contentFilter,
            CustomUrlFilter customCatalogUrlFilter)
            : base(
                sitemapRepository,
                contentRepository,
                urlResolver,
                siteDefinitionRepository,
                languageBranchRepository,
                referenceConverter,
                contentFilter)
        {
            _customCatalogUrlFilter = customCatalogUrlFilter ?? throw new ArgumentNullException(nameof(customCatalogUrlFilter));
        }

        protected override void AddFilteredContentElement(CurrentLanguageContent languageContentInfo, IList&amp;lt;XElement&amp;gt; xmlElements)
        {
            if (ContentFilter.ShouldExcludeContent(languageContentInfo, SiteSettings, SitemapData))
            {
                return;
            }

            var content = languageContentInfo.Content;
            string url;

            var localizableContent = content as ILocalizable;

            if (localizableContent != null)
            {
                string language = string.IsNullOrWhiteSpace(this.SitemapData.Language)
                    ? languageContentInfo.CurrentLanguage.Name
                    : this.SitemapData.Language;

                url = this.UrlResolver.GetUrl(content.ContentLink, language);

                if (string.IsNullOrWhiteSpace(url))
                {
                    return;
                }

                // Make 100% sure we remove the language part in the URL if the sitemap host is mapped to the page&#39;s LanguageBranch.
                if (this.HostLanguageBranch != null &amp;amp;&amp;amp; localizableContent.Language.Name.Equals(this.HostLanguageBranch, StringComparison.InvariantCultureIgnoreCase))
                {
                    url = url.Replace(string.Format(&quot;/{0}/&quot;, this.HostLanguageBranch), &quot;/&quot;);
                }
            }
            else
            {
                url = this.UrlResolver.GetUrl(content.ContentLink);

                if (string.IsNullOrWhiteSpace(url))
                {
                    return;
                }
            }

            url = GetAbsoluteUrl(url);

            var fullContentUrl = new Uri(url);

            if (this.UrlSet.Contains(fullContentUrl.ToString()) || UrlFilter.IsUrlFiltered(fullContentUrl.AbsolutePath, this.SitemapData))
            {
                return;
            }

            // Custom code added to make sure Folder Pages are not ignored when handling paths to avoid:
            if (_customCatalogUrlFilter.IsUrlFiltered(content, this.SitemapData.PathsToAvoid))
                return;

            XElement contentElement = this.GenerateSiteElement(content, fullContentUrl.ToString());

            if (contentElement == null)
            {
                return;
            }

            xmlElements.Add(contentElement);
            this.UrlSet.Add(fullContentUrl.ToString());
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note: I am assuming the routing is already done for the folder/category content which you want to exclude from the sitemap.xml.&lt;/p&gt;
&lt;p&gt;Enjoy!&lt;/p&gt;</id><updated>2020-08-02T19:57:19.0000000Z</updated><summary type="html">Blog post</summary></entry> <entry><title>Episerver A/B testing and visitor group (personalization) metadata into an analytics payload for consumption with the Google Analytics</title><link href="https://world.optimizely.com/blogs/sanjay-katiyar/dates/2020/3/episerver-ab-testing-and-visitor-group-personalization-metadata-into-an-analytics-payload-for-consumption-with-the-google-analytics/" /><id>&lt;p&gt;The purpose of this blog post is to retrieve the real-time A/B testing and visitor group (personalization) details and feed into the Google Analytics for tracking real-time content item progress on your website.&lt;/p&gt;
&lt;h4&gt;&lt;/h4&gt;
&lt;h3&gt;A/B TESTING&lt;/h3&gt;
&lt;p&gt;A/B Testing is the variations of content items and that compares which variation performs best. When A/B testing running then majors number the conversions for the A and B version, one the gets the best result then wins.&lt;/p&gt;
&lt;p&gt;Episerver CMS and Episerver Commerce each have own three conversion goals and developer can define custom conversion goals using KPI interface. A/B testing is distributed as free AddOn which you can download from NuGet but you need to add&amp;nbsp;&lt;a href=&quot;http://nuget.episerver.com/feed/packages.svc/&quot;&gt;http://nuget.episerver.com/feed/packages.svc/&lt;/a&gt;&amp;nbsp;in Nuget package manager before download. The name of NuGet package that installs under the &lt;em&gt;~/module&lt;/em&gt; folder is &lt;em&gt;EPiServer.Marketing.Testing&lt;/em&gt; after installation you need to update Episerver database schema.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Episerver CMS Conversions Goals:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Landing page: The goal for the visitor to navigate the specify page and only click is counted as a conversion.&lt;/li&gt;
&lt;li&gt;Site Stickiness: The A/B test counts a conversion if a visitor goes from the target page to any other page on the site during the set time period (1-60 minutes).&lt;/li&gt;
&lt;li&gt;Time on Page: Visitors spend some time on the page for specifying numbers second.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Episerver Commerce Conversions Goals:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add to cart: Create a visitor group for the specific product and then the visitor adds that product to a cart, it is counted as a conversion.&lt;/li&gt;
&lt;li&gt;Purchase product: If a site visitor buys the added products on the cart, then it is counted as a conversion.&lt;/li&gt;
&lt;li&gt;Average order: The conversion goal to track completed orders on each of the test pages. The conversion goal totals up the values of all Episerver Commerce carts created by visitors included in the A/B test. The test determines which page variant creates the highest average value for all those carts when picking a winner. If a visitor creates multiple carts, all the (purchased) carts are included in the total, which means that the visitor can &amp;ldquo;convert&amp;rdquo; many times in the test duration. On Episerver Commerce websites using different currencies, the test converts all carts to the same currency.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Developers can create own custom conversions which known as KPI (Key performance indicator)&lt;/li&gt;
&lt;li&gt;For the Commerce-related conversion goals, you required Episerver Commerce and then you can create commerce-related visitor groups criteria for personalization&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;PERSONALIZATION&lt;/h3&gt;
&lt;p&gt;Personalization in Episerver target the website content to selected visitor groups. The personalization feature is based on customized visitor groups that you create based on a set of personalization criteria. Episerver provides two types of criteria one for CMS and another for commerce.&lt;/p&gt;
&lt;p&gt;List of &lt;strong&gt;CMS&lt;/strong&gt; and &lt;strong&gt;Commerce&lt;/strong&gt; visitor group criteria, you can see the difference in both.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/bc54870ebf1b453081f56996bae0ab02.aspx&quot; width=&quot;793&quot; height=&quot;567&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Let&#39;s create visitor groups (personalization) for the CMS and Commerce following the below steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Episerver CMS visitor group&lt;/strong&gt;&lt;br /&gt;&lt;strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go to visitor group area and click to &lt;strong&gt;create&lt;/strong&gt; button&lt;/li&gt;
&lt;li&gt;Tap on &lt;strong&gt;&lt;em&gt;Time and Place criteria&lt;/em&gt;&lt;/strong&gt; options and drag `Time on Site` from the right section in `Drop new creation here` in the left section.&lt;/li&gt;
&lt;li&gt;Enter specific time in seconds e.g. 10&lt;/li&gt;
&lt;li&gt;Enter the visitor group name e.g. Time On-Site Visitor Group&lt;/li&gt;
&lt;li&gt;Click to &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/a6ab301b3794464d8252db15f4f8bd05.aspx&quot; width=&quot;798&quot; height=&quot;304&quot; /&gt;&lt;/p&gt;
&lt;h4&gt;Episerver Commerce visitor group&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Go to visitor group area and click to &lt;strong&gt;create&lt;/strong&gt; button&lt;/li&gt;
&lt;li&gt;Tap on &lt;strong&gt;&lt;em&gt;Commerce criteria&lt;/em&gt;&lt;/strong&gt; options and `Product in Cart or Wish List` from the right section in `Drop new creation here` in the left section.&lt;/li&gt;
&lt;li&gt;Enter the specific product codes. e.g. 123456&lt;/li&gt;
&lt;li&gt;Enter visitor group name e.g. Add to cart visitor group&lt;/li&gt;
&lt;li&gt;Click to &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/ddf28d1fd211485695171200277f5465.aspx&quot; width=&quot;816&quot; height=&quot;362&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;The below screenshot is an example of the cart page AB testing where you can see CMS and Commerce related conversion goals with personalization.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src=&quot;/link/cbd1e34ec6bf43fbbe066bd339ebb35b.aspx&quot; width=&quot;818&quot; height=&quot;596&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src=&quot;/link/6f50a0380c004467972e95f8f09c38a3.aspx&quot; width=&quot;814&quot; height=&quot;531&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3&gt;CODE&lt;/h3&gt;
&lt;p&gt;Using the below code you can retrieve the real-time A/B testing and visitor group (personalization) details. I have divided the code into four main part.&amp;nbsp;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;View Model&lt;/li&gt;
&lt;li&gt;Factory or Service&lt;/li&gt;
&lt;li&gt;Controller&lt;/li&gt;
&lt;li&gt;Assign the payload result into Google Analytics&lt;/li&gt;
&lt;/ul&gt;
&lt;h5&gt;View Model&lt;/h5&gt;
&lt;p&gt;Create two view models with the name&amp;nbsp;&lt;em&gt;AnalyticsViewModel &lt;/em&gt;and&amp;nbsp;&lt;em&gt;VisitorGroupViewModel.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;AnalyticsViewModel &lt;/strong&gt;view model is the payload response view model that returns the real-time A/B testing and personalization details into the response of payload.&lt;/p&gt;
&lt;pre&gt;public class AnalyticsViewModel &lt;br /&gt;{&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public bool AbTestRunning { get; set; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp; &amp;nbsp; public string AbTestId { get; set; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public string AbTestVariant { get; set; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public bool PersonalizationRunning { get; set; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public string PersonalizationId { get; set; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public string PersonalizationType { get; set; }&lt;br /&gt;&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;VisitorGroupViewModel&lt;/strong&gt; view model that helps to get the visitor group details into the created factory class.&lt;/p&gt;
&lt;pre&gt;public class VisitorGroupViewModel&lt;br /&gt;{&lt;br /&gt;   public string Id { get; set; }&lt;br /&gt;&lt;br /&gt;   public string Name { get; set; }&lt;br /&gt;}&lt;/pre&gt;
&lt;h5&gt;Factory or Service&lt;/h5&gt;
&lt;p&gt;Create a factory class with name &lt;strong&gt;AnalyticsViewModelFactory &lt;/strong&gt;within this factory you need to inject the following dependency that I listed below which helps to get visitor group (personalization) and A/B testing variation details for the current session and feed into the payload response.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IVisitorGroupRepository&lt;/li&gt;
&lt;li&gt;IVisitorGroupRoleRepository&lt;/li&gt;
&lt;li&gt;ITestManager&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;public class AnalyticsViewModelFactory&lt;br /&gt;{&lt;br /&gt;        private readonly IVisitorGroupRepository _visitorGroupRepository;&lt;br /&gt;        private readonly IVisitorGroupRoleRepository _visitorGroupRoleRepository;&lt;br /&gt;        private readonly ITestManager _testManager;&lt;br /&gt;&lt;br /&gt;        public AnalyticsViewModelFactory(&lt;br /&gt;            IVisitorGroupRepository visitorGroupRepository,&lt;br /&gt;            IVisitorGroupRoleRepository visitorGroupRoleRepository,&lt;br /&gt;            ITestManager testManager)&lt;br /&gt;        {&lt;br /&gt;            _visitorGroupRepository = visitorGroupRepository ?? throw new ArgumentNullException(nameof(visitorGroupRepository));&lt;br /&gt;            _visitorGroupRoleRepository = visitorGroupRoleRepository ?? throw new ArgumentNullException(nameof(visitorGroupRoleRepository));&lt;br /&gt;            _testManager = testManager ?? throw new ArgumentNullException(nameof(testManager));&lt;br /&gt;        }&lt;br /&gt;&lt;br /&gt;        public AnalyticsViewModel Create(HttpContextBase httpContext)&lt;br /&gt;        {&lt;br /&gt;            var visitorGroups = this.GetVisitorGroupsByCurrentUser(httpContext);&lt;br /&gt;            var visitorIds = visitorGroups?.Select(x =&amp;gt; x.Id).ToList();&lt;br /&gt;            var visitorNames = visitorGroups?.Select(x =&amp;gt; x.Name).ToList();&lt;br /&gt;&lt;br /&gt;            var activeTests = _testManager?.GetActiveTests();&lt;br /&gt;            var variantNames = activeTests?.Select(x =&amp;gt; x.Title).ToList();&lt;br /&gt;            var testIds = activeTests?.Select(x =&amp;gt; x?.Id.ToString()).ToList();&lt;br /&gt;&lt;br /&gt;            return new AnalyticsViewModel&lt;br /&gt;            {&lt;br /&gt;                AbTestRunning = activeTests?.Count &amp;gt; decimal.Zero,&lt;br /&gt;                AbTestId = string.Join(&quot;,&quot;, testIds ?? new List&amp;lt;string&amp;gt;()),&lt;br /&gt;                AbTestVariant = string.Join(&quot;,&quot;, variantNames ?? new List&amp;lt;string&amp;gt;()),&lt;br /&gt;                PersonalizationRunning = visitorIds?.Count &amp;gt; decimal.Zero,&lt;br /&gt;                PersonalizationId = string.Join(&quot;,&quot;, visitorIds ?? new List&amp;lt;string&amp;gt;()),&lt;br /&gt;                PersonalizationType = string.Join(&quot;,&quot;, visitorNames ?? new List&amp;lt;string&amp;gt;())&lt;br /&gt;            };&lt;br /&gt;        }&lt;br /&gt;&lt;br /&gt;        private List&amp;lt;VisitorGroupViewModel&amp;gt; GetVisitorGroupsByCurrentUser(HttpContextBase httpContext)&lt;br /&gt;        {&lt;br /&gt;            var visitorGroupList = new List&amp;lt;VisitorGroupViewModel&amp;gt;();&lt;br /&gt;            var user = httpContext.User;&lt;br /&gt;            var visitorGroups = _visitorGroupRepository.List();&lt;br /&gt;&lt;br /&gt;            foreach (var visitorGroup in visitorGroups)&lt;br /&gt;            {&lt;br /&gt;                if (_visitorGroupRoleRepository.TryGetRole(visitorGroup.Name, out var virtualRoleObject))&lt;br /&gt;                {&lt;br /&gt;                    if (virtualRoleObject.IsMatch(user, httpContext))&lt;br /&gt;                    {&lt;br /&gt;                        var viewModel = new VisitorGroupViewModel&lt;br /&gt;                        {&lt;br /&gt;                            Id = visitorGroup.Id.ToString(),&lt;br /&gt;                            Name = visitorGroup.Name&lt;br /&gt;                        };&lt;br /&gt;&lt;br /&gt;                        visitorGroupList.Add(viewModel);&lt;br /&gt;                    }&lt;br /&gt;                }&lt;br /&gt;            }&lt;br /&gt;&lt;br /&gt;            return visitorGroupList;&lt;br /&gt;        }&lt;br /&gt;}&lt;/pre&gt;
&lt;h5&gt;Controller&lt;/h5&gt;
&lt;p&gt;Create an endpoint&amp;nbsp;&lt;em&gt;v1/google/analytics&lt;/em&gt; using a controller with the name of &lt;strong&gt;AnalyticsController&lt;/strong&gt; where you will inject the factory/service to load the data into the payload.&lt;/p&gt;
&lt;pre&gt;&amp;nbsp; [RoutePrefix(&quot;v1/google/analytics&quot;)]&lt;br /&gt;&amp;nbsp; public class AnalyticsController : Controller&lt;br /&gt;&amp;nbsp; {&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; private readonly AnalyticsViewModelFactory _analyticsViewModelFactory;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; public AnalyticsController(AnalyticsViewModelFactory analyticsViewModelFactory)&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; {&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; _analyticsViewModelFactory = analyticsViewModelFactory ?? throw new ArgumentNullException(nameof(analyticsViewModelFactory));&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; }&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; [HttpGet]&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; public JsonResult GetAnalytics()&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; {&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; return this.JsonNet(_analyticsViewModelFactory.Create(this.HttpContext));&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; }&lt;br /&gt;&amp;nbsp; }&lt;/pre&gt;
&lt;h5&gt;Assign the payload result into Google Analytics&lt;/h5&gt;
&lt;p&gt;When you will hit the &lt;em&gt;v1/google/analytics&lt;/em&gt; endpoint then you get a result similar like below and then pass into the Google Analytics&amp;nbsp;script&amp;nbsp;&lt;em&gt;datalayer &lt;/em&gt;properties.&lt;/p&gt;
&lt;pre&gt;{&lt;br /&gt;&quot;abTestRunning&quot;: true,&lt;br /&gt;&quot;abTestId&quot;: &quot;a0a77ae9-31ec-4d74-8952-32a082535bb1,29de7423-f6ba-4212-a913-34e0517ffda3&quot;,&lt;br /&gt;&quot;abTestVariant&quot;: &quot;Cart A/B Test, AboutUs A/B Test&quot;,&lt;br /&gt;&quot;personalizationRunning&quot;: true,&lt;br /&gt;&quot;personalizationId&quot;: &quot;adb342d2-8ebb-4430-a305-e403c549452a&quot;,&lt;br /&gt;&quot;personalizationType&quot;: &quot;Time On Site Visitor Group&quot;&lt;br /&gt;}&lt;/pre&gt;
&lt;p&gt;Thanks for visiting my blog!&lt;/p&gt;
&lt;h5&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/h5&gt;</id><updated>2020-03-30T08:38:05.0000000Z</updated><summary type="html">Blog post</summary></entry></feed>